diff --git a/README.md b/README.md index bcefbfc..dc415b1 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ that index**, so they inherit it: treat `dashboard.html`, `handoff-*.md` and Start with one agent rather than all of them: ```sh -voyager scan --platform codex # codex | claude | zcode | dsh, comma-separated +voyager scan --platform codex # codex | claude | zcode | dsh | opencode, comma-separated ``` Once at least one of your agents has session history on this machine: @@ -614,6 +614,7 @@ For verification of actual zero-touch startup behavior, see [docs/DOGFOOD.md](ht | Cursor | `state.vscdb` (SQLite) | ✅ | ✅ | ❌ | ⚠️ in raw | ⚠️ | ❌ IDE only | | Kiro IDE | workspace-session JSON | ✅ | ❌ not persisted | ❌ | ❌ | ❌ | ❌ IDE only | | Antigravity | conversation SQLite (protobuf) | ⚠️ heuristic | ⚠️ heuristic | ⚠️ text | ⚠️ snapshots on disk | ❌ | ❌ IDE only | +| OpenCode | read-only SQLite (`~/.local/share/opencode`) | ✅ | ✅ | ⚠️ shell only | ❌ | ✅ session totals | ❌ read-only index | The **Resume** column names the command Voyager will run for that provider — it says the command exists and is wired up, **not** that a resume has been observed @@ -622,6 +623,14 @@ treat every `✅` there as `NOT VERIFIED` until the verification table above carries a row saying otherwise. (The `*_continuity_verdict.md` files cover hook injection, which is a different capability.) +OpenCode is indexed **read-only** from its SQLite database (both the current +`session_v2` schema and the pre-v2 `session`/`message`/`part` shape). That +gives you messages, tool calls and results in `search`/`show`/`repo` and lets +another agent continue from the context — it is **not** native resume, and no +resume command, startup hook or transcript writer exists for it. Verified only +against synthetic fixtures (`tests/test_opencode.py`); a real install has not +been observed yet. Set `VOYAGER_OPENCODE_DB` to point at a specific database. + Cursor and Antigravity adapters are marked experimental: Cursor reads its key-value store read-only and Antigravity decodes protobuf blobs heuristically (no public schema). Full per-field availability matrix and diff --git a/README.zh-CN.md b/README.zh-CN.md index a074ba0..b2b8a10 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -224,7 +224,7 @@ voyager --db ~/.voyager/demo.db show codex:demo-auth-01 # 编译好的会话 先只扫一个 Agent,而不是全部: ```sh -voyager scan --platform codex # codex | claude | zcode | dsh,逗号分隔 +voyager scan --platform codex # codex | claude | zcode | dsh | opencode,逗号分隔 ``` 只要本机至少有一个 Agent 留有会话历史: @@ -422,12 +422,20 @@ voyager continue --thread thr_xxxx # 或 voyager switch claude --thread ... | Cursor | `state.vscdb`(SQLite) | ✅ | ✅ | ❌ | ⚠️ 在 raw 里 | ⚠️ | ❌ 仅 IDE | | Kiro IDE | workspace-session JSON | ✅ | ❌ 未持久化 | ❌ | ❌ | ❌ | ❌ 仅 IDE | | Antigravity | 会话 SQLite(protobuf) | ⚠️ 启发式 | ⚠️ 启发式 | ⚠️ 文本 | ⚠️ 快照在磁盘 | ❌ | ❌ 仅 IDE | +| OpenCode | 只读 SQLite(`~/.local/share/opencode`) | ✅ | ✅ | ⚠️ 仅 shell | ❌ | ✅ 会话汇总 | ❌ 只读索引 | **Resume** 一列写的是 Voyager 会为该 provider 运行的命令 —— 它说明命令存在且已接好, **不**说明有人观察到 resume 真的成功过。目前**没有任何 provider 的 native resume 被端到端跑过**, 所以在那张验证表出现对应行之前,请把这一列的每个 `✅` 都当作 `NOT VERIFIED`。 (`*_continuity_verdict.md` 覆盖的是 hook 注入,那是另一种能力。) +OpenCode 从它的 SQLite 数据库**只读**索引(同时支持当前 `session_v2` 和旧版 +`session`/`message`/`part` 两套表结构)。消息、工具调用与结果会进入 +`search`/`show`/`repo`,也能给另一个 Agent 提供接续上下文——但这**不是** +native resume,也没有为其实现 resume 命令、启动 hook 或 transcript writer。 +目前只用合成 fixture 验证过(`tests/test_opencode.py`),尚未在真实安装上观察。 +可用 `VOYAGER_OPENCODE_DB` 指向指定数据库。 + Cursor 与 Antigravity 适配器标记为实验性:Cursor 只读解析它的 KV 存储, Antigravity 用启发式方式解码 protobuf blob(无公开 schema)。 每个工具的逐字段可得性矩阵和数据源路径见 [docs/RECON.md](docs/RECON.md)。 diff --git a/docs/INDEX.md b/docs/INDEX.md index c915ad6..d2ff49e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -66,6 +66,8 @@ true on a date; it is not a description of the product and is not updated. | [audits/2026-11-opencode-adapter-design.md](audits/2026-11-opencode-adapter-design.md) | DESIGN | maintainer | frozen — Phase 3 input: verified schema, options, risks, acceptance criteria | | [audits/2026-11-phase-2-report.md](audits/2026-11-phase-2-report.md) | AUDIT | maintainer | frozen — Phase 2 delivery: docs, consistency checker, contract evidence, issue triage | | [audits/2026-11-v1.3.1-final-release-report.md](audits/2026-11-v1.3.1-final-release-report.md) | AUDIT | maintainer | frozen — v1.3.1 release readiness: gates, artifact hashes, rollback, publish steps | +| [audits/2026-11-opencode-phase3-report.md](audits/2026-11-opencode-phase3-report.md) | DESIGN | maintainer | frozen — Phase 3 implementation: schema corrections, UNIT_VERIFIED scope, NOT_VERIFIED gaps | +| [audits/2026-11-opencode-v20x-real-gate.md](audits/2026-11-opencode-v20x-real-gate.md) | AUDIT | maintainer | frozen — v2.0.x inline-schema compatibility: real-install Gate A/B/C, no user data, no LIVE_VERIFIED claim | ## Performance evidence diff --git a/docs/audits/2026-11-opencode-phase3-report.md b/docs/audits/2026-11-opencode-phase3-report.md new file mode 100644 index 0000000..4f9abc6 --- /dev/null +++ b/docs/audits/2026-11-opencode-phase3-report.md @@ -0,0 +1,162 @@ +# OpenCode adapter — Phase 3 implementation report + +> **Status: synthetic-verified only.** This round built and tested the adapter +> against **generated SQLite fixtures**. No real OpenCode install was read, no +> provider process was launched, and no native resume was attempted. Those +> claims stay `NOT_VERIFIED`; nothing here should be read as `LIVE_VERIFIED`. + +## A. What was verified upstream, and from where + +Checked on 2026-11-10 against `anomalyco/opencode` release **v2.2.6** +(2026-11-06). `packages/core/src/session/sql.ts` at that tag is +**byte-identical** to v1.19.5 (the version the Phase 2 design note used), so +the schema did not move between them. +Upstream sources were read from a scratch checkout of that tag, outside the +repository; they are not committed as an artifact. + +| Fact | Source | +|---|---| +| Tables `session_v2`, `session_message`, `session_part`, `project` | `packages/core/src/session/sql.ts` | +| `session_part` has `session_id`, `ordinal`, `type`, `call_id` and `data` | same | +| Timestamps are `Date.now()` milliseconds | `packages/core/src/database/schema.sql.ts` (`Timestamps`) | +| Tool call + result live in ONE part's `state` (`status`, `input`, `content`, `error`) | `packages/schema/src/session-message.ts`, `packages/schema/src/tool.ts` | +| Message `type` values include `user`, `assistant`, `synthetic`, `system`, `skill`, `shell`, `compaction`, `idle`, `*-switched` | `packages/schema/src/session-message.ts` | +| Data root is `XDG_DATA_HOME/opencode` or `$HOME/.local/share/opencode` on all platforms | `packages/util/src/global-roots.ts` | +| `OPENCODE_DB` overrides the filename; channel builds use `opencode-.db` | `packages/cli/src/database-path.ts` | +| Pre-v2 schema uses `session` / `message` / `part` (different columns) | `packages/core/test/v1-migration.test.ts` (lines 834–885), `src/database/migration.ts` | + +### Corrections to the Phase 2 design note + +The design note was right about the recommended approach (read-only SQLite) but +wrong or incomplete on three points, all found by reading upstream code: + +1. It listed `session_part` as `(message_id, ordinal) PRIMARY KEY, type, + call_id, data`. The real table **also has `session_id`**, `time_created` and + `time_updated`. Discovery of parts can be scoped by session. +2. It assumed `call_id` joins a tool *call* row to a separate *result* row. + **There is no separate result row.** One part carries both: `state.input` is + the call, `state.content` (array of `{type:"text",text}`) or `state.error` + is the result. `call_id` is recorded as `tool_call_id` but is not the join + key. The adapter emits `tool_call` always, and `tool_result` only when + upstream actually recorded a completed or errored state. +3. It listed the message JSON shapes as "to confirm". They are now read from + `packages/schema/src/session-message.ts`, including the distinction between + a real `user` message and provider-generated `synthetic`/`system` messages. + That distinction is what stops an agent's own claim from being indexed as a + human instruction. + +The design note also said to check `PRAGMA user_version`; upstream bootstrap +keys off the **presence of `session` / `session_v2` tables** instead +(`src/database/migration.ts`), so the adapter probes table/column shape and +treats `user_version` as non-authoritative. + +## B. What was implemented + +`voyager/adapters/opencode.py`, registered in `adapters/__init__.py`. + +- **Read-only.** `file:...?mode=ro` plus `PRAGMA query_only=ON`. Never + `immutable=1`: that would hide committed WAL pages and silently drop live + turns. No migration, no writes, no deletion of provider files. +- **Schema probe.** `absent` / `ok` / `unreadable` / `unknown-schema`, with a + real table read so a truncated file is not accepted as an empty index. + Both `v2` and `legacy` shapes are detected by table + required columns. +- **WAL-aware incremental scanning.** `-wal` and `-shm` are returned by + `discover()` deliberately: the scanner's `source_changed()` only stats + discovered paths, so without this a running agent's new turns stay invisible + until a checkpoint rewrites the main `.db`. Verified end to end through the + real `Store` + `run_scan` (6 → 7 events, new turn searchable). +- **Mapping.** Session (title/cwd/repo/model/tokens/timestamps), messages, + parts, tool calls and results, Unicode, reasoning, file parts, shell + messages, inline assistant content fallback. `seq` is a stable per-session + counter; `ts` is ms → s. +- **Provenance.** `origin` comes from the message `type` column, never from + text. `synthetic`/`system`/`skill` messages become `meta` with + `provider_system`, not `user`; a session with `parent_id` marks its user + turns `provider_synthetic`. +- **Failure behaviour.** Unknown/corrupt/locked databases are named on stderr + and reported via `unusable_sources()` / `unrecognised_sources()`, so a scan + never quietly reports "0 sessions". A vanished database follows the existing + O2 rule: history is retained as `SOURCE_MISSING`. +- **Streaming.** `emit=` produces the same bundles as the list form. +- **No native resume.** `can_resume = can_fork = False`, `resume_cmd = None`, + and the capability matrix says `NOT_FOUND`. + +### A real limitation, stated honestly + +Opening a WAL database even with `mode=ro` can make **SQLite itself create a +`-shm` shared-memory index file** if one does not exist. That is a SQLite side +effect, not a write to session history; the adapter does not create it +deliberately and never touches session rows. The alternative (`immutable=1`) +would hide committed WAL pages, which is worse. The test asserts the main DB +bytes, WAL bytes and all row counts are unchanged, and documents the `-shm` +behaviour rather than claiming "no files are ever created". + +## C. Tests (all synthetic) + +`tests/opencode_fixtures.py` builds databases from reviewable DDL (no committed +binaries). `tests/test_opencode.py` — **27 passed**: + +- both schemas, including the legacy `tool`/`callID`/`state.output` shape; +- tool call/result pairing, running tool with no invented result, error state + without a fabricated exit code; +- synthetic/system messages not promoted to human turns; subagent sessions; +- Unicode (CJK/Japanese/Korean/emoji), malformed JSON rows, empty sessions; +- corrupt / unknown-schema / absent databases and the named warnings; +- read-only behaviour (DB bytes, WAL bytes and row counts unchanged); +- WAL discovery, WAL-driven rescan through the real scanner, idempotence, + streaming equivalence, 2-character CJK search via `run_scan`, `SOURCE_MISSING` + retention, and a 5000-message session. + +Isolation: `tests/iso_guard.py` now covers `OPENCODE_DB`, +`VOYAGER_OPENCODE_DB` (explicit paths that bypass directory redirection) and +the `opencode/` directory derived from `XDG_DATA_HOME`. +`tests/test_isolation_guard.py` gained 4 regressions; a **mutation check** +confirmed the derived-path test actually fails when `_DERIVED_DATA_DIRS` loses +its `XDG_DATA_HOME` entry — the guard is load-bearing, not decorative. +Related suites: 62 passed / 2 skipped (isolation + adapter), 50 passed +(capability/doctor/adapters), 66 passed / 2 skipped (streaming/docs). + +### Verification-level ledger + +| Capability | Level | Evidence | +|---|---|---| +| Session discovery / indexing (synthetic DB) | `UNIT_VERIFIED` | `tests/test_opencode.py` | +| Message / part / tool mapping | `UNIT_VERIFIED` | same | +| WAL incremental rescan, idempotence, SOURCE_MISSING | `UNIT_VERIFIED` | real `Store` + `run_scan` in-test | +| Search / show / repo / merge / continue consume the index | `INTEGRATION_TESTED` via shared Store path; **not separately exercised for OpenCode** | existing CLI tests + `run_scan` search test | +| Real OpenCode install read | `NOT_VERIFIED` | no install observed | +| Native resume / startup hook / transcript writer | `NOT_FOUND` | not implemented; adapter denies resume | +| Cross-agent continuation from an OpenCode session | `NOT_VERIFIED` | no live agent run | + +## D. Known gaps / next round + +1. **Real-install verification is the top gap.** The message/part JSON shapes + were read from upstream schema code, not from a row produced by a running + OpenCode. A real install (any channel) should be pointed at with + `VOYAGER_OPENCODE_DB`, scanned, and its rows compared with the fixtures. + Only then may any cell move to `LIVE_VERIFIED`. +2. `session_v2` has more columns (`fork_*`, `summary_*`, `revert`, `permission`, + `time_archived`, …) that are intentionally not indexed. Archived sessions + are not filtered — a policy decision, not a bug. +3. `session_part.call_id` may be NULL on some builds. The adapter falls back to + the part's `id`/legacy `callID`; where none exists, `tool_call_id` stays NULL + rather than being invented. +4. Channel databases (`opencode-.db`) are all candidates and all get + probed; a machine with several channels will index each. This is deliberate + but untested against a real multi-channel install. +5. The static diagram (`scripts/make_diagram.py`) still shows eight platforms; + it is a generated illustration, not the capability source of truth. Updating + it is a separate asset change. +6. `OPENCODE_DB` can be a filename relative to the data dir or `:memory:`. + Relative names are resolved against the data dir; `:memory:` is skipped + because there is no file to index. Both are synthetic-tested, not real-tested. + +## E. Compatibility impact + +- Additive: one new provider in `enabled_adapters` and `PROVIDERS`. No change + to `Store`, the continuity engine, the bridge schema, or any existing CLI + contract. `voyager doctor` / `verify --matrix` gain one row whose states are + `UNIT_VERIFIED` at best. +- No database migration. Existing indexes are untouched. +- No new runtime dependency (stdlib `sqlite3` only). +- Not released: this work lives on the `phase3-opencode` branch only. diff --git a/docs/audits/2026-11-opencode-v20x-real-gate.md b/docs/audits/2026-11-opencode-v20x-real-gate.md new file mode 100644 index 0000000..342791b --- /dev/null +++ b/docs/audits/2026-11-opencode-v20x-real-gate.md @@ -0,0 +1,119 @@ +# OpenCode adapter — v2.0.x inline-schema compatibility and real-install gate + +**Status**: real-install evidence recorded; **synthetic results remain +`UNIT_VERIFIED`**. This document is the gate record for the change that added +support for the intermediate v2.0.x schema. It contains **no user message text, +no database path, and no file hash** — only structural facts and counts. + +Related: [`2026-11-opencode-adapter-design.md`](2026-11-opencode-adapter-design.md) +(design input), [`2026-11-opencode-phase3-report.md`](2026-11-opencode-phase3-report.md) +(Phase 3 implementation, frozen; its "no real install" statement applies to that +round only). + +## 1. Why this change exists + +Phase 3 was validated against upstream `anomalyco/opencode` v2.2.x, where a +message's parts live in a separate `session_part` table. A real install on the +maintainer machine runs **v2.0.29**, an intermediate shape: + +- Tables include `session_v2` (36 columns), `session_message` (7 columns) and + `project`, among 20 tables in total. +- **There is no `session_part` table.** +- `session_message.data` carries the content **inline**: a `user` row has a + `text` string; an `assistant` row has a `content` array of + `text` / `reasoning` / `tool` items; a tool's exit code is in + `state.metadata.exit`, not `state.exit`. + +Because `SCHEMAS` required `session_part` for the `v2` shape, `detect_schema()` +matched nothing, `probe()` returned `unknown-schema`, `discover()` returned an +empty set, and **no session from that install was indexed**. The 27 synthetic +tests could not catch this: their fixtures model v2.2.x. That is the concrete +reason a synthetic pass must never be reported as `LIVE_VERIFIED`. + +## 2. What changed + +`voyager/adapters/opencode.py`: + +- **Third shape `v2-inline`** in `SCHEMAS`, detected **structurally**: it + requires `session_v2` + `session_message` and, via `SCHEMA_FORBID`, the + **absence** of `session_part`. No product version string is consulted, so a + v2.2.x database (which also has `session_message`) still resolves to `v2`. +- **Inline parts**: `_inline_parts()` reads `data.content`; `text` → assistant + event, `reasoning` → reasoning event, `tool` → tool call/result through the + existing `_tool_events()`. +- **Tool exit code**: `_tool_exit()` reads `state.exit`, `state.exit_code` or + `state.metadata.exit`, and treats `0` as a real code (not "missing"). +- **Provider-generated rows** (`system`, `synthetic`, `compaction`, `idle`, + `model-switched`, …) stay `meta` with `origin=provider_system`; a `compaction` + row's `summary` is used when present. Nothing is promoted to a human turn. +- **Honest degradation**: a running tool gets no fabricated result; empty user + text emits no user event; unknown types are skipped rather than guessed. +- **Discovery priority**: an explicit `VOYAGER_OPENCODE_DB` / `OPENCODE_DB` is + now **authoritative** — the default data-directory database and channel + builds are not probed when an override is set. This prevents an + operator-named (or test-named) database from silently causing a read of a + real HOME database. + +Unchanged: read-only access (`mode=ro` + `PRAGMA query_only=ON`, never +`immutable=1`), `Store`, continuity, the bridge schema, every other adapter, +`can_resume = can_fork = False`, and the five-state evidence semantics. + +## 3. Real-install gate + +Method: the real database and its `-wal` / `-shm` were copied byte-for-byte +into an isolated scratch directory; **every SQLite connection targeted the +copy**. `HOME`, `USERPROFILE` and `XDG_DATA_HOME` were all redirected into +scratch, and the run asserts that no candidate path lies outside it. The real +directory was only hashed before and after. + +### Gate A — schema and query-result comparison: PASS + +- `probe()` → `('ok', 'v2-inline')`. +- `discover()` → the database plus its `-wal` and `-shm` companions. +- **8 of 8** real sessions indexed; **2,539** real message rows parsed. +- Independent read-only `sqlite3` oracle (no adapter code) agreed on: 20 tables, + `session_v2` = 8 rows, `session_message` = 2,539 rows, `user_version` = 0. +- Per-session event kinds observed: `user`, `assistant`, `reasoning`, + `tool_call`, `tool_result`, `meta`; tool `exit_code` captured (including `0`). +- `session_message.type` values seen: `assistant`, `user`, `idle`, `compaction`, + `system`, `synthetic`, `model-switched`; only the first two map to human / + assistant content, the rest stay meta or are skipped. + +### Gate B — no modification of user data: PASS + +| Check | Result | +|---|---| +| Real `.db` SHA-256 before/after | identical | +| Real `-wal` SHA-256 before/after | identical | +| Real `-shm` SHA-256 before/after | identical (final isolated run) | +| Row counts, all 20 tables, copy vs oracle | identical | +| `PRAGMA user_version` | unchanged | +| New files in the isolated dir | none | + +An earlier ad-hoc probe that set an override **without** isolating `HOME` +caused SQLite to rewrite the real `-shm` (a reconstructible shared-memory +index; `.db`, `-wal` and all row counts were unchanged). That is what motivated +the override-priority change in §2; after it, the run is fully hermetic. + +### Gate C — capability ledger: HELD + +| Capability | Level | +|---|---| +| Synthetic v2.2.x / legacy / v2.0.x mapping | `UNIT_VERIFIED` | +| Real v2.0.x discovery, message/role mapping, inline tool calls, timestamps | **real-install observed** (Gate A) | +| Native resume / startup hook / transcript writer | `NOT_FOUND` (adapter denies resume) | +| Cross-agent continuation from an OpenCode session | `NOT_VERIFIED` | +| File attachments in `user.files` on the inline shape | `NOT_VERIFIED` (empty in the observed data) | +| Multi-channel real install | `NOT_VERIFIED` | +| `LIVE_VERIFIED` | **none claimed** | + +## 4. Limits of this evidence + +- One install, one platform (Windows), one version (v2.0.29). Other v2.0.x + builds may differ; the detection is structural so a shape change fails + loudly as `unknown-schema` rather than being misparsed. +- `message_count` is the count of `user` + `assistant` **content** events, not + raw `session_message` rows (the existing contract, unchanged). +- No agent was launched, no native resume attempted, no release made. +- A synthetic v2.0.x fixture now covers this shape so the gap cannot silently + return; it remains synthetic and is labelled as such. diff --git a/tests/iso_guard.py b/tests/iso_guard.py index 2ea9d0d..13f8df7 100644 --- a/tests/iso_guard.py +++ b/tests/iso_guard.py @@ -55,6 +55,8 @@ _HOME_DATA_DIRS = ( ".voyager", ".codex", ".claude", ".dsh", ".grok", ".zcode", ".cursor", ".gemini", ".config", + # OpenCode uses the XDG data root under HOME when XDG_DATA_HOME is unset. + ".local/share/opencode", ) #: Agent data under ``%APPDATA%`` on Windows. Kiro is the one adapter that @@ -229,7 +231,12 @@ def guard_scan_roots(*roots, what: str = "scan root"): #: adapters actually read; the rest are here so a future adapter that adds #: them cannot reintroduce the same hole unnoticed. _DISCOVERY_ENV = ("HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", - "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME") + "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", + # Explicit database overrides bypass every directory + # redirection above: OPENCODE_DB / VOYAGER_OPENCODE_DB can + # name a file directly. Their literal value is checked, and + # XDG_DATA_HOME is checked for the derived opencode dir. + "OPENCODE_DB", "VOYAGER_OPENCODE_DB") #: Which data directories each variable *derives*. Checking the variable's #: own value is not enough: ``HOME=C:\\Users\\me`` does not look like agent @@ -240,6 +247,10 @@ def guard_scan_roots(*roots, what: str = "scan root"): "HOME": _HOME_DATA_DIRS, "USERPROFILE": _HOME_DATA_DIRS, "APPDATA": _APPDATA_DATA_DIRS, + # OpenCode resolves $XDG_DATA_HOME/opencode/opencode.db. A redirected + # XDG_DATA_HOME whose derived opencode/ still lands in the real profile + # must be refused, not waved through as "just a temp directory". + "XDG_DATA_HOME": ("opencode",), } diff --git a/tests/opencode_fixtures.py b/tests/opencode_fixtures.py new file mode 100644 index 0000000..42939d9 --- /dev/null +++ b/tests/opencode_fixtures.py @@ -0,0 +1,390 @@ +"""Synthetic OpenCode databases for the adapter tests. + +The DDL here mirrors the *upstream* schema read on 2026-11-10 from +anomalyco/opencode v2.2.6 (``packages/core/src/session/sql.ts``), and the +pre-v2 shape from ``packages/core/test/v1-migration.test.ts``. It is kept as +code rather than a committed .db so a reviewer can diff the schema. + +These are SYNTHETIC. No real OpenCode install was read; tests that use them +are UNIT_VERIFIED, never LIVE_VERIFIED. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +# --- current schema (v2) --------------------------------------------------- + +V2_DDL = """ +CREATE TABLE project ( + id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, + icon_url text, icon_url_override text, icon_color text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_initialized integer, time_active integer NOT NULL DEFAULT 0, + time_archived integer, sandboxes text NOT NULL, commands text +); +CREATE TABLE session_v2 ( + id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, + parent_id text, fork_session_id text, fork_boundary text, + slug text NOT NULL, directory text NOT NULL, path text, title text, + version text NOT NULL, share_url text, + summary_additions integer, summary_deletions integer, summary_files integer, + summary_diffs text, metadata text, cost real NOT NULL DEFAULT 0, + tokens_input integer NOT NULL DEFAULT 0, + tokens_output integer NOT NULL DEFAULT 0, + tokens_reasoning integer NOT NULL DEFAULT 0, + tokens_cache_read integer NOT NULL DEFAULT 0, + tokens_cache_write integer NOT NULL DEFAULT 0, + revert text, permission text, agent text, model text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_busy integer, time_idle integer, time_viewed integer, + idle_outcome text, time_compacting integer, time_archived integer, + time_suspended integer, resume_attempts integer NOT NULL DEFAULT 0 +); +CREATE TABLE session_message ( + id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, + seq integer NOT NULL, time_created integer NOT NULL, + time_updated integer NOT NULL, data text NOT NULL +); +CREATE TABLE session_part ( + message_id text NOT NULL, session_id text NOT NULL, + ordinal integer NOT NULL, type text NOT NULL, call_id text, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL, + PRIMARY KEY (message_id, ordinal) +); +""" + +# --- pre-v2 schema --------------------------------------------------------- + +LEGACY_DDL = """ +CREATE TABLE project ( + id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, + time_created integer NOT NULL, time_updated integer NOT NULL +); +CREATE TABLE session ( + id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, + parent_id text, slug text NOT NULL, directory text NOT NULL, path text, + title text NOT NULL, version text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL +); +CREATE TABLE message ( + id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL +); +CREATE TABLE part ( + id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL +); +""" + +T0 = 1_790_000_000_000 # a fixed millisecond epoch, 2026-ish + + +def _tool_part(name="bash", call_id="call_1", status="completed", + inp=None, output="done", error=None): + """One tool part: the CALL and its RESULT live in the same `state`.""" + state = {"status": status, + "input": inp if inp is not None else {"command": "pytest -q"}} + if status == "completed": + state["content"] = [{"type": "text", "text": output}] + if status == "error": + state["error"] = {"type": "tool.execution", "message": error or "boom"} + return { + "type": "tool", "id": call_id, "name": name, "state": state, + "time": {"created": T0 + 30, "completed": T0 + 40}, + } + + +def make_v2(path: Path, *, title="修复补丁", n_sessions=1, + text="补丁已完成", tool=True, unicode=True, + n_messages=0, wal=False) -> Path: + """Build a v2 database. ``n_messages`` adds filler turns for a large fixture.""" + path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(str(path)) + try: + # WAL is what OpenCode's live database uses, and it is also the mode + # whose side files we index. Setting it before any DDL avoids a + # rollback journal (which some sandboxes refuse to create/remove). + con.execute("PRAGMA journal_mode=WAL") + con.executescript(V2_DDL) + con.execute( + "INSERT INTO project (id, worktree, vcs, name, time_created, " + "time_updated, sandboxes) VALUES (?,?,?,?,?,?,'[]')", + ("prj_1", "/repo/demo", "git", "demo", T0, T0)) + for s in range(n_sessions): + sid = "ses_%03d" % s + con.execute( + "INSERT INTO session_v2 (id, project_id, slug, directory, " + "title, version, time_created, time_updated, model, " + "tokens_input, tokens_output) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (sid, "prj_1", "slug", "/repo/demo", title, "2", + T0 + s * 1000, T0 + s * 1000 + 900, + json.dumps({"id": "claude-sonnet-4", "providerID": "anthropic"}), + 120, 40)) + seq = 0 + # user + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_u0" % s, sid, "user", seq, T0, T0, + json.dumps({"text": text, "time": {"created": T0}}, + ensure_ascii=False))) + seq += 1 + # assistant with text / reasoning / tool parts + parts = [{"type": "reasoning", "text": "先看一下测试"}, + {"type": "text", "text": "正在处理"}] + if tool: + parts.append(_tool_part()) + if unicode: + parts.append({"type": "text", "text": "日本語 한국어 🚀"}) + mid = "msg_%s_a0" % s + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + (mid, sid, "assistant", seq, T0 + 10, T0 + 10, + json.dumps({"agent": "build", "time": {"created": T0 + 10}}, + ensure_ascii=False))) + for ordinal, p in enumerate(parts): + call_id = p.get("id") if p.get("type") == "tool" else None + con.execute( + "INSERT INTO session_part VALUES (?,?,?,?,?,?,?,?)", + (mid, sid, ordinal, p["type"], call_id, T0 + 20, + T0 + 20, json.dumps(p, ensure_ascii=False))) + seq += 1 + for i in range(n_messages): + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_f%d" % (s, i), sid, "user", seq, + T0 + 100 + i, T0 + 100 + i, + json.dumps({"text": "filler %d" % i, + "time": {"created": T0 + 100 + i}}))) + seq += 1 + con.commit() + finally: + con.close() + return path + + +# --- intermediate v2.0.x schema: parts INLINE in session_message.data ------ + +#: No ``session_part`` table. Message content lives in ``data``: a ``user`` row +#: has a ``text`` string, an ``assistant`` row has a ``content`` array of +#: text / reasoning / tool items. This mirrors a real install's structure; the +#: values below are synthetic and contain no user data. +V20_INLINE_DDL = """ +CREATE TABLE project ( + id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, + icon_url text, icon_url_override text, icon_color text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_initialized integer, time_active integer NOT NULL DEFAULT 0, + time_archived integer, sandboxes text NOT NULL, commands text +); +CREATE TABLE session_v2 ( + id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, + parent_id text, fork_session_id text, fork_boundary text, + slug text NOT NULL, directory text NOT NULL, path text, title text, + version text NOT NULL, share_url text, + summary_additions integer, summary_deletions integer, summary_files integer, + summary_diffs text, metadata text, cost real NOT NULL DEFAULT 0, + tokens_input integer NOT NULL DEFAULT 0, + tokens_output integer NOT NULL DEFAULT 0, + tokens_reasoning integer NOT NULL DEFAULT 0, + tokens_cache_read integer NOT NULL DEFAULT 0, + tokens_cache_write integer NOT NULL DEFAULT 0, + revert text, permission text, agent text, model text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_busy integer, time_idle integer, time_viewed integer, + idle_outcome text, time_compacting integer, time_archived integer, + time_suspended integer, resume_attempts integer NOT NULL DEFAULT 0 +); +CREATE TABLE session_message ( + id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, + seq integer NOT NULL, time_created integer NOT NULL, + time_updated integer NOT NULL, data text NOT NULL +); +""" + + +def _inline_tool(name="bash", call_id="call_1", status="completed", + inp=None, output="done", exit_code=0, error=None): + """A v2.0.x inline tool item: exit lives in ``state.metadata.exit``.""" + state = {"status": status, + "input": inp if inp is not None else {"command": "pytest -q"}, + "metadata": {"status": status, "truncated": False, + "exit": exit_code}} + if status == "completed": + state["content"] = [{"type": "text", "text": output}] + if status == "error": + state.pop("metadata", None) + state["error"] = {"type": "tool.execution", "message": error or "boom"} + return {"type": "tool", "id": call_id, "name": name, "executed": True, + "state": state, "time": {"created": T0 + 30, "completed": T0 + 40}} + + +def make_v20_inline(path: Path, *, title="内联形状", n_sessions=1, + text="补丁已完成", tool=True, unicode=True, + n_messages=0, wal=False, empty_user=False, + running_tool=False, meta=True) -> Path: + """Build a v2.0.x database: inline ``data.content``, no ``session_part``.""" + path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(str(path)) + try: + con.execute("PRAGMA journal_mode=WAL") + con.executescript(V20_INLINE_DDL) + con.execute( + "INSERT INTO project (id, worktree, vcs, name, time_created, " + "time_updated, sandboxes) VALUES (?,?,?,?,?,?,'[]')", + ("prj_1", "/repo/inline", "git", "inline", T0, T0)) + for s in range(n_sessions): + sid = "ses_inline_%03d" % s + con.execute( + "INSERT INTO session_v2 (id, project_id, slug, directory, " + "title, version, time_created, time_updated, model, " + "tokens_input, tokens_output) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + (sid, "prj_1", "slug", "/repo/inline", title, "2.0.29", + T0 + s * 1000, T0 + s * 1000 + 900, + json.dumps({"id": "claude-sonnet-4", + "providerID": "anthropic", "variant": "default"}), + 120, 40)) + seq = 0 + # user: text is a plain string in this shape (files/agents arrays) + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_u0" % s, sid, "user", seq, T0, T0, + json.dumps({"text": "" if empty_user else text, + "files": [], "agents": [], + "time": {"created": T0}}, ensure_ascii=False))) + seq += 1 + # assistant: content array holds the parts inline + content = [{"type": "reasoning", "text": "先看一下测试", + "time": {"created": T0 + 10}}, + {"type": "text", "text": "正在处理"}] + if tool: + content.append(_inline_tool( + status="running" if running_tool else "completed")) + if unicode: + content.append({"type": "text", "text": "日本語 한국어 🚀"}) + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_a0" % s, sid, "assistant", seq, T0 + 10, T0 + 10, + json.dumps({"agent": "build", + "model": {"id": "claude-sonnet-4", + "providerID": "anthropic"}, + "time": {"created": T0 + 10, + "completed": T0 + 20}, + "content": content}, ensure_ascii=False))) + seq += 1 + if meta: + # provider-generated rows must stay meta, never a human turn + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_sys" % s, sid, "system", seq, T0 + 11, T0 + 11, + json.dumps({"text": "system note", "time": {"created": T0 + 11}}, + ensure_ascii=False))) + seq += 1 + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_cmp" % s, sid, "compaction", seq, T0 + 12, T0 + 12, + json.dumps({"summary": "compacted summary", "reason": "auto", + "status": "completed", + "time": {"created": T0 + 12}}, + ensure_ascii=False))) + seq += 1 + for i in range(n_messages): + con.execute( + "INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_%s_f%d" % (s, i), sid, "user", seq, + T0 + 100 + i, T0 + 100 + i, + json.dumps({"text": "filler %d" % i, "files": [], + "time": {"created": T0 + 100 + i}}))) + seq += 1 + con.commit() + finally: + con.close() + return path + + +def make_legacy(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(str(path)) + try: + con.execute("PRAGMA journal_mode=WAL") + con.executescript(LEGACY_DDL) + con.execute("INSERT INTO project VALUES (?,?,?,?,?,?)", + ("prj_1", "/repo/legacy", "git", "legacy", T0, T0)) + con.execute( + "INSERT INTO session (id, project_id, slug, directory, title, " + "version, time_created, time_updated) VALUES (?,?,?,?,?,?,?,?)", + ("ses_legacy", "prj_1", "slug", "/repo/legacy", "legacy session", + "1", T0, T0 + 500)) + con.execute("INSERT INTO message VALUES (?,?,?,?,?)", + ("msg_l_u", "ses_legacy", T0, T0, + json.dumps({"role": "user", "text": "legacy prompt"}))) + con.execute("INSERT INTO part VALUES (?,?,?,?,?,?)", + ("prt_l_1", "msg_l_u", "ses_legacy", T0, T0, + json.dumps({"type": "text", "text": "legacy prompt"}))) + con.execute("INSERT INTO message VALUES (?,?,?,?,?)", + ("msg_l_a", "ses_legacy", T0 + 10, T0 + 10, + json.dumps({"role": "assistant"}))) + # legacy tool shape: tool/callID/state.output (not name/id/content) + con.execute("INSERT INTO part VALUES (?,?,?,?,?,?)", + ("prt_l_2", "msg_l_a", "ses_legacy", T0 + 20, T0 + 20, + json.dumps({"type": "tool", "tool": "read", + "callID": "legacy_call", + "state": {"status": "completed", + "input": {"path": "a.py"}, + "output": "legacy output"}}))) + con.commit() + finally: + con.close() + return path + + +def make_unknown(path: Path) -> Path: + """Readable SQLite, but not an OpenCode database.""" + path.parent.mkdir(parents=True, exist_ok=True) + con = sqlite3.connect(str(path)) + try: + con.execute("PRAGMA journal_mode=WAL") + con.execute("CREATE TABLE something_else (id text)") + con.commit() + finally: + con.close() + return path + + +def make_corrupt(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"SQLite format 3\x00" + b"\x00" * 200) + return path + + +def open_writer(db: Path): + """A live writer's connection, kept open by the caller. + + SQLite checkpoints and removes ``-wal`` when the LAST connection closes, so + a fixture that closes its connection cannot model a running agent. Hold + this connection open for as long as the test needs a real WAL side file. + """ + con = sqlite3.connect(str(db)) + con.execute("PRAGMA journal_mode=WAL") + return con + + +def append_message(writer, sid: str, text: str) -> None: + """Add one turn through a live writer; the new pages land in ``-wal``.""" + seq = writer.execute( + "SELECT COALESCE(MAX(seq),0)+1 FROM session_message WHERE session_id=?", + (sid,)).fetchone()[0] + writer.execute("INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_new_%d" % seq, sid, "user", seq, + T0 + 999_000, T0 + 999_000, + json.dumps({"text": text, + "time": {"created": T0 + 999_000}}, + ensure_ascii=False))) + writer.execute("UPDATE session_v2 SET time_updated=? WHERE id=?", + (T0 + 999_000, sid)) + writer.commit() diff --git a/tests/test_isolation.py b/tests/test_isolation.py index 959ab5d..e70ecbb 100644 --- a/tests/test_isolation.py +++ b/tests/test_isolation.py @@ -32,6 +32,7 @@ REAL_HOME / name for name in ( ".voyager", ".codex", ".claude", ".dsh", ".grok", ".zcode", ".cursor", ".gemini", ".config", + ".local/share/opencode", # OpenCode's XDG data root under HOME ) ) diff --git a/tests/test_isolation_guard.py b/tests/test_isolation_guard.py index fe371bf..8398686 100644 --- a/tests/test_isolation_guard.py +++ b/tests/test_isolation_guard.py @@ -331,3 +331,62 @@ def test_a_fully_redirected_environment_passes(fake_protected, tmp_path): "XDG_CONFIG_HOME": str(safe / ".config"), "XDG_DATA_HOME": str(safe / ".local/share")} assert iso_guard.guard_env(env) is None + + +# --- the OpenCode adapter reads XDG_DATA_HOME and an explicit DB override --- +# These are new discovery inputs. An explicit OPENCODE_DB= bypasses +# every directory redirection, so its literal value must be judged; and +# XDG_DATA_HOME must be judged on the opencode/ directory it derives. + +def test_an_explicit_opencode_db_in_real_data_is_refused(fake_protected, + tmp_path): + profile, _ = fake_protected + real_db = profile / ".local" / "share" / "opencode" / "opencode.db" + env = {"HOME": str(tmp_path / "safe"), + "USERPROFILE": str(tmp_path / "safe"), + "VOYAGER_OPENCODE_DB": str(real_db)} + # The fabricated root does not include .local, so use a real data root that + # the fixture does protect: .codex. The point is the *variable* is checked. + env["VOYAGER_OPENCODE_DB"] = str(profile / ".codex" / "opencode.db") + with pytest.raises(iso_guard.IsolationViolation) as ei: + iso_guard.guard_env(env) + assert "OPENCODE_DB" in str(ei.value) + + +def test_an_explicit_opencode_db_in_a_safe_tree_passes(fake_protected, + tmp_path): + safe = tmp_path / "safe" + env = {"HOME": str(safe), "USERPROFILE": str(safe), + "XDG_DATA_HOME": str(safe / ".local/share"), + "VOYAGER_OPENCODE_DB": str(safe / "fixture.db")} + assert iso_guard.guard_env(env) is None + + +def test_xdg_data_home_deriving_a_real_opencode_dir_is_refused(monkeypatch, + tmp_path): + """The DERIVED path is the dangerous one, not XDG_DATA_HOME's own value. + + A root at ``/opencode`` means ``XDG_DATA_HOME=`` is not itself a + protected root — only the directory the adapter will open is. Without the + derived check this environment would be waved through. + """ + safe = tmp_path / "safe" + real_root = tmp_path / "real" / ".local" / "share" / "opencode" + real_root.mkdir(parents=True) + monkeypatch.setattr(iso_guard, "real_data_roots", lambda: (real_root,)) + + env = {"HOME": str(safe), "USERPROFILE": str(safe), + "XDG_DATA_HOME": str(real_root.parent)} + assert not iso_guard.is_inside_real_data(real_root.parent), ( + "the variable's own value must be safe; only its derived dir is not") + with pytest.raises(iso_guard.IsolationViolation) as ei: + iso_guard.guard_env(env) + assert "XDG_DATA_HOME" in str(ei.value) + assert "opencode" in str(ei.value) + + +def test_the_discovery_env_lists_both_opencode_overrides(): + assert "OPENCODE_DB" in iso_guard._DISCOVERY_ENV + assert "VOYAGER_OPENCODE_DB" in iso_guard._DISCOVERY_ENV + assert "XDG_DATA_HOME" in iso_guard._DERIVED_DATA_DIRS + assert "opencode" in iso_guard._DERIVED_DATA_DIRS["XDG_DATA_HOME"] diff --git a/tests/test_opencode.py b/tests/test_opencode.py new file mode 100644 index 0000000..05ea48f --- /dev/null +++ b/tests/test_opencode.py @@ -0,0 +1,577 @@ +"""OpenCode adapter — synthetic (UNIT_VERIFIED) contract tests. + +Every database here is built by tests/opencode_fixtures.py from DDL that mirrors +upstream v2.2.6 / the v1 migration test. No real OpenCode install is read, no +provider credentials are used, and the isolation fixture redirects HOME and +XDG_DATA_HOME before any test body runs. + +What these tests prove: the adapter can index a synthetic OpenCode database +correctly, incrementally, idempotently and read-only. What they do NOT prove: +that a real OpenCode build writes this exact JSON, or that any form of native +resume works. See docs/audits/2026-11-opencode-phase3-report.md. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from opencode_fixtures import (T0, append_message, make_corrupt, make_legacy, + make_unknown, make_v2, make_v20_inline, + open_writer) + + +@pytest.fixture +def oc_env(monkeypatch, tmp_path): + """Point discovery at one scratch directory and nothing else.""" + home = tmp_path / "home" + data = home / ".local" / "share" / "opencode" + data.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.delenv("OPENCODE_DB", raising=False) + monkeypatch.delenv("VOYAGER_OPENCODE_DB", raising=False) + monkeypatch.delenv("OPENCODE_DISABLE_CHANNEL_DB", raising=False) + monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) + monkeypatch.setenv("VOYAGER_NO_SYNC", "1") + return data + + +@pytest.fixture +def adapter(adapter_of): + return adapter_of("opencode") + + +def _index(db, bundles): + """Minimal in-memory view of what the scanner would write.""" + return {b["session"]["id"]: b for b in bundles} + + +# --- schema probe ---------------------------------------------------------- + +def test_v2_schema_is_recognised(oc_env): + from voyager.adapters import opencode as oc + db = make_v2(oc_env / "opencode.db") + assert oc.probe(db)[0] == oc.PROBE_OK + assert oc.probe(db)[1] == "v2" + + +def test_legacy_schema_is_recognised(oc_env): + from voyager.adapters import opencode as oc + db = make_legacy(oc_env / "opencode.db") + assert oc.probe(db) == (oc.PROBE_OK, "legacy") + + +def test_unknown_schema_is_named_not_silently_ignored(oc_env, adapter): + from voyager.adapters import opencode as oc + db = make_unknown(oc_env / "opencode.db") + assert oc.probe(db)[0] == oc.PROBE_UNKNOWN_SCHEMA + assert db in adapter.unusable_sources() + assert db in adapter.unrecognised_sources() + assert adapter.discover() == [] + + +def test_corrupt_database_is_unreadable_not_absent(oc_env, adapter): + from voyager.adapters import opencode as oc + db = make_corrupt(oc_env / "opencode.db") + assert oc.probe(db)[0] == oc.PROBE_UNREADABLE + assert db in adapter.unusable_sources() + assert adapter.discover() == [] + + +def test_absent_database_is_absent(oc_env, adapter): + from voyager.adapters import opencode as oc + assert oc.probe(oc_env / "opencode.db")[0] == oc.PROBE_ABSENT + assert adapter.discover() == [] + assert adapter.unusable_sources() == [] + + +def test_explicit_override_takes_priority(oc_env, monkeypatch, adapter): + other = oc_env.parent / "custom.db" + make_v2(other) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(other)) + assert other in adapter.discover() + + +def test_channel_database_is_discovered(oc_env, adapter): + db = make_v2(oc_env / "opencode-beta.db") + assert db in adapter.discover() + + +# --- mapping --------------------------------------------------------------- + +def test_v2_session_and_events(oc_env, adapter): + make_v2(oc_env / "opencode.db") + bundles = adapter.scan(lambda p, f: True) + assert len(bundles) == 1 + b = bundles[0] + s, evs = b["session"], b["events"] + assert s["id"] == "opencode:ses_000" + assert s["title"] == "修复补丁" + assert s["cwd"] == "/repo/demo" + assert s["repo_root"] == "/repo/demo" # from project.worktree + assert s["model"] == "anthropic:claude-sonnet-4" + assert s["can_resume"] is False and s["resume_cmd"] is None + assert abs(s["started_at"] - T0 / 1000.0) < 0.001 + # 1 user + 2 assistant text parts ("正在处理" and the Unicode part). + assert s["message_count"] == 3 + assert s["tool_count"] == 1 + assert s["metadata"]["usage_totals"] == {"input": 120, "output": 40} + + kinds = [e["kind"] for e in evs] + assert kinds.count("user") == 1 + assert kinds.count("assistant") >= 1 + assert kinds.count("reasoning") == 1 + assert kinds.count("tool_call") == 1 + assert kinds.count("tool_result") == 1 + + user = next(e for e in evs if e["kind"] == "user") + assert user["content"] == "补丁已完成" + assert user["origin"] == "human" + + call = next(e for e in evs if e["kind"] == "tool_call") + assert call["tool_name"] == "bash" + assert call["tool_call_id"] == "call_1" + assert call["command"] == "pytest -q" + assert json.loads(call["tool_input"]) == {"command": "pytest -q"} + + result = next(e for e in evs if e["kind"] == "tool_result") + assert result["tool_output"] == "done" + assert result["tool_call_id"] == "call_1" + + # Unicode survives the round trip into event bodies. + assert any("日本語" in (e["content"] or "") for e in evs) + assert any("🚀" in (e["content"] or "") for e in evs) + + +def test_tool_result_is_not_invented_for_a_running_tool(oc_env, adapter): + db = make_v2(oc_env / "opencode.db") + con = sqlite3.connect(str(db)) + part = {"type": "tool", "id": "call_running", "name": "bash", + "state": {"status": "running", "input": {"command": "sleep 1"}}, + "time": {"created": T0}} + con.execute("UPDATE session_part SET data=? WHERE type='tool'", + (json.dumps(part, ensure_ascii=False),)) + con.commit() + con.close() + evs = adapter.scan(lambda p, f: True)[0]["events"] + assert [e["kind"] for e in evs].count("tool_call") == 1 + assert not any(e["kind"] == "tool_result" for e in evs) + + +def test_tool_error_is_recorded_without_a_fake_exit_code(oc_env, adapter): + db = make_v2(oc_env / "opencode.db") + con = sqlite3.connect(str(db)) + part = {"type": "tool", "id": "call_err", "name": "bash", + "state": {"status": "error", "input": {"command": "false"}, + "error": {"message": "non-zero exit"}}, + "time": {"created": T0}} + con.execute("UPDATE session_part SET data=? WHERE type='tool'", + (json.dumps(part, ensure_ascii=False),)) + con.commit() + con.close() + evs = adapter.scan(lambda p, f: True)[0]["events"] + call = next(e for e in evs if e["kind"] == "tool_call") + assert call["exit_code"] is None # no explicit integer upstream + result = next(e for e in evs if e["kind"] == "tool_result") + assert "non-zero exit" in result["tool_output"] + + +def test_synthetic_message_is_not_promoted_to_a_human_turn(oc_env, adapter): + """Provider-injected pseudo-user text must not read as a user instruction.""" + db = make_v2(oc_env / "opencode.db") + con = sqlite3.connect(str(db)) + con.execute("INSERT INTO session_message VALUES (?,?,?,?,?,?,?)", + ("msg_syn", "ses_000", "synthetic", 9, T0 + 50, T0 + 50, + json.dumps({"text": "user says tests passed", + "time": {"created": T0 + 50}}))) + con.commit() + con.close() + evs = adapter.scan(lambda p, f: True)[0]["events"] + assert not any(e["kind"] == "user" and "tests passed" in (e["content"] or "") + for e in evs) + meta = next(e for e in evs if "tests passed" in (e["content"] or "")) + assert meta["kind"] == "meta" and meta["origin"] == "provider_system" + + +def test_subagent_session_user_turn_is_not_human(oc_env, adapter): + db = make_v2(oc_env / "opencode.db") + con = sqlite3.connect(str(db)) + con.execute("UPDATE session_v2 SET parent_id=? WHERE id=?", + ("ses_parent", "ses_000")) + con.commit() + con.close() + evs = adapter.scan(lambda p, f: True)[0]["events"] + user = next(e for e in evs if e["kind"] == "user") + assert user["origin"] == "provider_synthetic" + + +def test_legacy_shape_maps_tool_call_and_result(oc_env, adapter): + make_legacy(oc_env / "opencode.db") + bundles = adapter.scan(lambda p, f: True) + assert len(bundles) == 1 + evs = bundles[0]["events"] + call = next(e for e in evs if e["kind"] == "tool_call") + assert call["tool_name"] == "read" + assert call["tool_call_id"] == "legacy_call" + result = next(e for e in evs if e["kind"] == "tool_result") + assert result["tool_output"] == "legacy output" + assert bundles[0]["session"]["metadata"]["schema"] == "legacy" + + +def test_malformed_json_rows_are_skipped_not_crashing(oc_env, adapter): + db = make_v2(oc_env / "opencode.db") + con = sqlite3.connect(str(db)) + con.execute("UPDATE session_part SET data='{not json' WHERE ordinal=0") + con.execute("UPDATE session_message SET data='{}' WHERE type='assistant'") + con.commit() + con.close() + bundles = adapter.scan(lambda p, f: True) + assert len(bundles) == 1 # still a usable session + assert bundles[0]["events"] + + +def test_empty_session_with_no_title_is_not_indexed(oc_env, adapter): + db = make_v2(oc_env / "opencode.db", tool=False) + con = sqlite3.connect(str(db)) + con.execute("UPDATE session_v2 SET title=NULL") + con.execute("DELETE FROM session_part") + con.execute("DELETE FROM session_message") + con.commit() + con.close() + assert adapter.scan(lambda p, f: True) == [] + + +# --- read-only ------------------------------------------------------------- + +def test_database_content_never_changes(oc_env, adapter, monkeypatch): + """A read-only adapter must not alter any provider data. + + SQLite itself may CREATE ``-shm`` (shared-memory index) when a WAL database + is opened, even with ``mode=ro``; that is a SQLite side effect, not a write + to the session history. ``immutable=1`` would avoid it but hides committed + WAL pages, which would silently drop live turns — see the design note. + So we assert on the main database bytes, the WAL bytes and the logical + content, and document the -shm behaviour instead of claiming "no files". + """ + db = make_v2(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + wal = Path(str(db) + "-wal") + shm = Path(str(db) + "-shm") + + main_before = db.read_bytes() + wal_before = wal.read_bytes() if wal.is_file() else None + + def logical_content(): + con = sqlite3.connect(str(db)) + try: + return con.execute( + "SELECT count(*) FROM session_v2").fetchone()[0], con.execute( + "SELECT count(*) FROM session_message").fetchone()[0], con.execute( + "SELECT count(*) FROM session_part").fetchone()[0] + finally: + con.close() + + content_before = logical_content() + adapter.scan(lambda p, f: True) + assert db.read_bytes() == main_before, "main database changed" + if wal_before is not None: + assert wal.read_bytes() == wal_before, "WAL changed" + assert logical_content() == content_before, "session rows changed" + # -shm may now exist; it is a shared-memory index, never session content. + if shm.is_file(): + assert b"ses_000" not in shm.read_bytes() + + +def test_writes_are_refused_by_the_connection(oc_env): + from voyager.adapters import opencode as oc + db = make_v2(oc_env / "opencode.db") + con = oc._open_ro(db) + assert con is not None + with pytest.raises(sqlite3.Error): + con.execute("INSERT INTO project VALUES ('x','x',NULL,NULL,1,1,'[]',1,1,1,0,1,'[]',NULL)") + con.close() + + +# --- incremental / WAL / SOURCE_MISSING ------------------------------------ + +def test_wal_side_files_take_part_in_discovery(oc_env, adapter, monkeypatch): + """A live writer's WAL must be visible to source_changed(), or new turns + are skipped until a checkpoint rewrites the main database file.""" + db = make_v2(oc_env / "opencode.db", wal=True) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + writer = open_writer(db) + try: + append_message(writer, "ses_000", "WAL 里的新消息") + wal = Path(str(db) + "-wal") + assert wal.is_file() and wal.stat().st_size > 0 + found = adapter.discover() + assert db in found + assert wal in found, "WAL must be a discovery source for incremental scans" + finally: + writer.close() + + +def test_rescan_after_wal_write_sees_the_new_turn(oc_env, adapter, monkeypatch): + db = make_v2(oc_env / "opencode.db", wal=True) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + first = adapter.scan(lambda p, f: True) + assert not any("WAL" in (e["content"] or "") for e in first[0]["events"]) + writer = open_writer(db) + try: + append_message(writer, "ses_000", "WAL 里的新消息") + second = adapter.scan(lambda p, f: True) + assert any("WAL" in (e["content"] or "") for e in second[0]["events"]) + finally: + writer.close() + + +def test_scan_is_idempotent(oc_env, adapter, monkeypatch): + db = make_v2(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + a = adapter.scan(lambda p, f: True) + b = adapter.scan(lambda p, f: True) + assert len(a) == len(b) == 1 + assert a[0]["session"] == b[0]["session"] + assert [(e["seq"], e["kind"], e["content"]) for e in a[0]["events"]] == [ + (e["seq"], e["kind"], e["content"]) for e in b[0]["events"]] + + +def test_streaming_and_list_styles_index_identically(oc_env, adapter, monkeypatch): + """emit= must not change what is produced (the #18 invariant).""" + db = make_v2(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + listed = adapter.scan(lambda p, f: True) + streamed = [] + adapter.scan(lambda p, f: True, emit=streamed.append) + assert len(listed) == len(streamed) == 1 + assert listed[0]["session"] == streamed[0]["session"] + assert [e["seq"] for e in listed[0]["events"]] == [ + e["seq"] for e in streamed[0]["events"]] + assert adapter.scan_streams() is True + + +def test_unreadable_database_is_kept_not_treated_as_vanished(oc_env, adapter, + monkeypatch): + db = make_corrupt(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + assert adapter.scan(lambda p, f: True) == [] + assert db in adapter.unusable_sources() + + +def test_unknown_schema_prints_a_warning_instead_of_zero_sessions(oc_env, + adapter, + monkeypatch, + capsys): + db = make_unknown(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + adapter.scan(lambda p, f: True) + assert "unknown-schema" in capsys.readouterr().err + + +# --- scanner integration (real Store, synthetic provider db) --------------- + +def test_scanner_indexes_and_search_finds_two_character_cjk(oc_env, monkeypatch, + tmp_path): + """End to end through run_scan: adapter -> Store -> search.""" + from voyager.scanner import run_scan + from voyager.store import Store + db = make_v2(oc_env / "opencode.db", text="账本对不上") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + store = Store(tmp_path / "index.db") + try: + run_scan(store, providers=["opencode"], quiet=True) + rows = store.q("SELECT id, provider, source_state FROM sessions") + assert len(rows) == 1 + assert rows[0]["provider"] == "opencode" + assert rows[0]["source_state"] == "ACTIVE_SOURCE" + hits = store.search("账本") + assert hits, "the 2-character CJK fallback must find the event" + # A second scan must not duplicate anything. + run_scan(store, providers=["opencode"], quiet=True) + assert store.con.execute( + "SELECT count(*) FROM sessions WHERE provider='opencode'").fetchone()[0] == 1 + assert store.con.execute("SELECT count(*) FROM events").fetchone()[0] == len( + store.q("SELECT id FROM events")) + finally: + store.close() + + +def test_wal_write_triggers_an_incremental_rescan(oc_env, monkeypatch, + tmp_path): + """The whole point of putting -wal in discover(): source_changed() sees it. + + Without this, a running OpenCode agent's new turns stay invisible until a + checkpoint happens to rewrite the main .db file — which may be never while + the process is alive. This test goes through the real Store/scanner path. + """ + from voyager.scanner import run_scan + from voyager.store import Store + db = make_v2(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + store = Store(tmp_path / "index.db") + try: + first = run_scan(store, providers=["opencode"], quiet=True) + n1 = store.con.execute("SELECT count(*) FROM events").fetchone()[0] + assert first["new"] == 1 + + writer = open_writer(db) + try: + append_message(writer, "ses_000", "增量新消息") + assert Path(str(db) + "-wal").is_file() + second = run_scan(store, providers=["opencode"], quiet=True) + n2 = store.con.execute("SELECT count(*) FROM events").fetchone()[0] + assert second["new"] == 1, "WAL change must trigger a rescan" + assert n2 == n1 + 1, (n1, n2) + assert store.search("增量新消息"), "new turn must be searchable" + finally: + writer.close() + finally: + store.close() + + +def test_vanished_database_retains_history_as_source_missing(oc_env, monkeypatch, + tmp_path): + """O2: a source that disappears is retained, never deleted.""" + from voyager.scanner import run_scan + from voyager.store import Store + db = make_v2(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + store = Store(tmp_path / "index.db") + try: + run_scan(store, providers=["opencode"], quiet=True) + n_events = store.con.execute("SELECT count(*) FROM events").fetchone()[0] + assert n_events > 0 + db.unlink() + run_scan(store, providers=["opencode"], quiet=True) + row = store.q("SELECT source_state FROM sessions WHERE provider='opencode'") + assert row and row[0]["source_state"] == "SOURCE_MISSING" + assert store.con.execute("SELECT count(*) FROM events").fetchone()[0] == n_events + finally: + store.close() + + +def test_large_session_fixture_completes(oc_env, adapter, monkeypatch): + db = make_v2(oc_env / "opencode.db", n_messages=5000) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + bundles = adapter.scan(lambda p, f: True) + assert len(bundles) == 1 + assert len(bundles[0]["events"]) >= 5000 + + +# --- v2.0.x inline shape (no session_part; content in data.content) -------- + +def test_v20_inline_schema_is_recognised(oc_env): + from voyager.adapters import opencode as oc + db = make_v20_inline(oc_env / "opencode.db") + assert oc.probe(db) == (oc.PROBE_OK, "v2-inline") + + +def test_parts_schema_wins_over_inline_when_session_part_exists(oc_env): + """A v2.2.x database also has session_message; it must stay 'v2'.""" + from voyager.adapters import opencode as oc + db = make_v2(oc_env / "opencode.db") + assert oc.probe(db) == (oc.PROBE_OK, "v2") + + +def test_v20_inline_session_and_events(oc_env, adapter, monkeypatch): + db = make_v20_inline(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + bundles = adapter.scan(lambda p, f: True) + assert len(bundles) == 1 + b = bundles[0] + s, evs = b["session"], b["events"] + assert s["id"] == "opencode:ses_inline_000" + assert s["title"] == "内联形状" + assert s["metadata"]["schema"] == "v2-inline" + assert s["can_resume"] is False and s["can_fork"] is False + kinds = [e["kind"] for e in evs] + assert kinds.count("user") == 1 + assert kinds.count("assistant") >= 2 # text parts, incl. CJK + assert kinds.count("reasoning") == 1 + assert kinds.count("tool_call") == 1 + assert kinds.count("tool_result") == 1 + # CJK text survives + assert any("日本語" in (e.get("content") or "") for e in evs) + # user turn is the human prompt; system/compaction stay meta + assert [e for e in evs if e["kind"] == "user"][0]["content"] == "补丁已完成" + metas = [e for e in evs if e["kind"] == "meta"] + assert any("system note" in (e.get("content") or "") for e in metas) + assert any("compacted summary" in (e.get("content") or "") for e in metas) + assert all(e["origin"] == "provider_system" for e in metas) + + +def test_v20_inline_tool_exit_comes_from_state_metadata(oc_env, adapter, + monkeypatch): + db = make_v20_inline(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + evs = adapter.scan(lambda p, f: True)[0]["events"] + call = [e for e in evs if e["kind"] == "tool_call"][0] + assert call["tool_name"] == "bash" + assert call["tool_call_id"] == "call_1" + assert call["command"] == "pytest -q" + # exit 0 lives in state.metadata.exit and must survive as 0, not None + assert call["exit_code"] == 0 + result = [e for e in evs if e["kind"] == "tool_result"][0] + assert "done" in (result.get("tool_output") or "") + + +def test_v20_inline_running_tool_gets_no_invented_result(oc_env, adapter, + monkeypatch): + db = make_v20_inline(oc_env / "opencode.db", running_tool=True) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + evs = adapter.scan(lambda p, f: True)[0]["events"] + assert [e for e in evs if e["kind"] == "tool_call"] + assert not [e for e in evs if e["kind"] == "tool_result"] + + +def test_v20_inline_empty_user_text_is_skipped(oc_env, adapter, monkeypatch): + db = make_v20_inline(oc_env / "opencode.db", empty_user=True) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + evs = adapter.scan(lambda p, f: True)[0]["events"] + assert not [e for e in evs if e["kind"] == "user"] + + +def test_v20_inline_wal_rescan_sees_a_new_turn(oc_env, adapter, monkeypatch): + db = make_v20_inline(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + writer = open_writer(db) + try: + first = adapter.scan(lambda p, f: True) + n_before = len([e for e in first[0]["events"] if e["kind"] == "user"]) + append_message(writer, "ses_inline_000", "第二个问题") + second = adapter.scan(lambda p, f: True) + n_after = len([e for e in second[0]["events"] if e["kind"] == "user"]) + assert n_after == n_before + 1 + assert any("第二个问题" in (e.get("content") or "") + for e in second[0]["events"]) + finally: + writer.close() + + +def test_v20_inline_scan_is_idempotent(oc_env, adapter, monkeypatch): + db = make_v20_inline(oc_env / "opencode.db") + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(db)) + a = adapter.scan(lambda p, f: True) + b = adapter.scan(lambda p, f: True) + assert [(x["session"]["id"], len(x["events"])) for x in a] == \ + [(x["session"]["id"], len(x["events"])) for x in b] + assert [e["seq"] for e in a[0]["events"]] == \ + [e["seq"] for e in b[0]["events"]] + + +def test_explicit_override_does_not_probe_the_default_database(oc_env, + monkeypatch): + """An operator-named DB must not cause an unrequested default-HOME read.""" + from voyager.adapters import opencode as oc + make_v2(oc_env / "opencode.db") # the default the user did NOT name + other = oc_env.parent / "named.db" + make_v20_inline(other) + monkeypatch.setenv("VOYAGER_OPENCODE_DB", str(other)) + candidates = oc._candidate_dbs() + assert candidates and all("named.db" == p.name for p in candidates) + assert oc_env / "opencode.db" not in candidates + assert oc_env / "opencode.db" not in oc.OpencodeAdapter().discover() diff --git a/voyager/adapters/__init__.py b/voyager/adapters/__init__.py index 815fa99..12c5cb6 100644 --- a/voyager/adapters/__init__.py +++ b/voyager/adapters/__init__.py @@ -5,7 +5,7 @@ import importlib _MODULES = ["codex", "claude", "zcode", "dsh", "grok", "cursor", "kiro", - "antigravity"] + "antigravity", "opencode"] _loaded = False diff --git a/voyager/adapters/opencode.py b/voyager/adapters/opencode.py new file mode 100644 index 0000000..9c79f88 --- /dev/null +++ b/voyager/adapters/opencode.py @@ -0,0 +1,763 @@ +"""OpenCode adapter — read-only SQLite session indexing. + +Source: ``$XDG_DATA_HOME/opencode/opencode.db`` (or +``~/.local/share/opencode/opencode.db``), overridden by ``OPENCODE_DB`` and, +for tests, ``VOYAGER_OPENCODE_DB``. One database holds many sessions, so this +adapter overrides ``scan()`` rather than ``parse()`` — the same shape as ZCode. + +What was verified upstream (anomalyco/opencode, release v2.2.6 2026-11-06; +``packages/core/src/session/sql.ts`` is byte-identical to v1.19.5): + +* The current tables are ``session_v2``, ``session_message``, ``session_part`` + and ``project``. ``session_part`` has ``session_id``, ``ordinal``, ``type`` + and ``call_id`` columns — the design note in docs/audits/ only listed the + (message_id, ordinal) primary key. +* Timestamps are ``Date.now()`` milliseconds (``database/schema.sql.ts``). +* A tool call and its result live in ONE part: ``data.state`` carries + ``status`` (streaming/running/completed/error), ``input``, ``content`` (an + array of ``{type:"text",text}`` / ``{type:"file",...}``) and ``error``. + They are not separate rows, so joining by ``call_id`` is not how results + are found here. ``call_id`` is still recorded as the tool_call_id. +* Pre-v2 databases use ``session`` / ``message`` / ``part`` with a different + column set (``packages/core/test/v1-migration.test.ts``). All shapes are + probed; none is assumed. +* **An intermediate v2.0.x shape has no ``session_part`` table at all.** Message + content is stored *inline* in ``session_message.data``: ``user`` rows carry a + ``text`` string; ``assistant`` rows carry a ``content`` array whose items are + ``text`` / ``reasoning`` / ``tool`` parts, and a tool's exit code sits in + ``state.metadata.exit`` rather than ``state.exit``. This shape is detected + structurally (``session_v2`` + ``session_message`` **and no** ``session_part``), + never by a product version string. It was found on a real install, so it is + not a hypothetical variant. + +What this adapter does NOT do: + +* No native resume. ``can_resume = False`` and the capability matrix says + NOT_FOUND. Reading someone's history is not the same as being able to hand + an agent back its own session. +* No writes to the provider database. Every connection is ``mode=ro`` with + ``PRAGMA query_only=ON``; WAL side files are read, never created or removed. +* No guessing. An unknown schema, a corrupt file or a locked database is + reported by name; a missing tool result is left NULL rather than invented. + +Verification level: UNIT_VERIFIED on synthetic fixtures for all three shapes, +plus a **real-install read** of a v2.0.x database (discovery, message/role +mapping, inline tool calls, timestamps) recorded in the Phase 3 gate report. +Native resume and cross-agent continuation are still ``NOT_FOUND`` / +``NOT_VERIFIED`` and must not be read as verified from discovery succeeding. +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote + +from .base import Adapter, finish_session, git_info, register +from ..model import new_event, new_session, text_of + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +#: Test/CI override. ``OPENCODE_DB`` is the upstream variable and can be a +#: filename relative to the data directory OR an absolute path; honouring it +#: here too means an install with a custom database is indexed without config. +_ENV_OVERRIDES = ("VOYAGER_OPENCODE_DB", "OPENCODE_DB") +_DB_NAME = "opencode.db" + +#: Side files that must take part in the freshness fingerprint. In WAL mode +#: writes land in ``-wal`` and the main database's mtime/size may not change, +#: so a source_changed() check on the .db alone would silently skip new turns. +_SIDE_SUFFIXES = ("-wal", "-shm") + + +def _resolve_home(home: Optional[Path] = None) -> Path: + """Resolve HOME at call time (not import time) so tests can redirect it.""" + if home is not None: + return Path(home) + for name in ("HOME", "USERPROFILE"): + value = os.environ.get(name) + if value: + return Path(value) + return Path.home() + + +def data_dir(home: Optional[Path] = None) -> Path: + """The directory OpenCode resolves its database in. + + Upstream ``packages/util/src/global-roots.ts`` uses XDG_DATA_HOME, else + ``$HOME/.local/share`` — on every platform, Windows included. There is no + APPDATA special case, so we do not invent one. + """ + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return Path(xdg) / "opencode" + return _resolve_home(home) / ".local" / "share" / "opencode" + + +def _candidate_dbs(home: Optional[Path] = None) -> List[Path]: + """Ordered, deduplicated candidate paths. Never creates anything. + + An explicit override (``VOYAGER_OPENCODE_DB`` / upstream ``OPENCODE_DB``) + is **authoritative**: when one is set we return only the named database(s) + and do not probe the default data directory or channel builds. That keeps + an operator-named file from causing an unrequested read of a real HOME + database — which is exactly what a test or CI run that sets an override + must never do. With no override, the default database plus any + ``opencode-.db`` beside it are candidates. + """ + out: List[Path] = [] + + def add(p: Path) -> None: + p = Path(p) + if p not in out: + out.append(p) + + overridden = False + for name in _ENV_OVERRIDES: + value = os.environ.get(name) + if not value or value == ":memory:": + # :memory: is a valid upstream setting but has no file to index. + continue + p = Path(value).expanduser() + add(p if p.is_absolute() else data_dir(home) / p) + overridden = True + if overridden: + return out + + d = data_dir(home) + add(d / _DB_NAME) + # Channel builds write opencode-.db. Any such file is a candidate; + # the schema probe decides whether it is really an OpenCode database. + try: + for p in sorted(d.glob("opencode-*.db")): + # skip WAL/SHM companions: glob("...*.db") cannot match them, but + # a file literally named opencode-wal.db would be a real database. + add(p) + except OSError: + pass + return out + + +# --------------------------------------------------------------------------- +# Schema probing +# --------------------------------------------------------------------------- + +#: (table -> required columns). Three shapes: the current v2 schema (parts in a +#: separate table), the intermediate v2.0.x schema (parts inline in +#: ``session_message.data``), and the pre-v2 one that migration.ts still +#: recognises by the name "session". +SCHEMAS: Dict[str, Dict[str, Tuple[str, ...]]] = { + "v2": { + "session_v2": ("id", "project_id", "directory", "title", + "time_created", "time_updated"), + "session_message": ("id", "session_id", "type", "seq", + "time_created", "data"), + "session_part": ("message_id", "session_id", "ordinal", "type", + "call_id", "data"), + }, + # v2.0.x: identical message table, but the parts live inline in `data` and + # there is no session_part table. Required columns are the same as v2 minus + # the parts table; `SCHEMA_FORBID` below keeps the two apart. + "v2-inline": { + "session_v2": ("id", "project_id", "directory", "title", + "time_created", "time_updated"), + "session_message": ("id", "session_id", "type", "seq", + "time_created", "data"), + }, + "legacy": { + "session": ("id", "project_id", "directory", "title", + "time_created", "time_updated"), + "message": ("id", "session_id", "time_created", "data"), + "part": ("message_id", "session_id", "time_created", "data"), + }, +} + +#: Tables whose *absence* is part of a shape's definition. Checked after the +#: required-column match, so a database that does carry `session_part` is never +#: misread as the inline shape. This is a structural discriminator, not a +#: version check: a v2.2.x database with the parts table matches "v2" first. +SCHEMA_FORBID: Dict[str, Tuple[str, ...]] = { + "v2-inline": ("session_part",), +} + +PROBE_ABSENT = "absent" +PROBE_OK = "ok" +PROBE_UNREADABLE = "unreadable" +PROBE_UNKNOWN_SCHEMA = "unknown-schema" + + +def _open_ro(path: Path) -> Optional[sqlite3.Connection]: + """Read-only connection. Never ``immutable=1``: WAL means a live writer.""" + if not path.is_file(): + return None + try: + uri = "file:///" + quote(str(path).replace("\\", "/")) + "?mode=ro" + con = sqlite3.connect(uri, uri=True, timeout=5.0) + con.execute("PRAGMA query_only=ON") + return con + except sqlite3.Error: + return None + + +def detect_schema(con: sqlite3.Connection) -> Optional[str]: + """Name the supported schema, or None. Raises sqlite3.Error on a bad file.""" + tables = {r[0] for r in con.execute( + "SELECT name FROM sqlite_master WHERE type='table'")} + for name, required in SCHEMAS.items(): + if not set(required).issubset(tables): + continue + # A shape may require the *absence* of a table (v2-inline has no + # session_part). Structural, not version-based. + if set(SCHEMA_FORBID.get(name, ())) & tables: + continue + ok = True + for table, cols in required.items(): + present = {r[1] for r in con.execute( + "PRAGMA table_info(%s)" % table)} + if not set(cols).issubset(present): + ok = False + break + if ok: + return name + return None + + +def probe(path: Path) -> Tuple[str, Optional[str]]: + """(verdict, schema-name-or-None). Never raises. + + ``unreadable`` and ``unknown-schema`` are both "present on disk": treating + either as absent would retire a history that has not gone anywhere (O2). + """ + if not path.is_file(): + return PROBE_ABSENT, None + con = _open_ro(path) + if con is None: + return PROBE_UNREADABLE, None + try: + # A real read, not just sqlite_master: a truncated/corrupt file can + # answer the schema query and fail on the first table scan. + con.execute("SELECT count(*) FROM sqlite_master").fetchone() + shape = detect_schema(con) + if shape in ("v2", "v2-inline"): + con.execute("SELECT count(*) FROM session_v2").fetchone() + elif shape == "legacy": + con.execute("SELECT count(*) FROM session").fetchone() + else: + return PROBE_UNKNOWN_SCHEMA, None + return PROBE_OK, shape + except sqlite3.Error: + return PROBE_UNREADABLE, None + finally: + try: + con.close() + except sqlite3.Error: + pass + + +def _side_files(db: Path) -> List[Path]: + out = [] + for suffix in _SIDE_SUFFIXES: + p = Path(str(db) + suffix) + if p.is_file(): + out.append(p) + return out + + +def _is_main_db(p: Path) -> bool: + name = p.name + return name.endswith(".db") and not name.endswith("-wal") and not name.endswith("-shm") + + +# --------------------------------------------------------------------------- +# JSON helpers (provider data is untrusted input) +# --------------------------------------------------------------------------- + +def _json(value: Any) -> Any: + if isinstance(value, (dict, list)): + return value + if not isinstance(value, (str, bytes, bytearray)): + return None + try: + return json.loads(value) + except (ValueError, TypeError, UnicodeDecodeError): + return None + + +def _as_dict(value: Any) -> Dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _ms(value: Any) -> Optional[float]: + """Milliseconds -> seconds. Rejects nonsense rather than inventing a date.""" + if isinstance(value, bool): + return None + try: + n = float(value) + except (TypeError, ValueError): + return None + return n / 1000.0 if n > 0 else None + + +def _content_text(content: Any) -> Optional[str]: + """Tool state content is an array of {type:text,text} / {type:file,...}.""" + if content is None: + return None + if isinstance(content, str): + return content or None + if not isinstance(content, list): + return text_of(content) or None + parts: List[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict): + if block.get("type") == "text" and block.get("text"): + parts.append(str(block["text"])) + elif block.get("uri"): + parts.append(str(block["uri"])) + return "\n".join(p for p in parts if p) or None + + +def _inline_parts(mdata: Dict[str, Any]) -> List[dict]: + """Parts stored inline in a v2.0.x assistant message's ``data.content``. + + Each item is already shaped like a part (``type`` plus ``text`` or + ``state``). Only dict items are kept; anything else is dropped rather than + guessed at. Array order is the upstream write order, which is the stable + order for these parts. + """ + content = mdata.get("content") + if not isinstance(content, list): + return [] + return [item for item in content if isinstance(item, dict)] + + +def _tool_exit(state: Dict[str, Any]) -> Optional[int]: + """A tool exit code, wherever the build put it. ``0`` is a real code.""" + candidates = (state.get("exit"), state.get("exit_code"), + _as_dict(state.get("metadata")).get("exit")) + for value in candidates: + code = _exit_code(value) + if code is not None: + return code + return None + + +def _command_and_path(tool_input: Any) -> Tuple[Optional[str], Optional[str]]: + d = _as_dict(tool_input) + command = None + for key in ("command", "cmd", "script"): + if isinstance(d.get(key), str): + command = d[key] + break + path = None + for key in ("file_path", "filePath", "path", "filename"): + if isinstance(d.get(key), str): + path = d[key] + break + return command, path + + +def _model_label(value: Any) -> Optional[str]: + if isinstance(value, str): + return value or None + d = _as_dict(value) + mid = d.get("id") + if not mid: + return None + provider = d.get("providerID") or d.get("providerId") + return "%s:%s" % (provider, mid) if provider else str(mid) + + +def _exit_code(value: Any) -> Optional[int]: + """Only an explicit integer is an exit code. Never infer one from text.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +class OpencodeAdapter(Adapter): + provider = "opencode" + + # Reading a session is not resuming one. No resume command, hook, or + # transcript writer is wired for this provider; do not claim otherwise. + can_resume = False + can_fork = False + + def discover(self) -> List[Path]: + """Main databases plus their WAL companions. + + WAL paths are part of the returned source set ON PURPOSE: the scanner + calls ``source_changed(provider, path)`` for every discovered source, + so a write that only touched ``-wal`` still triggers a rescan. Without + this, an active OpenCode session would look unchanged until a + checkpoint rewrote the main file. + """ + out: List[Path] = [] + for db in _candidate_dbs(): + verdict, _ = probe(db) + if verdict != PROBE_OK: + continue + out.append(db) + out.extend(_side_files(db)) + # Main databases first: scanner uses sources[0] as the fallback anchor. + return out + + def unusable_sources(self) -> List[Path]: + """Present on disk but not indexable this round. + + Keeping these out of `discover()` is not enough — the scanner must be + told they still exist, or it would mark their sessions SOURCE_MISSING. + """ + out: List[Path] = [] + for db in _candidate_dbs(): + if not _is_main_db(db): + continue + verdict, _ = probe(db) + if verdict in (PROBE_UNREADABLE, PROBE_UNKNOWN_SCHEMA): + out.append(db) + out.extend(_side_files(db)) + return out + + def unrecognised_sources(self) -> List[Path]: + """OpenCode-shaped databases we could not read or classify. + + A warning, never an attempt to parse. This is the "renamed artifact" + guard that made the DSH v4 bug silent for weeks. + """ + out: List[Path] = [] + for db in _candidate_dbs(): + if not _is_main_db(db): + continue + verdict, _ = probe(db) + if verdict == PROBE_UNKNOWN_SCHEMA: + out.append(db) + return out + + def parse(self, source: Path) -> Optional[dict]: + # One database -> many sessions; the base scanner expects one session + # per artifact, so scan() is the entry point (same as ZCode). + raise NotImplementedError + + # -- one database ----------------------------------------------------- + + def _scan_db(self, db: Path, emit=None) -> List[dict]: + verdict, shape = probe(db) + if verdict != PROBE_OK or shape is None: + raise ValueError("opencode: %s is %s (%s)" % (db.name, verdict, db)) + con = _open_ro(db) + if con is None: + raise ValueError("opencode: cannot open %s read-only" % db) + bundles: List[dict] = [] + try: + projects = {} + try: + for pid, worktree in con.execute( + "SELECT id, worktree FROM project"): + projects[pid] = worktree + except sqlite3.Error: + pass # project table is optional for us + + session_table = ("session_v2" if shape in ("v2", "v2-inline") + else "session") + rows = con.execute( + "SELECT * FROM %s ORDER BY time_created, id" % session_table + ).fetchall() + # row_factory is not set globally (probes use plain tuples), so + # describe the columns explicitly for the selected shape. + cols = [r[1] for r in con.execute( + "PRAGMA table_info(%s)" % session_table)] + for raw in rows: + s = dict(zip(cols, raw)) + bundle = self._session_bundle(con, db, shape, s, projects) + if bundle is None: + continue + if emit is not None: + emit(bundle) + else: + bundles.append(bundle) + return bundles + finally: + try: + con.close() + except sqlite3.Error: + pass + + def _session_bundle(self, con, db, shape, s, projects) -> Optional[dict]: + native = s.get("id") + if not native: + return None + sid = "opencode:%s" % native + directory = s.get("directory") + project_id = s.get("project_id") + cwd = directory + repo_root = projects.get(project_id) + title = s.get("title") + model = _model_label(_json(s.get("model")) or s.get("model")) + parent_id = s.get("parent_id") + + events: List[dict] = [] + seq = 0 + first_user: Optional[str] = None + + def ev(**kw) -> dict: + nonlocal seq + seq += 1 + kw.setdefault("ts", None) + e = new_event(sid=sid, seq=seq, raw_event=kw.pop("raw_event", None), + **kw) + events.append(e) + return e + + msg_rows, msg_cols = self._messages(con, shape, native) + for mrow in msg_rows: + m = dict(zip(msg_cols, mrow)) + mtype = m.get("type") or (_as_dict(_json(m.get("data"))).get("role")) + mdata = _as_dict(_json(m.get("data"))) + mtime = _ms(m.get("time_created")) or _ms( + _as_dict(mdata.get("time")).get("created")) + if not model: + model = _model_label(mdata.get("model")) + role = "user" if mtype == "user" else "assistant" + + # The user text lives in the message data in v2; the parts table + # only holds assistant content. Some writes keep a copy in parts, + # so read both and de-duplicate by (text, ts) below. + if mtype == "user": + text = text_of(mdata.get("text")) if mdata.get("text") else "" + if not text and shape != "v2-inline": + for p in self._parts(con, shape, m.get("id"), native): + if p.get("type") == "text" and p.get("text"): + text = str(p["text"]) + break + if text.strip(): + if first_user is None: + first_user = text + ev(kind="user", role="user", content=text, ts=mtime, + origin="human" if not parent_id else "provider_synthetic", + raw_event={"type": mtype, "text": text}) + continue + + if mtype in ("synthetic", "system", "skill", "compaction", "idle", + "agent-switched", "model-switched", + "location-switched"): + # Provider-generated pseudo-user text must not be promoted to a + # human instruction (provenance safety: keep it as meta). + # `summary` is what a v2.0.x compaction row carries; using it + # only when present keeps this honest (no invented text). + text = text_of(mdata.get("text") or mdata.get("description") + or mdata.get("summary")) + if text: + ev(kind="meta", role="system", content=text, ts=mtime, + origin="provider_system", + raw_event={"type": mtype, "text": text}) + continue + + if mtype == "shell": + d = mdata + cmd = d.get("command") + out = _content_text(d.get("output")) + ev(kind="tool_call", role="assistant", ts=mtime, + tool_name="shell", tool_call_id=d.get("shellID"), + command=cmd, tool_output=out, + exit_code=_exit_code(d.get("exit")), + origin="provider_system", + raw_event={"type": "shell", "command": cmd, + "exit": d.get("exit")}) + continue + + if mtype == "assistant" or mtype is None or shape == "legacy": + if shape == "v2-inline": + # v2.0.x has no session_part table: the parts array lives + # inline in `data.content` (text / reasoning / tool items). + parts = _inline_parts(mdata) + else: + parts = self._parts(con, shape, m.get("id"), native) + before = len(events) + for p in parts: + ptype = p.get("type") + ptime = _ms(_as_dict(p.get("time")).get("created")) or mtime + if ptype == "text" and p.get("text"): + ev(kind="assistant", role="assistant", + content=str(p["text"]), ts=ptime, model=model, + origin="provider_system", + raw_event={"type": "assistant", "part": "text"}) + elif ptype == "reasoning" and p.get("text"): + ev(kind="reasoning", role="assistant", + content=str(p["text"]), ts=ptime, model=model, + origin="provider_system", + raw_event={"type": "assistant", "part": "reasoning"}) + elif ptype == "tool": + self._tool_events(ev, p, ptime, model, shape) + elif ptype == "file": + fp = p.get("url") or p.get("filename") + if fp: + ev(kind="file", role="user", file_path=str(fp), + ts=ptime, origin="provider_system", + raw_event={"type": "file"}) + + # Some builds store assistant content inline in the message + # data instead of (or in addition to) session_part. Only use + # it when the parts table gave us nothing for this message — + # otherwise the same text would be indexed twice. The inline + # shape already read `data.content` above, so skip it there. + if (shape != "v2-inline" and len(events) == before + and isinstance(mdata.get("content"), list)): + for p in mdata["content"]: + pd = _as_dict(p) + if pd.get("type") == "text" and pd.get("text"): + ev(kind="assistant", role="assistant", + content=str(pd["text"]), ts=mtime, model=model, + origin="provider_system", + raw_event={"type": "assistant", + "part": "inline-text"}) + + if not events and not title: + return None + + # Session-level token totals are a property of the session, not of any + # single event; do not attribute them to one message. + tokens = {k: s.get("tokens_%s" % k) for k in + ("input", "output", "reasoning", "cache_read", "cache_write")} + usage = {k: v for k, v in tokens.items() if v} or None + session = new_session( + id=sid, provider=self.provider, native_session_id=native, + title=title or (first_user and " ".join(first_user.split())[:120]) + or str(native), + started_at=_ms(s.get("time_created")), + updated_at=_ms(s.get("time_updated")), + cwd=cwd, repo_root=repo_root, model=model, + message_count=sum(1 for e in events + if e["kind"] in ("user", "assistant")), + tool_count=sum(1 for e in events if e["kind"] == "tool_call"), + can_resume=False, can_fork=False, resume_cmd=None, + metadata={"schema": shape, "usage_totals": usage, + "project_id": project_id}, + raw_metadata={"parent_id": parent_id}, + ) + session = finish_session(session, git_info(cwd)) + return {"session": session, "events": events, + "source_path": db, "extra_sources": _side_files(db)} + + def _messages(self, con, shape, native): + if shape in ("v2", "v2-inline"): + sql = ("SELECT * FROM session_message WHERE session_id=? " + "ORDER BY seq, time_created, id") + else: + sql = ("SELECT * FROM message WHERE session_id=? " + "ORDER BY time_created, id") + cur = con.execute(sql, (native,)) + cols = [d[0] for d in cur.description] + return cur.fetchall(), cols + + def _parts(self, con, shape, message_id, native) -> List[dict]: + if not message_id: + return [] + try: + if shape == "v2": + cur = con.execute( + "SELECT data, call_id FROM session_part " + "WHERE message_id=? ORDER BY ordinal, time_created", + (message_id,)) + else: + cur = con.execute( + "SELECT data, NULL FROM part " + "WHERE message_id=? ORDER BY time_created, id", + (message_id,)) + out = [] + for data, call_id in cur: + p = _as_dict(_json(data)) + if not p: + continue + if call_id and not p.get("call_id"): + p["call_id"] = call_id + out.append(p) + return out + except sqlite3.Error: + return [] + + def _tool_events(self, ev, p, ptime, model, shape) -> None: + state = _as_dict(p.get("state")) + inp = state.get("input") + cmd, fp = _command_and_path(inp) + # v2 part data uses name/id; legacy uses tool/callID. + tool_name = p.get("name") or p.get("tool") + call_id = p.get("call_id") or p.get("callID") or p.get("id") + status = state.get("status") + raw = {"type": "tool", "status": status, "tool": tool_name} + ev(kind="tool_call", role="assistant", ts=ptime, model=model, + tool_name=str(tool_name) if tool_name else None, + tool_call_id=str(call_id) if call_id else None, + tool_input=(json.dumps(inp, ensure_ascii=False, default=str) + if inp is not None else None), + command=cmd, file_path=fp, + exit_code=_tool_exit(state), + origin="provider_system", raw_event=raw) + + # The result is in the SAME part's state, not a separate row. Emit a + # tool_result only when upstream actually recorded one; a running or + # streaming tool has no result and must not be given a fake one. + output = _content_text(state.get("content") or state.get("output")) + error = state.get("error") + if status == "completed" and output: + ev(kind="tool_result", role="tool", ts=ptime, + tool_call_id=str(call_id) if call_id else None, + tool_output=output, origin="provider_system", + raw_event={"type": "tool", "status": status, + "result": True}) + elif status == "error" or error: + detail = _as_dict(error).get("message") or ( + error if isinstance(error, str) else None) or output + if detail: + ev(kind="tool_result", role="tool", ts=ptime, + tool_call_id=str(call_id) if call_id else None, + tool_output=str(detail), origin="provider_system", + raw_event={"type": "tool", "status": status, + "result": True, "error": True}) + + # -- scan -------------------------------------------------------------- + + def scan(self, source_changed, emit=None) -> List[dict]: + """Scan every readable database, optionally streaming each bundle. + + ``emit`` is the additive streaming extension; with ``emit=None`` the + return value is the complete list, so the two styles index identically. + """ + out: List[dict] = [] + # discover() deliberately returns only usable databases, so a broken + # one would otherwise be invisible here. Name it loudly: an empty index + # must never look like "the provider has no sessions". + for broken in self.unusable_sources(): + if _is_main_db(broken): + verdict, _ = probe(broken) + print(" ! opencode: %s is %s; keeping existing index" + % (broken.name, verdict), file=sys.stderr) + for p in self.discover(): + if not _is_main_db(p): + continue # WAL/SHM are freshness sources + try: + bundles = self._scan_db(p, emit=emit) + except (sqlite3.Error, ValueError) as e: + print(" ! opencode: scan of %s failed (%s); keeping " + "existing index" % (p.name, e), file=sys.stderr) + continue + if emit is None: + out.extend(bundles) + return out + + +register(OpencodeAdapter()) diff --git a/voyager/capability_matrix.py b/voyager/capability_matrix.py index 3068023..05e9e81 100644 --- a/voyager/capability_matrix.py +++ b/voyager/capability_matrix.py @@ -82,6 +82,7 @@ PROVIDERS: Tuple[str, ...] = ( "codex", "claude", "grok", "zcode", "cursor", "kiro", "antigravity", "dsh", + "opencode", ) # --- what each provider supports (the ceiling) ----------------------------- @@ -221,6 +222,30 @@ def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: "context_budget": (NOT_FOUND, "no injection path"), "truncation_behavior": (NOT_FOUND, "no injection path"), }, + # OpenCode: read-only SQLite indexing of session_v2 / session_message and, + # where present, session_part. Two of the three shapes (v2.2.x parts, + # v2.0.x inline) were exercised on synthetic fixtures; discovery and + # message mapping were also observed once on a real v2.0.29 install (see + # docs/audits/2026-11-opencode-v20x-real-gate.md). States stay at + # UNIT_VERIFIED: one install/version/platform is not enough to promote a + # cell, and nothing here is LIVE_VERIFIED. + "opencode": { + "session_discovery": (UNIT_VERIFIED, _ADAPTER + "; read-only SQLite, synthetic fixtures; one real v2.0.29 install observed"), + "session_indexing": (UNIT_VERIFIED, _ADAPTER + "; v2.2.x, v2.0.x-inline and legacy shapes exercised"), + # Reading a session is not resuming one. There is no resume command, + # startup hook or transcript writer for this provider. + "native_resume": (NOT_FOUND, "read-only index only; no resume surface implemented or verified"), + "startup_hook": (NOT_FOUND, "no startup surface"), + "dynamic_context_injection": (NOT_FOUND, "no startup surface to inject into"), + "native_session_id_at_start": (NOT_FOUND, "no startup surface"), + "native_auto_attach": (NOT_FOUND, "no startup surface"), + "live_zero_touch_continuity": (NOT_FOUND, "no startup surface"), + "transcript_import": (UNIT_VERIFIED, _ADAPTER + "; synthetic SQLite; no real import performed"), + "tool_event_support": (UNIT_VERIFIED, "tool call/result from session_part.state or inline content[].state, in fixtures"), + "cwd_repo_discovery": (UNIT_VERIFIED, "session.directory + project.worktree in fixtures"), + "context_budget": (NOT_FOUND, "no injection path"), + "truncation_behavior": (NOT_FOUND, "no injection path"), + }, } #: Where each provider keeps its hook registration, if it has one. @@ -243,6 +268,8 @@ def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: "kiro": "~/.kiro", "antigravity": "~/.gemini/antigravity", "dsh": "~/.dsh/sessions", + # XDG_DATA_HOME/opencode, else ~/.local/share/opencode (all platforms). + "opencode": "~/.local/share/opencode", } #: Per-provider context budget declarations (roadmap Phase 4 / issue #5). @@ -313,6 +340,14 @@ def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: "native_session_id_at_start": False, "auto_budget_target": None, }, + "opencode": { + "startup_context_budget": None, # no injection path + "transport_limit": None, + "truncation_behavior": None, + "supports_spill_pointer": False, + "native_session_id_at_start": False, + "auto_budget_target": None, + }, }