From effacebb56c8b933b65623c3cbbc9a10dbda2ab7 Mon Sep 17 00:00:00 2001 From: HarryHe <2496583567@qq.com> Date: Thu, 12 Nov 2026 22:05:18 +0800 Subject: [PATCH 1/4] feat(opencode): read-only SQLite adapter with v2.0.x inline schema support Adds the OpenCode adapter (read-only SQLite: session_v2 / session_message / session_part / project), a provider capability matrix entry, isolation coverage, and an anonymised fixture suite. Three shapes are detected structurally, never by a product version string: - v2 (v2.2.x): parts in session_part; - v2-inline (v2.0.x): content inline in session_message.data, no session_part; - legacy: session / message / part. The inline shape maps user.text, assistant.content[] (text / reasoning / tool), reads a tool exit code from state.metadata.exit, keeps provider-generated rows as meta, never invents a tool result, and orders events by seq with a deterministic tiebreak. An explicit VOYAGER_OPENCODE_DB / OPENCODE_DB is authoritative, so an operator-named database cannot trigger an unrequested read of the default HOME database. Real-install gate (one v2.0.29 install, isolated copy): probe -> ok/v2-inline, 8/8 sessions indexed, per-kind counts exactly match an independent read-only sqlite3 oracle, and the real .db / -wal / -shm hashes and all row counts are unchanged. Synthetic results stay UNIT_VERIFIED; native resume and cross-agent continuation remain NOT_FOUND / NOT_VERIFIED. --- README.md | 11 +- README.zh-CN.md | 10 +- docs/INDEX.md | 2 + docs/audits/2026-11-opencode-phase3-report.md | 162 ++++ .../audits/2026-11-opencode-v20x-real-gate.md | 119 +++ tests/iso_guard.py | 13 +- tests/opencode_fixtures.py | 390 +++++++++ tests/test_isolation.py | 1 + tests/test_isolation_guard.py | 59 ++ tests/test_opencode.py | 577 +++++++++++++ voyager/adapters/__init__.py | 2 +- voyager/adapters/opencode.py | 763 ++++++++++++++++++ voyager/capability_matrix.py | 31 + 13 files changed, 2136 insertions(+), 4 deletions(-) create mode 100644 docs/audits/2026-11-opencode-phase3-report.md create mode 100644 docs/audits/2026-11-opencode-v20x-real-gate.md create mode 100644 tests/opencode_fixtures.py create mode 100644 tests/test_opencode.py create mode 100644 voyager/adapters/opencode.py 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..a067cf7 --- /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. Sources are copied into +`E:\sessionflow-scratch\taskB\upstream\` for this session (scratch, not a +repository 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..269c1f7 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,26 @@ 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 / + # session_part. Every state below is what the SYNTHETIC suite supports; + # no real install was observed, so nothing is LIVE_VERIFIED. + "opencode": { + "session_discovery": (UNIT_VERIFIED, _ADAPTER + "; read-only SQLite, synthetic fixtures"), + "session_indexing": (UNIT_VERIFIED, _ADAPTER + "; both v2 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, not a real install"), + "tool_event_support": (UNIT_VERIFIED, "tool call/result from session_part.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 +264,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 +336,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, + }, } From 9471608ddb32cb7a45843f8afd58749647f925cd Mon Sep 17 00:00:00 2001 From: HarryHe <2496583567@qq.com> Date: Thu, 12 Nov 2026 22:07:14 +0800 Subject: [PATCH 2/4] docs(opencode): keep the phase-3 report free of a local scratch path --- docs/audits/2026-11-opencode-phase3-report.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/audits/2026-11-opencode-phase3-report.md b/docs/audits/2026-11-opencode-phase3-report.md index a067cf7..4f9abc6 100644 --- a/docs/audits/2026-11-opencode-phase3-report.md +++ b/docs/audits/2026-11-opencode-phase3-report.md @@ -10,9 +10,9 @@ 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. Sources are copied into -`E:\sessionflow-scratch\taskB\upstream\` for this session (scratch, not a -repository artifact). +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 | |---|---| From 96031645330901515503ba58741ee5afa95c847c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=A6=B9=E5=A3=95?= <2496583567@qq.com> Date: Thu, 12 Nov 2026 22:44:48 +0800 Subject: [PATCH 3/4] fix(opencode): correct stale capability-matrix claims The opencode block still said 'no real install was observed' and described only v2/legacy shapes, which contradicts this PR's own gate report (one real v2.0.29 install, three shapes). States stay UNIT_VERIFIED: one install/version/platform does not justify promoting a cell, and nothing is LIVE_VERIFIED. --- voyager/capability_matrix.py | 1292 +++++++++++++++++----------------- 1 file changed, 648 insertions(+), 644 deletions(-) diff --git a/voyager/capability_matrix.py b/voyager/capability_matrix.py index 269c1f7..dc8d73c 100644 --- a/voyager/capability_matrix.py +++ b/voyager/capability_matrix.py @@ -1,644 +1,648 @@ -"""The canonical provider capability matrix. - -One source of truth for what sessionFlow can do with each provider. The README, -`voyager doctor` and `voyager integrate status` all read this, so the docs cannot -drift away from the code. - -Two things are kept separate, because conflating them is how a project ends up -claiming live verification it never did: - - DECLARED -- what the provider supports, as established by reading its - documented contract and by the code in this repo. A ceiling. - EVIDENCE -- what has actually happened on this machine: is the provider - installed, is a hook registered, has that hook been observed - firing, has a bare "continue" resumed a WorkThread. - -A cell resolves to the weaker of the two, and the six states are ordered: - - NOT_FOUND_IN_CURRENT_AUDIT < SUPPORTED < CONFIGURED < UNIT_VERIFIED - < LIVE_VERIFIED < ZERO_TOUCH_LIVE_VERIFIED - -``SUPPORTED`` means "the provider offers this and Voyager implements it"; it never -means "seen working". Only evidence can lift a cell above CONFIGURED, and no -cell is promoted without a recorded reason. - -Usage: ------- -1. Collect runtime evidence: ``from voyager.capability_matrix import summary; summary()`` -2. Generate full matrix: ``from voyager.capability_matrix import matrix; matrix()`` -3. Check individual provider: ``from voyager.capability_matrix import provider_state; provider_state('codex')`` - -This module is imported, not run: the command-line view is -``voyager verify [--matrix] [--json]`` (O6 removed the old -``python -m voyager.capability_matrix`` entry point, which duplicated it). - -All outputs feed into: -- ``voyager doctor --json``: Provider health status -- ``voyager integrate status``: Integration installation state -- README.md: Supported platforms table -- ROADMAP.md: Verification milestones -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -# --- the vocabulary --------------------------------------------------------- - -NOT_FOUND = "NOT_FOUND_IN_CURRENT_AUDIT" -SUPPORTED = "SUPPORTED" -CONFIGURED = "CONFIGURED" -UNIT_VERIFIED = "UNIT_VERIFIED" -LIVE_VERIFIED = "LIVE_VERIFIED" -ZERO_TOUCH_LIVE_VERIFIED = "ZERO_TOUCH_LIVE_VERIFIED" - -STATE_ORDER = { - NOT_FOUND: 0, - SUPPORTED: 1, - CONFIGURED: 2, - UNIT_VERIFIED: 3, - LIVE_VERIFIED: 4, - ZERO_TOUCH_LIVE_VERIFIED: 5, -} - -#: The dimensions every provider is measured against. -DIMENSIONS: Tuple[str, ...] = ( - "session_discovery", - "session_indexing", - "native_resume", - "startup_hook", - "dynamic_context_injection", - "native_session_id_at_start", - "native_auto_attach", - "live_zero_touch_continuity", - "transcript_import", - "tool_event_support", - "cwd_repo_discovery", - "context_budget", - "truncation_behavior", -) - -PROVIDERS: Tuple[str, ...] = ( - "codex", "claude", "grok", "zcode", "cursor", "kiro", "antigravity", "dsh", - "opencode", -) - -# --- what each provider supports (the ceiling) ----------------------------- -# -# Every entry is (state, note). The note is the evidence for the ceiling and is -# surfaced by `doctor`, so a reader can check the claim instead of trusting it. - -_ADAPTER = "adapter in voyager/adapters/" -_HANDLER = "startup handler in voyager/integrations/" - - -def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: - return {d: (state, note) for d in DIMENSIONS} - - -DECLARED: Dict[str, Dict[str, Tuple[str, str]]] = { - "codex": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; rollout JSONL"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "`codex resume` is documented"), - "startup_hook": (LIVE_VERIFIED, "hooks.json SessionStart, observed firing"), - "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), - "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), - "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), - "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, - "2026-09-28: bare 继续 resumed the WorkThread"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool events present in rollouts"), - "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd + rollout fallback"), - "context_budget": (LIVE_VERIFIED, "10,000 chars measured; window built at 7,600"), - "truncation_behavior": (LIVE_VERIFIED, "measured: middle elision above 10,000"), - }, - "claude": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; project JSONL"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "`claude --resume` is documented"), - "startup_hook": (LIVE_VERIFIED, "settings.json SessionStart, observed firing"), - "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), - "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), - "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), - "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, - "2026-09-24/25: cross-provider leg ran live"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool_use / tool_result records"), - "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd"), - "context_budget": (LIVE_VERIFIED, "10,000 chars; window built at 7,600"), - "truncation_behavior": (SUPPORTED, "documented cap; not triggered since the budget fix"), - }, - "grok": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; chat_history.jsonl"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "`grok -r` is documented"), - "startup_hook": (LIVE_VERIFIED, "rules-writer hook, observed firing"), - "dynamic_context_injection": (LIVE_VERIFIED, "rules file written before launch"), - "native_session_id_at_start": (LIVE_VERIFIED, "session id recorded on start"), - "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), - "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, - "2026-09-25: sentinel written in Claude was recovered"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (LIVE_VERIFIED, "cwd resolved at start"), - "context_budget": (SUPPORTED, "documented budget; not measured here"), - "truncation_behavior": (NOT_FOUND, "no measurement recorded"), - }, - "zcode": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; SQLite"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "desktop resume is documented"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; registered; not yet observed firing"), - "dynamic_context_injection": (UNIT_VERIFIED, "envelope driven end to end with a real payload"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "file events stay in raw"), - "cwd_repo_discovery": (UNIT_VERIFIED, "payload cwd"), - "context_budget": (UNIT_VERIFIED, "shared cap; no documented provider limit"), - "truncation_behavior": (NOT_FOUND, "not measured; ZCode reports maxOutputBytes"), - }, - "cursor": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; state.vscdb"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "no resume surface found; IDE only"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks.json registered; not yet fired"), - "dynamic_context_injection": (UNIT_VERIFIED, "top-level additional_context verified"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id / conversation_id"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool data in raw"), - "cwd_repo_discovery": (UNIT_VERIFIED, "workspace_roots (no cwd on sessionStart)"), - "context_budget": (UNIT_VERIFIED, "shared cap; Cursor documents no limit"), - "truncation_behavior": (NOT_FOUND, "not measured"), - }, - "kiro": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; workspace-session JSON"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "sessions are not persisted across restarts"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks are project-scoped, so none installed globally"), - "dynamic_context_injection": (UNIT_VERIFIED, "plain-text stdout contract verified"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload session id, if present"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (UNIT_VERIFIED, "project root of the command hook"), - "context_budget": (UNIT_VERIFIED, "shared cap; Kiro documents no limit"), - "truncation_behavior": (NOT_FOUND, "not measured"), - }, - "antigravity": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; conversation SQLite"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "no resume surface found"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; PreInvocation, registered globally"), - "dynamic_context_injection": (UNIT_VERIFIED, "injectSteps.ephemeralMessage verified"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload conversationId"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (UNIT_VERIFIED, "workspacePaths"), - "context_budget": (UNIT_VERIFIED, "shared cap; no documented limit"), - "truncation_behavior": (NOT_FOUND, "not measured"), - }, - "dsh": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; zstd JSONL"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "`dsh --resume` needs `--profile`, which a session does not record"), - "startup_hook": (NOT_FOUND, "no native startup surface; profiles/plugins/ACP only"), - "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": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (LIVE_VERIFIED, "session records carry cwd"), - "context_budget": (NOT_FOUND, "no injection path"), - "truncation_behavior": (NOT_FOUND, "no injection path"), - }, - # OpenCode: read-only SQLite indexing of session_v2 / session_message / - # session_part. Every state below is what the SYNTHETIC suite supports; - # no real install was observed, so nothing is LIVE_VERIFIED. - "opencode": { - "session_discovery": (UNIT_VERIFIED, _ADAPTER + "; read-only SQLite, synthetic fixtures"), - "session_indexing": (UNIT_VERIFIED, _ADAPTER + "; both v2 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, not a real install"), - "tool_event_support": (UNIT_VERIFIED, "tool call/result from session_part.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. -HOOK_CONFIG_PATHS: Dict[str, str] = { - "codex": "~/.codex/hooks.json", - "claude": "~/.claude/settings.json", - "zcode": "~/.zcode/cli/config.json", - "cursor": "~/.cursor/hooks.json", - "kiro": ".kiro/hooks/.json", # project-scoped, not global - "antigravity": "~/.gemini/config/hooks.json", -} - -#: Where each provider's own session data lives, for source-path checks. -SOURCE_PATHS: Dict[str, str] = { - "codex": "~/.codex/sessions", - "claude": "~/.claude/projects", - "grok": "~/.grok", - "zcode": "~/.zcode/cli/db", - "cursor": "~/.cursor", # config/data root on Windows - "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). -# Each provider declares its maximum safe payload size, transport limits, -# and truncation/spill behaviors. These override the global defaults. -PROVIDER_CONTEXT_BUDGETS: Dict[str, Dict[str, Any]] = { - "codex": { - "startup_context_budget": 7600, # chars before Codex additionalContext cap - "transport_limit": 8000, # max bytes per hookSpecificOutput - "truncation_behavior": "middle_elision", # measured: middle cut above 10k - "supports_spill_pointer": False, # no spill path in hooks.json - "native_session_id_at_start": True, - "auto_budget_target": "balanced", # map to preset - }, - "claude": { - "startup_context_budget": 7600, # chars before claude cap - "transport_limit": 10000, # additionalContext ceiling - "truncation_behavior": "header_elision", # drops header sections first - "supports_spill_pointer": True, # can use spilled path pointer - "native_session_id_at_start": True, - "auto_budget_target": "balanced", - }, - "grok": { - "startup_context_budget": 5000, # rules file size constraint - "transport_limit": 5500, - "truncation_behavior": "section_deduplication", - "supports_spill_pointer": False, - "native_session_id_at_start": True, - "auto_budget_target": "compact", - }, - "zcode": { - "startup_context_budget": 8000, # shared cap; ZCode reports maxOutputBytes - "transport_limit": 10000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "balanced", - }, - "cursor": { - "startup_context_budget": 8000, # Cursor documents no limit - "transport_limit": 12000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "full", - }, - "kiro": { - "startup_context_budget": 8000, # Kiro documents no limit - "transport_limit": 12000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "full", - }, - "antigravity": { - "startup_context_budget": 8000, # no documented limit - "transport_limit": 12000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "full", - }, - "dsh": { - "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, - }, - "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, - }, -} - - -# --- runtime evidence ------------------------------------------------------ - -@dataclass -class Evidence: - """What has actually been observed on this machine.""" - - installed: bool = False - hook_registered: bool = False - hook_fired: bool = False - zero_touch_observed: bool = False - last_trigger: Optional[str] = None - notes: List[str] = field(default_factory=list) - - -def _log_dir() -> Path: - import os - override = os.environ.get("VOYAGER_LOG_DIR") - if override: - return Path(override).expanduser() - return Path.home() / ".voyager" / "logs" - - -def collect_evidence(provider: str) -> Evidence: - """Read the machine state for one provider. Best effort, never raises. - - Runtime evidence (hook fired, zero-touch observed) comes **only** from the - append-only ``verification_events`` table managed by - :mod:`voyager.verification_harness`. No hardcoded sets, no log-file - grepping, no "adapter exists = live" shortcuts -- if the evidence table has - no rows for a provider, that provider is ``UNIT_VERIFIED`` at best, which - is the honest answer ("code exists, not yet observed on this machine"). - - ``installed`` and ``hook_registered`` are still checked on the filesystem, - because those describe *configuration* state, not *runtime* state. - """ - ev = Evidence() - try: - src = SOURCE_PATHS.get(provider) - ev.installed = bool(src) and Path(src).expanduser().exists() - except Exception: - ev.installed = False - try: - cfg = HOOK_CONFIG_PATHS.get(provider) - ev.hook_registered = bool(cfg) and not cfg.startswith(".") and Path(cfg).expanduser().exists() - except Exception: - ev.hook_registered = False - - # Query the evidence table for runtime observations. This is the sole - # source of hook_fired / zero_touch_observed -- no hardcoded sets, no log - # grepping. If the table does not exist or has no events for this - # provider, the evidence stays at its defaults (all False), which means - # the ceiling is UNIT_VERIFIED. That is correct. - try: - from .verification_harness import _connect, observed_state, has_evidence_table - con = _connect() - if con is not None: - try: - if has_evidence_table(con): - obs = observed_state(con, provider) - state = obs.get("observed_state") - if state == ZERO_TOUCH_LIVE_VERIFIED: - ev.zero_touch_observed = True - ev.hook_fired = True - elif state == LIVE_VERIFIED: - ev.hook_fired = True - ev.last_trigger = obs.get("last_live_event") - finally: - try: - con.close() - except Exception: - pass - except Exception: - pass # verification_harness not available or DB inaccessible - - return ev - - -# --- resolution ------------------------------------------------------------ - -def _weaker(a: str, b: str) -> str: - return a if STATE_ORDER.get(a, 0) <= STATE_ORDER.get(b, 0) else b - - -def evidence_ceiling(ev: Evidence) -> str: - """The highest state the machine evidence can justify. - - The floor is UNIT_VERIFIED, not CONFIGURED: a handler that exists and is - covered by tests is *verified at the unit level* whether or not this machine - happens to have the provider installed. Evidence below that only ever lowers - a LIVE / ZERO_TOUCH claim, never a code-level one. - """ - if ev.zero_touch_observed: - return ZERO_TOUCH_LIVE_VERIFIED - if ev.hook_fired: - return LIVE_VERIFIED - return UNIT_VERIFIED - - -def adapter_can_resume(provider: str) -> Optional[bool]: - """What the adapter itself says, or None when there is no adapter. - - The adapter is the authority on native resume: it is the code that would - have to build the command. When it sets ``can_resume = False`` the - provider cannot be resumed no matter what a hand-written table claims, and - reporting SUPPORTED tells the user to run a command that does not exist. - """ - try: - from voyager.adapters import load_all - from voyager.adapters.base import get_adapter - except Exception: # noqa: BLE001 - a broken import must not break the matrix - return None - try: - # Importing the adapter modules is what registers them; without this - # the registry is empty and every provider looks like "no adapter". - load_all() - adapter = get_adapter(provider) - except Exception: # noqa: BLE001 - return None - if adapter is None: - return None - return bool(getattr(adapter, "can_resume", False)) - - -def resolve_cell(provider: str, dimension: str, ev: Optional[Evidence] = None) -> Tuple[str, str]: - """The actual state of one cell, with the reason it is not higher.""" - declared = DECLARED.get(provider, {}).get(dimension, (NOT_FOUND, "no entry")) - state, note = declared - ev = ev or collect_evidence(provider) - - # Native resume is a claim about code, not about this machine: if the - # adapter says it cannot resume, no declaration may outrank that. This is - # checked before the evidence ceiling because it is not an evidence - # question at all -- see the zcode entry, which declared SUPPORTED while - # its adapter sets can_resume=False. - if dimension == "native_resume": - can = adapter_can_resume(provider) - if can is False and STATE_ORDER.get(state, 0) > STATE_ORDER.get(NOT_FOUND, 0): - return (NOT_FOUND, - "the %s adapter sets can_resume=False; declared %s (%s)" - % (provider, state, note)) - - # Dimensions that describe data on disk do not depend on a hook firing. - machine_dependent = dimension in ( - "startup_hook", "dynamic_context_injection", "native_session_id_at_start", - "native_auto_attach", "live_zero_touch_continuity", - ) - if machine_dependent: - ceiling = evidence_ceiling(ev) - if STATE_ORDER.get(state, 0) > STATE_ORDER.get(ceiling, 0): - reason = "declared %s; on this machine: %s" % (state, _explain(ev, ceiling)) - return (ceiling, reason) - return (state, note) - - -def _explain(ev: Evidence, ceiling: str) -> str: - if ceiling == UNIT_VERIFIED: - return "hook registered, covered by tests, provider not yet observed firing" - if ceiling == LIVE_VERIFIED: - return "hook observed firing" - if ceiling == CONFIGURED: - return "provider installed, hook not registered" - if ceiling == ZERO_TOUCH_LIVE_VERIFIED: - return "a bare continue resumed the WorkThread with no Voyager command" - return "not observed on this machine" - - -def matrix(providers: Optional[List[str]] = None) -> Dict[str, Dict[str, Dict[str, str]]]: - """The resolved matrix: {provider: {dimension: {state, note}}}.""" - out: Dict[str, Dict[str, Dict[str, str]]] = {} - for p in (providers or PROVIDERS): - ev = collect_evidence(p) - out[p] = {} - for d in DIMENSIONS: - state, note = resolve_cell(p, d, ev) - out[p][d] = {"state": state, "note": note} - return out - - -def provider_state(provider: str, ev: Optional[Evidence] = None) -> str: - """One headline state for a provider. - - The headline is the continuity a user actually gets: ZERO_TOUCH_LIVE_VERIFIED - when a bare continue was observed resuming the thread, LIVE_VERIFIED when the - hook was seen firing, otherwise whatever the startup-hook dimension supports - -- **capped at the evidence ceiling**. A provider whose code declares - LIVE_VERIFIED but has no evidence in the table reports UNIT_VERIFIED, - because "the handler exists and is tested" is all the evidence supports. - - A provider with no startup surface at all reports NOT_FOUND -- it is not - silently folded into SUPPORTED. - """ - ev = ev or collect_evidence(provider) - declared = DECLARED.get(provider, {}).get("startup_hook", (NOT_FOUND, ""))[0] - if declared == NOT_FOUND: - return NOT_FOUND - if ev.zero_touch_observed: - return ZERO_TOUCH_LIVE_VERIFIED - if ev.hook_fired: - return LIVE_VERIFIED - # No live evidence: cap at the evidence ceiling (UNIT_VERIFIED when no - # evidence table or no events). The declared ceiling may be higher - # (LIVE_VERIFIED / ZERO_TOUCH_LIVE_VERIFIED), but without evidence the - # honest answer is the ceiling the evidence can justify. - ceiling = evidence_ceiling(ev) - if STATE_ORDER.get(declared, 0) > STATE_ORDER.get(ceiling, 0): - return ceiling - return declared - - -def summary() -> Dict[str, Any]: - """What `doctor` prints and `--json` emits.""" - providers = {} - for p in PROVIDERS: - ev = collect_evidence(p) - providers[p] = { - "state": provider_state(p, ev), - "installed": ev.installed, - "hook_registered": ev.hook_registered, - "hook_fired": ev.hook_fired, - "last_trigger": ev.last_trigger, - "context_budget": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("startup_context_budget"), - "auto_budget_target": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("auto_budget_target"), - } - return {"providers": providers, - "dimensions": list(DIMENSIONS), - "states": list(STATE_ORDER)} - - -# --- provider-aware budget helpers ----------------------------------------- - -def provider_context_budget(provider: str) -> Optional[int]: - """Get the declared startup context budget (in chars) for a provider. - - Returns None if provider has no injection path (e.g., DSH). - """ - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("startup_context_budget") - - -def provider_transport_limit(provider: str) -> Optional[int]: - """Get the transport limit (max bytes per hook payload) for a provider.""" - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("transport_limit") - - -def provider_truncation_behavior(provider: str) -> Optional[str]: - """Get the truncation behavior strategy for a provider. - - Strategies: "middle_elision", "header_elision", "section_deduplication", "section_dropping" - """ - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("truncation_behavior") - - -def provider_supports_spill_pointer(provider: str) -> bool: - """Does this provider support spill pointer mechanism?""" - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("supports_spill_pointer", False) - - -def provider_auto_budget_target(provider: str) -> Optional[str]: - """Get the auto-budget target preset name for a provider. - - Maps to: "compact" | "balanced" | "full" | None - """ - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("auto_budget_target") - - -def resolve_provider_budget(provider: str, user_specified: Optional[str] = None) -> int: - """Resolve the actual token budget for a given provider. - - Priority order: - 1. User-specified budget overrides everything - 2. Provider-specific startup_context_budget converted to tokens - 3. Global default (AUTO_BUDGET_TOKENS from budget.py) - - Returns token count (chars/4 estimate). - """ - from .budget import BUDGET_PRESETS, AUTO_BUDGET_TOKENS - - # 1. If user explicitly specified, use that - if user_specified and user_specified.lower() != "auto": - from .budget import parse_budget - parsed = parse_budget(user_specified) - if parsed: - return parsed - - # 2. Try provider-specific budget - char_budget = provider_context_budget(provider) - if char_budget: - # Convert chars to tokens using standard estimate (chars/4) - return max(1, char_budget // 4) - - # 3. Fallback to global default - auto_target = provider_auto_budget_target(provider) - if auto_target and auto_target in BUDGET_PRESETS: - return BUDGET_PRESETS[auto_target] - - return AUTO_BUDGET_TOKENS +"""The canonical provider capability matrix. + +One source of truth for what sessionFlow can do with each provider. The README, +`voyager doctor` and `voyager integrate status` all read this, so the docs cannot +drift away from the code. + +Two things are kept separate, because conflating them is how a project ends up +claiming live verification it never did: + + DECLARED -- what the provider supports, as established by reading its + documented contract and by the code in this repo. A ceiling. + EVIDENCE -- what has actually happened on this machine: is the provider + installed, is a hook registered, has that hook been observed + firing, has a bare "continue" resumed a WorkThread. + +A cell resolves to the weaker of the two, and the six states are ordered: + + NOT_FOUND_IN_CURRENT_AUDIT < SUPPORTED < CONFIGURED < UNIT_VERIFIED + < LIVE_VERIFIED < ZERO_TOUCH_LIVE_VERIFIED + +``SUPPORTED`` means "the provider offers this and Voyager implements it"; it never +means "seen working". Only evidence can lift a cell above CONFIGURED, and no +cell is promoted without a recorded reason. + +Usage: +------ +1. Collect runtime evidence: ``from voyager.capability_matrix import summary; summary()`` +2. Generate full matrix: ``from voyager.capability_matrix import matrix; matrix()`` +3. Check individual provider: ``from voyager.capability_matrix import provider_state; provider_state('codex')`` + +This module is imported, not run: the command-line view is +``voyager verify [--matrix] [--json]`` (O6 removed the old +``python -m voyager.capability_matrix`` entry point, which duplicated it). + +All outputs feed into: +- ``voyager doctor --json``: Provider health status +- ``voyager integrate status``: Integration installation state +- README.md: Supported platforms table +- ROADMAP.md: Verification milestones +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# --- the vocabulary --------------------------------------------------------- + +NOT_FOUND = "NOT_FOUND_IN_CURRENT_AUDIT" +SUPPORTED = "SUPPORTED" +CONFIGURED = "CONFIGURED" +UNIT_VERIFIED = "UNIT_VERIFIED" +LIVE_VERIFIED = "LIVE_VERIFIED" +ZERO_TOUCH_LIVE_VERIFIED = "ZERO_TOUCH_LIVE_VERIFIED" + +STATE_ORDER = { + NOT_FOUND: 0, + SUPPORTED: 1, + CONFIGURED: 2, + UNIT_VERIFIED: 3, + LIVE_VERIFIED: 4, + ZERO_TOUCH_LIVE_VERIFIED: 5, +} + +#: The dimensions every provider is measured against. +DIMENSIONS: Tuple[str, ...] = ( + "session_discovery", + "session_indexing", + "native_resume", + "startup_hook", + "dynamic_context_injection", + "native_session_id_at_start", + "native_auto_attach", + "live_zero_touch_continuity", + "transcript_import", + "tool_event_support", + "cwd_repo_discovery", + "context_budget", + "truncation_behavior", +) + +PROVIDERS: Tuple[str, ...] = ( + "codex", "claude", "grok", "zcode", "cursor", "kiro", "antigravity", "dsh", + "opencode", +) + +# --- what each provider supports (the ceiling) ----------------------------- +# +# Every entry is (state, note). The note is the evidence for the ceiling and is +# surfaced by `doctor`, so a reader can check the claim instead of trusting it. + +_ADAPTER = "adapter in voyager/adapters/" +_HANDLER = "startup handler in voyager/integrations/" + + +def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: + return {d: (state, note) for d in DIMENSIONS} + + +DECLARED: Dict[str, Dict[str, Tuple[str, str]]] = { + "codex": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; rollout JSONL"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "`codex resume` is documented"), + "startup_hook": (LIVE_VERIFIED, "hooks.json SessionStart, observed firing"), + "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), + "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), + "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), + "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, + "2026-09-28: bare 继续 resumed the WorkThread"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool events present in rollouts"), + "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd + rollout fallback"), + "context_budget": (LIVE_VERIFIED, "10,000 chars measured; window built at 7,600"), + "truncation_behavior": (LIVE_VERIFIED, "measured: middle elision above 10,000"), + }, + "claude": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; project JSONL"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "`claude --resume` is documented"), + "startup_hook": (LIVE_VERIFIED, "settings.json SessionStart, observed firing"), + "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), + "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), + "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), + "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, + "2026-09-24/25: cross-provider leg ran live"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool_use / tool_result records"), + "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd"), + "context_budget": (LIVE_VERIFIED, "10,000 chars; window built at 7,600"), + "truncation_behavior": (SUPPORTED, "documented cap; not triggered since the budget fix"), + }, + "grok": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; chat_history.jsonl"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "`grok -r` is documented"), + "startup_hook": (LIVE_VERIFIED, "rules-writer hook, observed firing"), + "dynamic_context_injection": (LIVE_VERIFIED, "rules file written before launch"), + "native_session_id_at_start": (LIVE_VERIFIED, "session id recorded on start"), + "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), + "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, + "2026-09-25: sentinel written in Claude was recovered"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (LIVE_VERIFIED, "cwd resolved at start"), + "context_budget": (SUPPORTED, "documented budget; not measured here"), + "truncation_behavior": (NOT_FOUND, "no measurement recorded"), + }, + "zcode": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; SQLite"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "desktop resume is documented"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; registered; not yet observed firing"), + "dynamic_context_injection": (UNIT_VERIFIED, "envelope driven end to end with a real payload"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "file events stay in raw"), + "cwd_repo_discovery": (UNIT_VERIFIED, "payload cwd"), + "context_budget": (UNIT_VERIFIED, "shared cap; no documented provider limit"), + "truncation_behavior": (NOT_FOUND, "not measured; ZCode reports maxOutputBytes"), + }, + "cursor": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; state.vscdb"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "no resume surface found; IDE only"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks.json registered; not yet fired"), + "dynamic_context_injection": (UNIT_VERIFIED, "top-level additional_context verified"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id / conversation_id"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool data in raw"), + "cwd_repo_discovery": (UNIT_VERIFIED, "workspace_roots (no cwd on sessionStart)"), + "context_budget": (UNIT_VERIFIED, "shared cap; Cursor documents no limit"), + "truncation_behavior": (NOT_FOUND, "not measured"), + }, + "kiro": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; workspace-session JSON"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "sessions are not persisted across restarts"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks are project-scoped, so none installed globally"), + "dynamic_context_injection": (UNIT_VERIFIED, "plain-text stdout contract verified"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload session id, if present"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (UNIT_VERIFIED, "project root of the command hook"), + "context_budget": (UNIT_VERIFIED, "shared cap; Kiro documents no limit"), + "truncation_behavior": (NOT_FOUND, "not measured"), + }, + "antigravity": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; conversation SQLite"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "no resume surface found"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; PreInvocation, registered globally"), + "dynamic_context_injection": (UNIT_VERIFIED, "injectSteps.ephemeralMessage verified"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload conversationId"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (UNIT_VERIFIED, "workspacePaths"), + "context_budget": (UNIT_VERIFIED, "shared cap; no documented limit"), + "truncation_behavior": (NOT_FOUND, "not measured"), + }, + "dsh": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; zstd JSONL"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "`dsh --resume` needs `--profile`, which a session does not record"), + "startup_hook": (NOT_FOUND, "no native startup surface; profiles/plugins/ACP only"), + "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": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (LIVE_VERIFIED, "session records carry cwd"), + "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. +HOOK_CONFIG_PATHS: Dict[str, str] = { + "codex": "~/.codex/hooks.json", + "claude": "~/.claude/settings.json", + "zcode": "~/.zcode/cli/config.json", + "cursor": "~/.cursor/hooks.json", + "kiro": ".kiro/hooks/.json", # project-scoped, not global + "antigravity": "~/.gemini/config/hooks.json", +} + +#: Where each provider's own session data lives, for source-path checks. +SOURCE_PATHS: Dict[str, str] = { + "codex": "~/.codex/sessions", + "claude": "~/.claude/projects", + "grok": "~/.grok", + "zcode": "~/.zcode/cli/db", + "cursor": "~/.cursor", # config/data root on Windows + "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). +# Each provider declares its maximum safe payload size, transport limits, +# and truncation/spill behaviors. These override the global defaults. +PROVIDER_CONTEXT_BUDGETS: Dict[str, Dict[str, Any]] = { + "codex": { + "startup_context_budget": 7600, # chars before Codex additionalContext cap + "transport_limit": 8000, # max bytes per hookSpecificOutput + "truncation_behavior": "middle_elision", # measured: middle cut above 10k + "supports_spill_pointer": False, # no spill path in hooks.json + "native_session_id_at_start": True, + "auto_budget_target": "balanced", # map to preset + }, + "claude": { + "startup_context_budget": 7600, # chars before claude cap + "transport_limit": 10000, # additionalContext ceiling + "truncation_behavior": "header_elision", # drops header sections first + "supports_spill_pointer": True, # can use spilled path pointer + "native_session_id_at_start": True, + "auto_budget_target": "balanced", + }, + "grok": { + "startup_context_budget": 5000, # rules file size constraint + "transport_limit": 5500, + "truncation_behavior": "section_deduplication", + "supports_spill_pointer": False, + "native_session_id_at_start": True, + "auto_budget_target": "compact", + }, + "zcode": { + "startup_context_budget": 8000, # shared cap; ZCode reports maxOutputBytes + "transport_limit": 10000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "balanced", + }, + "cursor": { + "startup_context_budget": 8000, # Cursor documents no limit + "transport_limit": 12000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "full", + }, + "kiro": { + "startup_context_budget": 8000, # Kiro documents no limit + "transport_limit": 12000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "full", + }, + "antigravity": { + "startup_context_budget": 8000, # no documented limit + "transport_limit": 12000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "full", + }, + "dsh": { + "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, + }, + "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, + }, +} + + +# --- runtime evidence ------------------------------------------------------ + +@dataclass +class Evidence: + """What has actually been observed on this machine.""" + + installed: bool = False + hook_registered: bool = False + hook_fired: bool = False + zero_touch_observed: bool = False + last_trigger: Optional[str] = None + notes: List[str] = field(default_factory=list) + + +def _log_dir() -> Path: + import os + override = os.environ.get("VOYAGER_LOG_DIR") + if override: + return Path(override).expanduser() + return Path.home() / ".voyager" / "logs" + + +def collect_evidence(provider: str) -> Evidence: + """Read the machine state for one provider. Best effort, never raises. + + Runtime evidence (hook fired, zero-touch observed) comes **only** from the + append-only ``verification_events`` table managed by + :mod:`voyager.verification_harness`. No hardcoded sets, no log-file + grepping, no "adapter exists = live" shortcuts -- if the evidence table has + no rows for a provider, that provider is ``UNIT_VERIFIED`` at best, which + is the honest answer ("code exists, not yet observed on this machine"). + + ``installed`` and ``hook_registered`` are still checked on the filesystem, + because those describe *configuration* state, not *runtime* state. + """ + ev = Evidence() + try: + src = SOURCE_PATHS.get(provider) + ev.installed = bool(src) and Path(src).expanduser().exists() + except Exception: + ev.installed = False + try: + cfg = HOOK_CONFIG_PATHS.get(provider) + ev.hook_registered = bool(cfg) and not cfg.startswith(".") and Path(cfg).expanduser().exists() + except Exception: + ev.hook_registered = False + + # Query the evidence table for runtime observations. This is the sole + # source of hook_fired / zero_touch_observed -- no hardcoded sets, no log + # grepping. If the table does not exist or has no events for this + # provider, the evidence stays at its defaults (all False), which means + # the ceiling is UNIT_VERIFIED. That is correct. + try: + from .verification_harness import _connect, observed_state, has_evidence_table + con = _connect() + if con is not None: + try: + if has_evidence_table(con): + obs = observed_state(con, provider) + state = obs.get("observed_state") + if state == ZERO_TOUCH_LIVE_VERIFIED: + ev.zero_touch_observed = True + ev.hook_fired = True + elif state == LIVE_VERIFIED: + ev.hook_fired = True + ev.last_trigger = obs.get("last_live_event") + finally: + try: + con.close() + except Exception: + pass + except Exception: + pass # verification_harness not available or DB inaccessible + + return ev + + +# --- resolution ------------------------------------------------------------ + +def _weaker(a: str, b: str) -> str: + return a if STATE_ORDER.get(a, 0) <= STATE_ORDER.get(b, 0) else b + + +def evidence_ceiling(ev: Evidence) -> str: + """The highest state the machine evidence can justify. + + The floor is UNIT_VERIFIED, not CONFIGURED: a handler that exists and is + covered by tests is *verified at the unit level* whether or not this machine + happens to have the provider installed. Evidence below that only ever lowers + a LIVE / ZERO_TOUCH claim, never a code-level one. + """ + if ev.zero_touch_observed: + return ZERO_TOUCH_LIVE_VERIFIED + if ev.hook_fired: + return LIVE_VERIFIED + return UNIT_VERIFIED + + +def adapter_can_resume(provider: str) -> Optional[bool]: + """What the adapter itself says, or None when there is no adapter. + + The adapter is the authority on native resume: it is the code that would + have to build the command. When it sets ``can_resume = False`` the + provider cannot be resumed no matter what a hand-written table claims, and + reporting SUPPORTED tells the user to run a command that does not exist. + """ + try: + from voyager.adapters import load_all + from voyager.adapters.base import get_adapter + except Exception: # noqa: BLE001 - a broken import must not break the matrix + return None + try: + # Importing the adapter modules is what registers them; without this + # the registry is empty and every provider looks like "no adapter". + load_all() + adapter = get_adapter(provider) + except Exception: # noqa: BLE001 + return None + if adapter is None: + return None + return bool(getattr(adapter, "can_resume", False)) + + +def resolve_cell(provider: str, dimension: str, ev: Optional[Evidence] = None) -> Tuple[str, str]: + """The actual state of one cell, with the reason it is not higher.""" + declared = DECLARED.get(provider, {}).get(dimension, (NOT_FOUND, "no entry")) + state, note = declared + ev = ev or collect_evidence(provider) + + # Native resume is a claim about code, not about this machine: if the + # adapter says it cannot resume, no declaration may outrank that. This is + # checked before the evidence ceiling because it is not an evidence + # question at all -- see the zcode entry, which declared SUPPORTED while + # its adapter sets can_resume=False. + if dimension == "native_resume": + can = adapter_can_resume(provider) + if can is False and STATE_ORDER.get(state, 0) > STATE_ORDER.get(NOT_FOUND, 0): + return (NOT_FOUND, + "the %s adapter sets can_resume=False; declared %s (%s)" + % (provider, state, note)) + + # Dimensions that describe data on disk do not depend on a hook firing. + machine_dependent = dimension in ( + "startup_hook", "dynamic_context_injection", "native_session_id_at_start", + "native_auto_attach", "live_zero_touch_continuity", + ) + if machine_dependent: + ceiling = evidence_ceiling(ev) + if STATE_ORDER.get(state, 0) > STATE_ORDER.get(ceiling, 0): + reason = "declared %s; on this machine: %s" % (state, _explain(ev, ceiling)) + return (ceiling, reason) + return (state, note) + + +def _explain(ev: Evidence, ceiling: str) -> str: + if ceiling == UNIT_VERIFIED: + return "hook registered, covered by tests, provider not yet observed firing" + if ceiling == LIVE_VERIFIED: + return "hook observed firing" + if ceiling == CONFIGURED: + return "provider installed, hook not registered" + if ceiling == ZERO_TOUCH_LIVE_VERIFIED: + return "a bare continue resumed the WorkThread with no Voyager command" + return "not observed on this machine" + + +def matrix(providers: Optional[List[str]] = None) -> Dict[str, Dict[str, Dict[str, str]]]: + """The resolved matrix: {provider: {dimension: {state, note}}}.""" + out: Dict[str, Dict[str, Dict[str, str]]] = {} + for p in (providers or PROVIDERS): + ev = collect_evidence(p) + out[p] = {} + for d in DIMENSIONS: + state, note = resolve_cell(p, d, ev) + out[p][d] = {"state": state, "note": note} + return out + + +def provider_state(provider: str, ev: Optional[Evidence] = None) -> str: + """One headline state for a provider. + + The headline is the continuity a user actually gets: ZERO_TOUCH_LIVE_VERIFIED + when a bare continue was observed resuming the thread, LIVE_VERIFIED when the + hook was seen firing, otherwise whatever the startup-hook dimension supports + -- **capped at the evidence ceiling**. A provider whose code declares + LIVE_VERIFIED but has no evidence in the table reports UNIT_VERIFIED, + because "the handler exists and is tested" is all the evidence supports. + + A provider with no startup surface at all reports NOT_FOUND -- it is not + silently folded into SUPPORTED. + """ + ev = ev or collect_evidence(provider) + declared = DECLARED.get(provider, {}).get("startup_hook", (NOT_FOUND, ""))[0] + if declared == NOT_FOUND: + return NOT_FOUND + if ev.zero_touch_observed: + return ZERO_TOUCH_LIVE_VERIFIED + if ev.hook_fired: + return LIVE_VERIFIED + # No live evidence: cap at the evidence ceiling (UNIT_VERIFIED when no + # evidence table or no events). The declared ceiling may be higher + # (LIVE_VERIFIED / ZERO_TOUCH_LIVE_VERIFIED), but without evidence the + # honest answer is the ceiling the evidence can justify. + ceiling = evidence_ceiling(ev) + if STATE_ORDER.get(declared, 0) > STATE_ORDER.get(ceiling, 0): + return ceiling + return declared + + +def summary() -> Dict[str, Any]: + """What `doctor` prints and `--json` emits.""" + providers = {} + for p in PROVIDERS: + ev = collect_evidence(p) + providers[p] = { + "state": provider_state(p, ev), + "installed": ev.installed, + "hook_registered": ev.hook_registered, + "hook_fired": ev.hook_fired, + "last_trigger": ev.last_trigger, + "context_budget": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("startup_context_budget"), + "auto_budget_target": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("auto_budget_target"), + } + return {"providers": providers, + "dimensions": list(DIMENSIONS), + "states": list(STATE_ORDER)} + + +# --- provider-aware budget helpers ----------------------------------------- + +def provider_context_budget(provider: str) -> Optional[int]: + """Get the declared startup context budget (in chars) for a provider. + + Returns None if provider has no injection path (e.g., DSH). + """ + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("startup_context_budget") + + +def provider_transport_limit(provider: str) -> Optional[int]: + """Get the transport limit (max bytes per hook payload) for a provider.""" + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("transport_limit") + + +def provider_truncation_behavior(provider: str) -> Optional[str]: + """Get the truncation behavior strategy for a provider. + + Strategies: "middle_elision", "header_elision", "section_deduplication", "section_dropping" + """ + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("truncation_behavior") + + +def provider_supports_spill_pointer(provider: str) -> bool: + """Does this provider support spill pointer mechanism?""" + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("supports_spill_pointer", False) + + +def provider_auto_budget_target(provider: str) -> Optional[str]: + """Get the auto-budget target preset name for a provider. + + Maps to: "compact" | "balanced" | "full" | None + """ + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("auto_budget_target") + + +def resolve_provider_budget(provider: str, user_specified: Optional[str] = None) -> int: + """Resolve the actual token budget for a given provider. + + Priority order: + 1. User-specified budget overrides everything + 2. Provider-specific startup_context_budget converted to tokens + 3. Global default (AUTO_BUDGET_TOKENS from budget.py) + + Returns token count (chars/4 estimate). + """ + from .budget import BUDGET_PRESETS, AUTO_BUDGET_TOKENS + + # 1. If user explicitly specified, use that + if user_specified and user_specified.lower() != "auto": + from .budget import parse_budget + parsed = parse_budget(user_specified) + if parsed: + return parsed + + # 2. Try provider-specific budget + char_budget = provider_context_budget(provider) + if char_budget: + # Convert chars to tokens using standard estimate (chars/4) + return max(1, char_budget // 4) + + # 3. Fallback to global default + auto_target = provider_auto_budget_target(provider) + if auto_target and auto_target in BUDGET_PRESETS: + return BUDGET_PRESETS[auto_target] + + return AUTO_BUDGET_TOKENS From 1475f2eda842290d4a21e70c6dfaafa560ec0529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=95=E7=A6=B9=E5=A3=95?= <2496583567@qq.com> Date: Thu, 12 Nov 2026 23:05:10 +0800 Subject: [PATCH 4/4] fix(opencode): store capability_matrix.py with LF endings The previous commit was created through the Git Data API from the Windows working tree, so it uploaded CRLF and rewrote every line. Normalise to LF to match the repository; the content is unchanged. --- voyager/capability_matrix.py | 1296 +++++++++++++++++----------------- 1 file changed, 648 insertions(+), 648 deletions(-) diff --git a/voyager/capability_matrix.py b/voyager/capability_matrix.py index dc8d73c..05e9e81 100644 --- a/voyager/capability_matrix.py +++ b/voyager/capability_matrix.py @@ -1,648 +1,648 @@ -"""The canonical provider capability matrix. - -One source of truth for what sessionFlow can do with each provider. The README, -`voyager doctor` and `voyager integrate status` all read this, so the docs cannot -drift away from the code. - -Two things are kept separate, because conflating them is how a project ends up -claiming live verification it never did: - - DECLARED -- what the provider supports, as established by reading its - documented contract and by the code in this repo. A ceiling. - EVIDENCE -- what has actually happened on this machine: is the provider - installed, is a hook registered, has that hook been observed - firing, has a bare "continue" resumed a WorkThread. - -A cell resolves to the weaker of the two, and the six states are ordered: - - NOT_FOUND_IN_CURRENT_AUDIT < SUPPORTED < CONFIGURED < UNIT_VERIFIED - < LIVE_VERIFIED < ZERO_TOUCH_LIVE_VERIFIED - -``SUPPORTED`` means "the provider offers this and Voyager implements it"; it never -means "seen working". Only evidence can lift a cell above CONFIGURED, and no -cell is promoted without a recorded reason. - -Usage: ------- -1. Collect runtime evidence: ``from voyager.capability_matrix import summary; summary()`` -2. Generate full matrix: ``from voyager.capability_matrix import matrix; matrix()`` -3. Check individual provider: ``from voyager.capability_matrix import provider_state; provider_state('codex')`` - -This module is imported, not run: the command-line view is -``voyager verify [--matrix] [--json]`` (O6 removed the old -``python -m voyager.capability_matrix`` entry point, which duplicated it). - -All outputs feed into: -- ``voyager doctor --json``: Provider health status -- ``voyager integrate status``: Integration installation state -- README.md: Supported platforms table -- ROADMAP.md: Verification milestones -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -# --- the vocabulary --------------------------------------------------------- - -NOT_FOUND = "NOT_FOUND_IN_CURRENT_AUDIT" -SUPPORTED = "SUPPORTED" -CONFIGURED = "CONFIGURED" -UNIT_VERIFIED = "UNIT_VERIFIED" -LIVE_VERIFIED = "LIVE_VERIFIED" -ZERO_TOUCH_LIVE_VERIFIED = "ZERO_TOUCH_LIVE_VERIFIED" - -STATE_ORDER = { - NOT_FOUND: 0, - SUPPORTED: 1, - CONFIGURED: 2, - UNIT_VERIFIED: 3, - LIVE_VERIFIED: 4, - ZERO_TOUCH_LIVE_VERIFIED: 5, -} - -#: The dimensions every provider is measured against. -DIMENSIONS: Tuple[str, ...] = ( - "session_discovery", - "session_indexing", - "native_resume", - "startup_hook", - "dynamic_context_injection", - "native_session_id_at_start", - "native_auto_attach", - "live_zero_touch_continuity", - "transcript_import", - "tool_event_support", - "cwd_repo_discovery", - "context_budget", - "truncation_behavior", -) - -PROVIDERS: Tuple[str, ...] = ( - "codex", "claude", "grok", "zcode", "cursor", "kiro", "antigravity", "dsh", - "opencode", -) - -# --- what each provider supports (the ceiling) ----------------------------- -# -# Every entry is (state, note). The note is the evidence for the ceiling and is -# surfaced by `doctor`, so a reader can check the claim instead of trusting it. - -_ADAPTER = "adapter in voyager/adapters/" -_HANDLER = "startup handler in voyager/integrations/" - - -def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: - return {d: (state, note) for d in DIMENSIONS} - - -DECLARED: Dict[str, Dict[str, Tuple[str, str]]] = { - "codex": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; rollout JSONL"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "`codex resume` is documented"), - "startup_hook": (LIVE_VERIFIED, "hooks.json SessionStart, observed firing"), - "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), - "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), - "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), - "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, - "2026-09-28: bare 继续 resumed the WorkThread"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool events present in rollouts"), - "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd + rollout fallback"), - "context_budget": (LIVE_VERIFIED, "10,000 chars measured; window built at 7,600"), - "truncation_behavior": (LIVE_VERIFIED, "measured: middle elision above 10,000"), - }, - "claude": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; project JSONL"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "`claude --resume` is documented"), - "startup_hook": (LIVE_VERIFIED, "settings.json SessionStart, observed firing"), - "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), - "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), - "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), - "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, - "2026-09-24/25: cross-provider leg ran live"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool_use / tool_result records"), - "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd"), - "context_budget": (LIVE_VERIFIED, "10,000 chars; window built at 7,600"), - "truncation_behavior": (SUPPORTED, "documented cap; not triggered since the budget fix"), - }, - "grok": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; chat_history.jsonl"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "`grok -r` is documented"), - "startup_hook": (LIVE_VERIFIED, "rules-writer hook, observed firing"), - "dynamic_context_injection": (LIVE_VERIFIED, "rules file written before launch"), - "native_session_id_at_start": (LIVE_VERIFIED, "session id recorded on start"), - "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), - "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, - "2026-09-25: sentinel written in Claude was recovered"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (LIVE_VERIFIED, "cwd resolved at start"), - "context_budget": (SUPPORTED, "documented budget; not measured here"), - "truncation_behavior": (NOT_FOUND, "no measurement recorded"), - }, - "zcode": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; SQLite"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (SUPPORTED, "desktop resume is documented"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; registered; not yet observed firing"), - "dynamic_context_injection": (UNIT_VERIFIED, "envelope driven end to end with a real payload"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "file events stay in raw"), - "cwd_repo_discovery": (UNIT_VERIFIED, "payload cwd"), - "context_budget": (UNIT_VERIFIED, "shared cap; no documented provider limit"), - "truncation_behavior": (NOT_FOUND, "not measured; ZCode reports maxOutputBytes"), - }, - "cursor": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; state.vscdb"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "no resume surface found; IDE only"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks.json registered; not yet fired"), - "dynamic_context_injection": (UNIT_VERIFIED, "top-level additional_context verified"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id / conversation_id"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool data in raw"), - "cwd_repo_discovery": (UNIT_VERIFIED, "workspace_roots (no cwd on sessionStart)"), - "context_budget": (UNIT_VERIFIED, "shared cap; Cursor documents no limit"), - "truncation_behavior": (NOT_FOUND, "not measured"), - }, - "kiro": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; workspace-session JSON"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "sessions are not persisted across restarts"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks are project-scoped, so none installed globally"), - "dynamic_context_injection": (UNIT_VERIFIED, "plain-text stdout contract verified"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload session id, if present"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (UNIT_VERIFIED, "project root of the command hook"), - "context_budget": (UNIT_VERIFIED, "shared cap; Kiro documents no limit"), - "truncation_behavior": (NOT_FOUND, "not measured"), - }, - "antigravity": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; conversation SQLite"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "no resume surface found"), - "startup_hook": (UNIT_VERIFIED, _HANDLER + "; PreInvocation, registered globally"), - "dynamic_context_injection": (UNIT_VERIFIED, "injectSteps.ephemeralMessage verified"), - "native_session_id_at_start": (UNIT_VERIFIED, "payload conversationId"), - "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), - "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), - "transcript_import": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (UNIT_VERIFIED, "workspacePaths"), - "context_budget": (UNIT_VERIFIED, "shared cap; no documented limit"), - "truncation_behavior": (NOT_FOUND, "not measured"), - }, - "dsh": { - "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; zstd JSONL"), - "session_indexing": (LIVE_VERIFIED, _ADAPTER), - "native_resume": (NOT_FOUND, "`dsh --resume` needs `--profile`, which a session does not record"), - "startup_hook": (NOT_FOUND, "no native startup surface; profiles/plugins/ACP only"), - "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": (LIVE_VERIFIED, _ADAPTER), - "tool_event_support": (SUPPORTED, "tool records present"), - "cwd_repo_discovery": (LIVE_VERIFIED, "session records carry cwd"), - "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. -HOOK_CONFIG_PATHS: Dict[str, str] = { - "codex": "~/.codex/hooks.json", - "claude": "~/.claude/settings.json", - "zcode": "~/.zcode/cli/config.json", - "cursor": "~/.cursor/hooks.json", - "kiro": ".kiro/hooks/.json", # project-scoped, not global - "antigravity": "~/.gemini/config/hooks.json", -} - -#: Where each provider's own session data lives, for source-path checks. -SOURCE_PATHS: Dict[str, str] = { - "codex": "~/.codex/sessions", - "claude": "~/.claude/projects", - "grok": "~/.grok", - "zcode": "~/.zcode/cli/db", - "cursor": "~/.cursor", # config/data root on Windows - "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). -# Each provider declares its maximum safe payload size, transport limits, -# and truncation/spill behaviors. These override the global defaults. -PROVIDER_CONTEXT_BUDGETS: Dict[str, Dict[str, Any]] = { - "codex": { - "startup_context_budget": 7600, # chars before Codex additionalContext cap - "transport_limit": 8000, # max bytes per hookSpecificOutput - "truncation_behavior": "middle_elision", # measured: middle cut above 10k - "supports_spill_pointer": False, # no spill path in hooks.json - "native_session_id_at_start": True, - "auto_budget_target": "balanced", # map to preset - }, - "claude": { - "startup_context_budget": 7600, # chars before claude cap - "transport_limit": 10000, # additionalContext ceiling - "truncation_behavior": "header_elision", # drops header sections first - "supports_spill_pointer": True, # can use spilled path pointer - "native_session_id_at_start": True, - "auto_budget_target": "balanced", - }, - "grok": { - "startup_context_budget": 5000, # rules file size constraint - "transport_limit": 5500, - "truncation_behavior": "section_deduplication", - "supports_spill_pointer": False, - "native_session_id_at_start": True, - "auto_budget_target": "compact", - }, - "zcode": { - "startup_context_budget": 8000, # shared cap; ZCode reports maxOutputBytes - "transport_limit": 10000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "balanced", - }, - "cursor": { - "startup_context_budget": 8000, # Cursor documents no limit - "transport_limit": 12000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "full", - }, - "kiro": { - "startup_context_budget": 8000, # Kiro documents no limit - "transport_limit": 12000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "full", - }, - "antigravity": { - "startup_context_budget": 8000, # no documented limit - "transport_limit": 12000, - "truncation_behavior": "section_dropping", - "supports_spill_pointer": True, - "native_session_id_at_start": True, - "auto_budget_target": "full", - }, - "dsh": { - "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, - }, - "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, - }, -} - - -# --- runtime evidence ------------------------------------------------------ - -@dataclass -class Evidence: - """What has actually been observed on this machine.""" - - installed: bool = False - hook_registered: bool = False - hook_fired: bool = False - zero_touch_observed: bool = False - last_trigger: Optional[str] = None - notes: List[str] = field(default_factory=list) - - -def _log_dir() -> Path: - import os - override = os.environ.get("VOYAGER_LOG_DIR") - if override: - return Path(override).expanduser() - return Path.home() / ".voyager" / "logs" - - -def collect_evidence(provider: str) -> Evidence: - """Read the machine state for one provider. Best effort, never raises. - - Runtime evidence (hook fired, zero-touch observed) comes **only** from the - append-only ``verification_events`` table managed by - :mod:`voyager.verification_harness`. No hardcoded sets, no log-file - grepping, no "adapter exists = live" shortcuts -- if the evidence table has - no rows for a provider, that provider is ``UNIT_VERIFIED`` at best, which - is the honest answer ("code exists, not yet observed on this machine"). - - ``installed`` and ``hook_registered`` are still checked on the filesystem, - because those describe *configuration* state, not *runtime* state. - """ - ev = Evidence() - try: - src = SOURCE_PATHS.get(provider) - ev.installed = bool(src) and Path(src).expanduser().exists() - except Exception: - ev.installed = False - try: - cfg = HOOK_CONFIG_PATHS.get(provider) - ev.hook_registered = bool(cfg) and not cfg.startswith(".") and Path(cfg).expanduser().exists() - except Exception: - ev.hook_registered = False - - # Query the evidence table for runtime observations. This is the sole - # source of hook_fired / zero_touch_observed -- no hardcoded sets, no log - # grepping. If the table does not exist or has no events for this - # provider, the evidence stays at its defaults (all False), which means - # the ceiling is UNIT_VERIFIED. That is correct. - try: - from .verification_harness import _connect, observed_state, has_evidence_table - con = _connect() - if con is not None: - try: - if has_evidence_table(con): - obs = observed_state(con, provider) - state = obs.get("observed_state") - if state == ZERO_TOUCH_LIVE_VERIFIED: - ev.zero_touch_observed = True - ev.hook_fired = True - elif state == LIVE_VERIFIED: - ev.hook_fired = True - ev.last_trigger = obs.get("last_live_event") - finally: - try: - con.close() - except Exception: - pass - except Exception: - pass # verification_harness not available or DB inaccessible - - return ev - - -# --- resolution ------------------------------------------------------------ - -def _weaker(a: str, b: str) -> str: - return a if STATE_ORDER.get(a, 0) <= STATE_ORDER.get(b, 0) else b - - -def evidence_ceiling(ev: Evidence) -> str: - """The highest state the machine evidence can justify. - - The floor is UNIT_VERIFIED, not CONFIGURED: a handler that exists and is - covered by tests is *verified at the unit level* whether or not this machine - happens to have the provider installed. Evidence below that only ever lowers - a LIVE / ZERO_TOUCH claim, never a code-level one. - """ - if ev.zero_touch_observed: - return ZERO_TOUCH_LIVE_VERIFIED - if ev.hook_fired: - return LIVE_VERIFIED - return UNIT_VERIFIED - - -def adapter_can_resume(provider: str) -> Optional[bool]: - """What the adapter itself says, or None when there is no adapter. - - The adapter is the authority on native resume: it is the code that would - have to build the command. When it sets ``can_resume = False`` the - provider cannot be resumed no matter what a hand-written table claims, and - reporting SUPPORTED tells the user to run a command that does not exist. - """ - try: - from voyager.adapters import load_all - from voyager.adapters.base import get_adapter - except Exception: # noqa: BLE001 - a broken import must not break the matrix - return None - try: - # Importing the adapter modules is what registers them; without this - # the registry is empty and every provider looks like "no adapter". - load_all() - adapter = get_adapter(provider) - except Exception: # noqa: BLE001 - return None - if adapter is None: - return None - return bool(getattr(adapter, "can_resume", False)) - - -def resolve_cell(provider: str, dimension: str, ev: Optional[Evidence] = None) -> Tuple[str, str]: - """The actual state of one cell, with the reason it is not higher.""" - declared = DECLARED.get(provider, {}).get(dimension, (NOT_FOUND, "no entry")) - state, note = declared - ev = ev or collect_evidence(provider) - - # Native resume is a claim about code, not about this machine: if the - # adapter says it cannot resume, no declaration may outrank that. This is - # checked before the evidence ceiling because it is not an evidence - # question at all -- see the zcode entry, which declared SUPPORTED while - # its adapter sets can_resume=False. - if dimension == "native_resume": - can = adapter_can_resume(provider) - if can is False and STATE_ORDER.get(state, 0) > STATE_ORDER.get(NOT_FOUND, 0): - return (NOT_FOUND, - "the %s adapter sets can_resume=False; declared %s (%s)" - % (provider, state, note)) - - # Dimensions that describe data on disk do not depend on a hook firing. - machine_dependent = dimension in ( - "startup_hook", "dynamic_context_injection", "native_session_id_at_start", - "native_auto_attach", "live_zero_touch_continuity", - ) - if machine_dependent: - ceiling = evidence_ceiling(ev) - if STATE_ORDER.get(state, 0) > STATE_ORDER.get(ceiling, 0): - reason = "declared %s; on this machine: %s" % (state, _explain(ev, ceiling)) - return (ceiling, reason) - return (state, note) - - -def _explain(ev: Evidence, ceiling: str) -> str: - if ceiling == UNIT_VERIFIED: - return "hook registered, covered by tests, provider not yet observed firing" - if ceiling == LIVE_VERIFIED: - return "hook observed firing" - if ceiling == CONFIGURED: - return "provider installed, hook not registered" - if ceiling == ZERO_TOUCH_LIVE_VERIFIED: - return "a bare continue resumed the WorkThread with no Voyager command" - return "not observed on this machine" - - -def matrix(providers: Optional[List[str]] = None) -> Dict[str, Dict[str, Dict[str, str]]]: - """The resolved matrix: {provider: {dimension: {state, note}}}.""" - out: Dict[str, Dict[str, Dict[str, str]]] = {} - for p in (providers or PROVIDERS): - ev = collect_evidence(p) - out[p] = {} - for d in DIMENSIONS: - state, note = resolve_cell(p, d, ev) - out[p][d] = {"state": state, "note": note} - return out - - -def provider_state(provider: str, ev: Optional[Evidence] = None) -> str: - """One headline state for a provider. - - The headline is the continuity a user actually gets: ZERO_TOUCH_LIVE_VERIFIED - when a bare continue was observed resuming the thread, LIVE_VERIFIED when the - hook was seen firing, otherwise whatever the startup-hook dimension supports - -- **capped at the evidence ceiling**. A provider whose code declares - LIVE_VERIFIED but has no evidence in the table reports UNIT_VERIFIED, - because "the handler exists and is tested" is all the evidence supports. - - A provider with no startup surface at all reports NOT_FOUND -- it is not - silently folded into SUPPORTED. - """ - ev = ev or collect_evidence(provider) - declared = DECLARED.get(provider, {}).get("startup_hook", (NOT_FOUND, ""))[0] - if declared == NOT_FOUND: - return NOT_FOUND - if ev.zero_touch_observed: - return ZERO_TOUCH_LIVE_VERIFIED - if ev.hook_fired: - return LIVE_VERIFIED - # No live evidence: cap at the evidence ceiling (UNIT_VERIFIED when no - # evidence table or no events). The declared ceiling may be higher - # (LIVE_VERIFIED / ZERO_TOUCH_LIVE_VERIFIED), but without evidence the - # honest answer is the ceiling the evidence can justify. - ceiling = evidence_ceiling(ev) - if STATE_ORDER.get(declared, 0) > STATE_ORDER.get(ceiling, 0): - return ceiling - return declared - - -def summary() -> Dict[str, Any]: - """What `doctor` prints and `--json` emits.""" - providers = {} - for p in PROVIDERS: - ev = collect_evidence(p) - providers[p] = { - "state": provider_state(p, ev), - "installed": ev.installed, - "hook_registered": ev.hook_registered, - "hook_fired": ev.hook_fired, - "last_trigger": ev.last_trigger, - "context_budget": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("startup_context_budget"), - "auto_budget_target": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("auto_budget_target"), - } - return {"providers": providers, - "dimensions": list(DIMENSIONS), - "states": list(STATE_ORDER)} - - -# --- provider-aware budget helpers ----------------------------------------- - -def provider_context_budget(provider: str) -> Optional[int]: - """Get the declared startup context budget (in chars) for a provider. - - Returns None if provider has no injection path (e.g., DSH). - """ - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("startup_context_budget") - - -def provider_transport_limit(provider: str) -> Optional[int]: - """Get the transport limit (max bytes per hook payload) for a provider.""" - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("transport_limit") - - -def provider_truncation_behavior(provider: str) -> Optional[str]: - """Get the truncation behavior strategy for a provider. - - Strategies: "middle_elision", "header_elision", "section_deduplication", "section_dropping" - """ - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("truncation_behavior") - - -def provider_supports_spill_pointer(provider: str) -> bool: - """Does this provider support spill pointer mechanism?""" - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("supports_spill_pointer", False) - - -def provider_auto_budget_target(provider: str) -> Optional[str]: - """Get the auto-budget target preset name for a provider. - - Maps to: "compact" | "balanced" | "full" | None - """ - return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("auto_budget_target") - - -def resolve_provider_budget(provider: str, user_specified: Optional[str] = None) -> int: - """Resolve the actual token budget for a given provider. - - Priority order: - 1. User-specified budget overrides everything - 2. Provider-specific startup_context_budget converted to tokens - 3. Global default (AUTO_BUDGET_TOKENS from budget.py) - - Returns token count (chars/4 estimate). - """ - from .budget import BUDGET_PRESETS, AUTO_BUDGET_TOKENS - - # 1. If user explicitly specified, use that - if user_specified and user_specified.lower() != "auto": - from .budget import parse_budget - parsed = parse_budget(user_specified) - if parsed: - return parsed - - # 2. Try provider-specific budget - char_budget = provider_context_budget(provider) - if char_budget: - # Convert chars to tokens using standard estimate (chars/4) - return max(1, char_budget // 4) - - # 3. Fallback to global default - auto_target = provider_auto_budget_target(provider) - if auto_target and auto_target in BUDGET_PRESETS: - return BUDGET_PRESETS[auto_target] - - return AUTO_BUDGET_TOKENS +"""The canonical provider capability matrix. + +One source of truth for what sessionFlow can do with each provider. The README, +`voyager doctor` and `voyager integrate status` all read this, so the docs cannot +drift away from the code. + +Two things are kept separate, because conflating them is how a project ends up +claiming live verification it never did: + + DECLARED -- what the provider supports, as established by reading its + documented contract and by the code in this repo. A ceiling. + EVIDENCE -- what has actually happened on this machine: is the provider + installed, is a hook registered, has that hook been observed + firing, has a bare "continue" resumed a WorkThread. + +A cell resolves to the weaker of the two, and the six states are ordered: + + NOT_FOUND_IN_CURRENT_AUDIT < SUPPORTED < CONFIGURED < UNIT_VERIFIED + < LIVE_VERIFIED < ZERO_TOUCH_LIVE_VERIFIED + +``SUPPORTED`` means "the provider offers this and Voyager implements it"; it never +means "seen working". Only evidence can lift a cell above CONFIGURED, and no +cell is promoted without a recorded reason. + +Usage: +------ +1. Collect runtime evidence: ``from voyager.capability_matrix import summary; summary()`` +2. Generate full matrix: ``from voyager.capability_matrix import matrix; matrix()`` +3. Check individual provider: ``from voyager.capability_matrix import provider_state; provider_state('codex')`` + +This module is imported, not run: the command-line view is +``voyager verify [--matrix] [--json]`` (O6 removed the old +``python -m voyager.capability_matrix`` entry point, which duplicated it). + +All outputs feed into: +- ``voyager doctor --json``: Provider health status +- ``voyager integrate status``: Integration installation state +- README.md: Supported platforms table +- ROADMAP.md: Verification milestones +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# --- the vocabulary --------------------------------------------------------- + +NOT_FOUND = "NOT_FOUND_IN_CURRENT_AUDIT" +SUPPORTED = "SUPPORTED" +CONFIGURED = "CONFIGURED" +UNIT_VERIFIED = "UNIT_VERIFIED" +LIVE_VERIFIED = "LIVE_VERIFIED" +ZERO_TOUCH_LIVE_VERIFIED = "ZERO_TOUCH_LIVE_VERIFIED" + +STATE_ORDER = { + NOT_FOUND: 0, + SUPPORTED: 1, + CONFIGURED: 2, + UNIT_VERIFIED: 3, + LIVE_VERIFIED: 4, + ZERO_TOUCH_LIVE_VERIFIED: 5, +} + +#: The dimensions every provider is measured against. +DIMENSIONS: Tuple[str, ...] = ( + "session_discovery", + "session_indexing", + "native_resume", + "startup_hook", + "dynamic_context_injection", + "native_session_id_at_start", + "native_auto_attach", + "live_zero_touch_continuity", + "transcript_import", + "tool_event_support", + "cwd_repo_discovery", + "context_budget", + "truncation_behavior", +) + +PROVIDERS: Tuple[str, ...] = ( + "codex", "claude", "grok", "zcode", "cursor", "kiro", "antigravity", "dsh", + "opencode", +) + +# --- what each provider supports (the ceiling) ----------------------------- +# +# Every entry is (state, note). The note is the evidence for the ceiling and is +# surfaced by `doctor`, so a reader can check the claim instead of trusting it. + +_ADAPTER = "adapter in voyager/adapters/" +_HANDLER = "startup handler in voyager/integrations/" + + +def _all(state: str, note: str) -> Dict[str, Tuple[str, str]]: + return {d: (state, note) for d in DIMENSIONS} + + +DECLARED: Dict[str, Dict[str, Tuple[str, str]]] = { + "codex": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; rollout JSONL"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "`codex resume` is documented"), + "startup_hook": (LIVE_VERIFIED, "hooks.json SessionStart, observed firing"), + "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), + "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), + "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), + "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, + "2026-09-28: bare 继续 resumed the WorkThread"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool events present in rollouts"), + "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd + rollout fallback"), + "context_budget": (LIVE_VERIFIED, "10,000 chars measured; window built at 7,600"), + "truncation_behavior": (LIVE_VERIFIED, "measured: middle elision above 10,000"), + }, + "claude": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; project JSONL"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "`claude --resume` is documented"), + "startup_hook": (LIVE_VERIFIED, "settings.json SessionStart, observed firing"), + "dynamic_context_injection": (LIVE_VERIFIED, "hookSpecificOutput.additionalContext"), + "native_session_id_at_start": (LIVE_VERIFIED, "payload session_id"), + "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), + "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, + "2026-09-24/25: cross-provider leg ran live"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool_use / tool_result records"), + "cwd_repo_discovery": (LIVE_VERIFIED, "payload cwd"), + "context_budget": (LIVE_VERIFIED, "10,000 chars; window built at 7,600"), + "truncation_behavior": (SUPPORTED, "documented cap; not triggered since the budget fix"), + }, + "grok": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; chat_history.jsonl"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "`grok -r` is documented"), + "startup_hook": (LIVE_VERIFIED, "rules-writer hook, observed firing"), + "dynamic_context_injection": (LIVE_VERIFIED, "rules file written before launch"), + "native_session_id_at_start": (LIVE_VERIFIED, "session id recorded on start"), + "native_auto_attach": (LIVE_VERIFIED, "pending-attach resolved on start"), + "live_zero_touch_continuity": (ZERO_TOUCH_LIVE_VERIFIED, + "2026-09-25: sentinel written in Claude was recovered"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (LIVE_VERIFIED, "cwd resolved at start"), + "context_budget": (SUPPORTED, "documented budget; not measured here"), + "truncation_behavior": (NOT_FOUND, "no measurement recorded"), + }, + "zcode": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; SQLite"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (SUPPORTED, "desktop resume is documented"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; registered; not yet observed firing"), + "dynamic_context_injection": (UNIT_VERIFIED, "envelope driven end to end with a real payload"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "file events stay in raw"), + "cwd_repo_discovery": (UNIT_VERIFIED, "payload cwd"), + "context_budget": (UNIT_VERIFIED, "shared cap; no documented provider limit"), + "truncation_behavior": (NOT_FOUND, "not measured; ZCode reports maxOutputBytes"), + }, + "cursor": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; state.vscdb"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "no resume surface found; IDE only"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks.json registered; not yet fired"), + "dynamic_context_injection": (UNIT_VERIFIED, "top-level additional_context verified"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload session_id / conversation_id"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool data in raw"), + "cwd_repo_discovery": (UNIT_VERIFIED, "workspace_roots (no cwd on sessionStart)"), + "context_budget": (UNIT_VERIFIED, "shared cap; Cursor documents no limit"), + "truncation_behavior": (NOT_FOUND, "not measured"), + }, + "kiro": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; workspace-session JSON"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "sessions are not persisted across restarts"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; hooks are project-scoped, so none installed globally"), + "dynamic_context_injection": (UNIT_VERIFIED, "plain-text stdout contract verified"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload session id, if present"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (UNIT_VERIFIED, "project root of the command hook"), + "context_budget": (UNIT_VERIFIED, "shared cap; Kiro documents no limit"), + "truncation_behavior": (NOT_FOUND, "not measured"), + }, + "antigravity": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; conversation SQLite"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "no resume surface found"), + "startup_hook": (UNIT_VERIFIED, _HANDLER + "; PreInvocation, registered globally"), + "dynamic_context_injection": (UNIT_VERIFIED, "injectSteps.ephemeralMessage verified"), + "native_session_id_at_start": (UNIT_VERIFIED, "payload conversationId"), + "native_auto_attach": (UNIT_VERIFIED, "shared core path exercised in tests"), + "live_zero_touch_continuity": (NOT_FOUND, "LIVE_VERIFICATION_BLOCKED_BY_PROVIDER_UI"), + "transcript_import": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (UNIT_VERIFIED, "workspacePaths"), + "context_budget": (UNIT_VERIFIED, "shared cap; no documented limit"), + "truncation_behavior": (NOT_FOUND, "not measured"), + }, + "dsh": { + "session_discovery": (LIVE_VERIFIED, _ADAPTER + "; zstd JSONL"), + "session_indexing": (LIVE_VERIFIED, _ADAPTER), + "native_resume": (NOT_FOUND, "`dsh --resume` needs `--profile`, which a session does not record"), + "startup_hook": (NOT_FOUND, "no native startup surface; profiles/plugins/ACP only"), + "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": (LIVE_VERIFIED, _ADAPTER), + "tool_event_support": (SUPPORTED, "tool records present"), + "cwd_repo_discovery": (LIVE_VERIFIED, "session records carry cwd"), + "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. +HOOK_CONFIG_PATHS: Dict[str, str] = { + "codex": "~/.codex/hooks.json", + "claude": "~/.claude/settings.json", + "zcode": "~/.zcode/cli/config.json", + "cursor": "~/.cursor/hooks.json", + "kiro": ".kiro/hooks/.json", # project-scoped, not global + "antigravity": "~/.gemini/config/hooks.json", +} + +#: Where each provider's own session data lives, for source-path checks. +SOURCE_PATHS: Dict[str, str] = { + "codex": "~/.codex/sessions", + "claude": "~/.claude/projects", + "grok": "~/.grok", + "zcode": "~/.zcode/cli/db", + "cursor": "~/.cursor", # config/data root on Windows + "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). +# Each provider declares its maximum safe payload size, transport limits, +# and truncation/spill behaviors. These override the global defaults. +PROVIDER_CONTEXT_BUDGETS: Dict[str, Dict[str, Any]] = { + "codex": { + "startup_context_budget": 7600, # chars before Codex additionalContext cap + "transport_limit": 8000, # max bytes per hookSpecificOutput + "truncation_behavior": "middle_elision", # measured: middle cut above 10k + "supports_spill_pointer": False, # no spill path in hooks.json + "native_session_id_at_start": True, + "auto_budget_target": "balanced", # map to preset + }, + "claude": { + "startup_context_budget": 7600, # chars before claude cap + "transport_limit": 10000, # additionalContext ceiling + "truncation_behavior": "header_elision", # drops header sections first + "supports_spill_pointer": True, # can use spilled path pointer + "native_session_id_at_start": True, + "auto_budget_target": "balanced", + }, + "grok": { + "startup_context_budget": 5000, # rules file size constraint + "transport_limit": 5500, + "truncation_behavior": "section_deduplication", + "supports_spill_pointer": False, + "native_session_id_at_start": True, + "auto_budget_target": "compact", + }, + "zcode": { + "startup_context_budget": 8000, # shared cap; ZCode reports maxOutputBytes + "transport_limit": 10000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "balanced", + }, + "cursor": { + "startup_context_budget": 8000, # Cursor documents no limit + "transport_limit": 12000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "full", + }, + "kiro": { + "startup_context_budget": 8000, # Kiro documents no limit + "transport_limit": 12000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "full", + }, + "antigravity": { + "startup_context_budget": 8000, # no documented limit + "transport_limit": 12000, + "truncation_behavior": "section_dropping", + "supports_spill_pointer": True, + "native_session_id_at_start": True, + "auto_budget_target": "full", + }, + "dsh": { + "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, + }, + "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, + }, +} + + +# --- runtime evidence ------------------------------------------------------ + +@dataclass +class Evidence: + """What has actually been observed on this machine.""" + + installed: bool = False + hook_registered: bool = False + hook_fired: bool = False + zero_touch_observed: bool = False + last_trigger: Optional[str] = None + notes: List[str] = field(default_factory=list) + + +def _log_dir() -> Path: + import os + override = os.environ.get("VOYAGER_LOG_DIR") + if override: + return Path(override).expanduser() + return Path.home() / ".voyager" / "logs" + + +def collect_evidence(provider: str) -> Evidence: + """Read the machine state for one provider. Best effort, never raises. + + Runtime evidence (hook fired, zero-touch observed) comes **only** from the + append-only ``verification_events`` table managed by + :mod:`voyager.verification_harness`. No hardcoded sets, no log-file + grepping, no "adapter exists = live" shortcuts -- if the evidence table has + no rows for a provider, that provider is ``UNIT_VERIFIED`` at best, which + is the honest answer ("code exists, not yet observed on this machine"). + + ``installed`` and ``hook_registered`` are still checked on the filesystem, + because those describe *configuration* state, not *runtime* state. + """ + ev = Evidence() + try: + src = SOURCE_PATHS.get(provider) + ev.installed = bool(src) and Path(src).expanduser().exists() + except Exception: + ev.installed = False + try: + cfg = HOOK_CONFIG_PATHS.get(provider) + ev.hook_registered = bool(cfg) and not cfg.startswith(".") and Path(cfg).expanduser().exists() + except Exception: + ev.hook_registered = False + + # Query the evidence table for runtime observations. This is the sole + # source of hook_fired / zero_touch_observed -- no hardcoded sets, no log + # grepping. If the table does not exist or has no events for this + # provider, the evidence stays at its defaults (all False), which means + # the ceiling is UNIT_VERIFIED. That is correct. + try: + from .verification_harness import _connect, observed_state, has_evidence_table + con = _connect() + if con is not None: + try: + if has_evidence_table(con): + obs = observed_state(con, provider) + state = obs.get("observed_state") + if state == ZERO_TOUCH_LIVE_VERIFIED: + ev.zero_touch_observed = True + ev.hook_fired = True + elif state == LIVE_VERIFIED: + ev.hook_fired = True + ev.last_trigger = obs.get("last_live_event") + finally: + try: + con.close() + except Exception: + pass + except Exception: + pass # verification_harness not available or DB inaccessible + + return ev + + +# --- resolution ------------------------------------------------------------ + +def _weaker(a: str, b: str) -> str: + return a if STATE_ORDER.get(a, 0) <= STATE_ORDER.get(b, 0) else b + + +def evidence_ceiling(ev: Evidence) -> str: + """The highest state the machine evidence can justify. + + The floor is UNIT_VERIFIED, not CONFIGURED: a handler that exists and is + covered by tests is *verified at the unit level* whether or not this machine + happens to have the provider installed. Evidence below that only ever lowers + a LIVE / ZERO_TOUCH claim, never a code-level one. + """ + if ev.zero_touch_observed: + return ZERO_TOUCH_LIVE_VERIFIED + if ev.hook_fired: + return LIVE_VERIFIED + return UNIT_VERIFIED + + +def adapter_can_resume(provider: str) -> Optional[bool]: + """What the adapter itself says, or None when there is no adapter. + + The adapter is the authority on native resume: it is the code that would + have to build the command. When it sets ``can_resume = False`` the + provider cannot be resumed no matter what a hand-written table claims, and + reporting SUPPORTED tells the user to run a command that does not exist. + """ + try: + from voyager.adapters import load_all + from voyager.adapters.base import get_adapter + except Exception: # noqa: BLE001 - a broken import must not break the matrix + return None + try: + # Importing the adapter modules is what registers them; without this + # the registry is empty and every provider looks like "no adapter". + load_all() + adapter = get_adapter(provider) + except Exception: # noqa: BLE001 + return None + if adapter is None: + return None + return bool(getattr(adapter, "can_resume", False)) + + +def resolve_cell(provider: str, dimension: str, ev: Optional[Evidence] = None) -> Tuple[str, str]: + """The actual state of one cell, with the reason it is not higher.""" + declared = DECLARED.get(provider, {}).get(dimension, (NOT_FOUND, "no entry")) + state, note = declared + ev = ev or collect_evidence(provider) + + # Native resume is a claim about code, not about this machine: if the + # adapter says it cannot resume, no declaration may outrank that. This is + # checked before the evidence ceiling because it is not an evidence + # question at all -- see the zcode entry, which declared SUPPORTED while + # its adapter sets can_resume=False. + if dimension == "native_resume": + can = adapter_can_resume(provider) + if can is False and STATE_ORDER.get(state, 0) > STATE_ORDER.get(NOT_FOUND, 0): + return (NOT_FOUND, + "the %s adapter sets can_resume=False; declared %s (%s)" + % (provider, state, note)) + + # Dimensions that describe data on disk do not depend on a hook firing. + machine_dependent = dimension in ( + "startup_hook", "dynamic_context_injection", "native_session_id_at_start", + "native_auto_attach", "live_zero_touch_continuity", + ) + if machine_dependent: + ceiling = evidence_ceiling(ev) + if STATE_ORDER.get(state, 0) > STATE_ORDER.get(ceiling, 0): + reason = "declared %s; on this machine: %s" % (state, _explain(ev, ceiling)) + return (ceiling, reason) + return (state, note) + + +def _explain(ev: Evidence, ceiling: str) -> str: + if ceiling == UNIT_VERIFIED: + return "hook registered, covered by tests, provider not yet observed firing" + if ceiling == LIVE_VERIFIED: + return "hook observed firing" + if ceiling == CONFIGURED: + return "provider installed, hook not registered" + if ceiling == ZERO_TOUCH_LIVE_VERIFIED: + return "a bare continue resumed the WorkThread with no Voyager command" + return "not observed on this machine" + + +def matrix(providers: Optional[List[str]] = None) -> Dict[str, Dict[str, Dict[str, str]]]: + """The resolved matrix: {provider: {dimension: {state, note}}}.""" + out: Dict[str, Dict[str, Dict[str, str]]] = {} + for p in (providers or PROVIDERS): + ev = collect_evidence(p) + out[p] = {} + for d in DIMENSIONS: + state, note = resolve_cell(p, d, ev) + out[p][d] = {"state": state, "note": note} + return out + + +def provider_state(provider: str, ev: Optional[Evidence] = None) -> str: + """One headline state for a provider. + + The headline is the continuity a user actually gets: ZERO_TOUCH_LIVE_VERIFIED + when a bare continue was observed resuming the thread, LIVE_VERIFIED when the + hook was seen firing, otherwise whatever the startup-hook dimension supports + -- **capped at the evidence ceiling**. A provider whose code declares + LIVE_VERIFIED but has no evidence in the table reports UNIT_VERIFIED, + because "the handler exists and is tested" is all the evidence supports. + + A provider with no startup surface at all reports NOT_FOUND -- it is not + silently folded into SUPPORTED. + """ + ev = ev or collect_evidence(provider) + declared = DECLARED.get(provider, {}).get("startup_hook", (NOT_FOUND, ""))[0] + if declared == NOT_FOUND: + return NOT_FOUND + if ev.zero_touch_observed: + return ZERO_TOUCH_LIVE_VERIFIED + if ev.hook_fired: + return LIVE_VERIFIED + # No live evidence: cap at the evidence ceiling (UNIT_VERIFIED when no + # evidence table or no events). The declared ceiling may be higher + # (LIVE_VERIFIED / ZERO_TOUCH_LIVE_VERIFIED), but without evidence the + # honest answer is the ceiling the evidence can justify. + ceiling = evidence_ceiling(ev) + if STATE_ORDER.get(declared, 0) > STATE_ORDER.get(ceiling, 0): + return ceiling + return declared + + +def summary() -> Dict[str, Any]: + """What `doctor` prints and `--json` emits.""" + providers = {} + for p in PROVIDERS: + ev = collect_evidence(p) + providers[p] = { + "state": provider_state(p, ev), + "installed": ev.installed, + "hook_registered": ev.hook_registered, + "hook_fired": ev.hook_fired, + "last_trigger": ev.last_trigger, + "context_budget": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("startup_context_budget"), + "auto_budget_target": PROVIDER_CONTEXT_BUDGETS.get(p, {}).get("auto_budget_target"), + } + return {"providers": providers, + "dimensions": list(DIMENSIONS), + "states": list(STATE_ORDER)} + + +# --- provider-aware budget helpers ----------------------------------------- + +def provider_context_budget(provider: str) -> Optional[int]: + """Get the declared startup context budget (in chars) for a provider. + + Returns None if provider has no injection path (e.g., DSH). + """ + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("startup_context_budget") + + +def provider_transport_limit(provider: str) -> Optional[int]: + """Get the transport limit (max bytes per hook payload) for a provider.""" + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("transport_limit") + + +def provider_truncation_behavior(provider: str) -> Optional[str]: + """Get the truncation behavior strategy for a provider. + + Strategies: "middle_elision", "header_elision", "section_deduplication", "section_dropping" + """ + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("truncation_behavior") + + +def provider_supports_spill_pointer(provider: str) -> bool: + """Does this provider support spill pointer mechanism?""" + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("supports_spill_pointer", False) + + +def provider_auto_budget_target(provider: str) -> Optional[str]: + """Get the auto-budget target preset name for a provider. + + Maps to: "compact" | "balanced" | "full" | None + """ + return PROVIDER_CONTEXT_BUDGETS.get(provider, {}).get("auto_budget_target") + + +def resolve_provider_budget(provider: str, user_specified: Optional[str] = None) -> int: + """Resolve the actual token budget for a given provider. + + Priority order: + 1. User-specified budget overrides everything + 2. Provider-specific startup_context_budget converted to tokens + 3. Global default (AUTO_BUDGET_TOKENS from budget.py) + + Returns token count (chars/4 estimate). + """ + from .budget import BUDGET_PRESETS, AUTO_BUDGET_TOKENS + + # 1. If user explicitly specified, use that + if user_specified and user_specified.lower() != "auto": + from .budget import parse_budget + parsed = parse_budget(user_specified) + if parsed: + return parsed + + # 2. Try provider-specific budget + char_budget = provider_context_budget(provider) + if char_budget: + # Convert chars to tokens using standard estimate (chars/4) + return max(1, char_budget // 4) + + # 3. Fallback to global default + auto_target = provider_auto_budget_target(provider) + if auto_target and auto_target in BUDGET_PRESETS: + return BUDGET_PRESETS[auto_target] + + return AUTO_BUDGET_TOKENS