From 0c1d4427267c129bcb11c477e0639151fd9d8fa0 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Wed, 26 Aug 2026 00:34:55 +0300 Subject: [PATCH] Add four native readers, covering five more harnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opencode (and Z.ai's Z Code, whose CLI is an Opencode fork sharing its schema exactly as OpenClaude shares Claude Code's), Goose, Copilot CLI, and Qwen. `ingest` now reads nine harnesses with no manual export step. A store-backed reader has a different shape from a file-per-session one, and three things follow. `read` takes the session id as well as the path, because every session shares one file. The store is opened SQLITE_OPEN_READ_ONLY — it belongs to a program that may be running right now, and a read-only handle makes "engram never writes to another tool's database" structural rather than a promise. And `--max-bytes` has to measure the session rather than the file. For a harness that writes one file per session those are the same thing, which is why the check reads as a file-size check in the existing readers. Opencode's store is 550 MB of every session at once, so measuring the file refused all 34 sessions of a project with "transcript is 550273024 bytes" — a transcript nobody had asked for. The guard exists to stop one runaway conversation exhausting memory, so the store readers accumulate text and check against that. SessionRef.bytes is likewise the session's own size now, summed from its rows, rather than the store's size repeated for every session in it. Per-reader, the things that would otherwise be rediscovered: Copilot CLI's row is two turns. The prompt and the reply share one row and one timestamp — the harness records no separate time for the reply — so the halves need distinct source_uuid suffixes, or the second collides with the first through turn_id and is dropped by INSERT OR IGNORE. Goose's message_id is nullable, so identity falls back to the row's position, the same reasoning codex uses for records with no id of their own. Qwen reuses the Claude Code reader. Its records carry Claude Code's envelope but put the body at message.parts[], whose entries have no `type` field at all. collect_text now treats an untyped block carrying text as text; without that it counted an entire conversation as non_message while reporting a perfectly healthy filter histogram. The readerless-harness test lists only harnesses that genuinely still have none. It must never be padded with one engram can now read, or it would assert a refusal that should no longer happen. Verified on real stores: opencode 34 sessions/547 turns for one project, zcode 20 turns, goose 12 sessions, copilot-cli 16 turns, qwen 3 turns. Gates: fmt, clippy -D warnings, 283 tests, REUSE 3.3, makeinfo clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016i16R4GhdSffsboRYq97Fs --- AGENTS.md | 15 +- CHANGELOG.md | 4 + src/harness.rs | 41 ++-- src/transcript/claude_code.rs | 14 +- src/transcript/copilot.rs | 225 ++++++++++++++++++++ src/transcript/goose.rs | 268 ++++++++++++++++++++++++ src/transcript/mod.rs | 24 +++ src/transcript/opencode.rs | 378 ++++++++++++++++++++++++++++++++++ src/transcript/qwen.rs | 70 +++++++ tests/cli.rs | 6 +- 10 files changed, 1016 insertions(+), 29 deletions(-) create mode 100644 src/transcript/copilot.rs create mode 100644 src/transcript/goose.rs create mode 100644 src/transcript/opencode.rs create mode 100644 src/transcript/qwen.rs diff --git a/AGENTS.md b/AGENTS.md index 102edca..ab2062c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -251,7 +251,20 @@ Every tool's schema costs context on every turn of every conversation, which is `src/harness.rs` + `src/transcript/{mod,claude_code,redact}.rs`. Reads the session file a harness already writes for itself and stores each message as an ordinary memory, so `recall`/`search`/`context`/`consolidate` see the real conversation. -Two readers exist: `claude_code` and `codex`. Adding a third means adding a `ReaderKind` variant, which the two `match`es in `transcript/mod.rs` then force you to handle. +Six readers exist — `claude_code`, `codex`, `opencode`, `goose`, `copilot`, `qwen` — serving **nine** harnesses. Adding another means adding a `ReaderKind` variant, which the two `match`es in `transcript/mod.rs` then force you to handle. + +**A store-backed reader has a different shape from a file-per-session one**, and three things follow from it: + +- **`read` takes the session id as well as the path**, because every session shares one file. The dispatcher in `read_session` already holds the `SessionRef`, so it just passes both. +- **The store is opened `SQLITE_OPEN_READ_ONLY`.** It belongs to a program that may be running right now; a read-only handle makes "engram never writes to another tool's database" structural rather than a promise. +- **`--max-bytes` measures the session, not the file.** For a file-per-session harness the two are identical, which is why the check reads as a file-size check in `claude_code` and `codex`. Opencode's store is 550 MB of *all* sessions, so measuring the file refused every session in it with `transcript is 550273024 bytes` — a transcript nobody asked for. The guard's real job is to stop one runaway conversation exhausting memory, so the store readers accumulate text and check against that. `SessionRef.bytes` is likewise the session's own size, summed from its rows. + +Reader-specific facts worth keeping: + +- **One reader serves Opencode and ZCode**, whose CLI is an Opencode fork with the same `session`/`message`/`part` schema — the `claude_code`/OpenClaude relationship again. Reconstructing a turn is a two-table join: `message` has the role and time, the text lives in `part` rows ordered by their own creation time. Parts are overwhelmingly *not* conversation (a real store: `tool` 9854, `step-start` 6439, `step-finish` 6335, `reasoning` 3945, `text` 3322, `patch` 461), so counting them rather than dropping them silently is the whole point. +- **Copilot CLI's row is two turns.** The prompt and the reply share one row *and one timestamp* — the harness records no separate time for the reply. The halves need distinct `source_uuid` suffixes (`0:user`, `0:assistant`) or the second would collide with the first through `turn_id` and be dropped by `INSERT OR IGNORE`. +- **Goose's `message_id` is nullable**, so identity falls back to the row's position — the same reasoning `codex` uses for records carrying no id of their own. +- **Qwen shares the Claude Code reader.** Its records use Claude Code's envelope but put the body at `message.parts[]`, whose entries carry *no* `type` field. `collect_text` therefore treats an untyped block carrying text as text; without that it counted the entire conversation as `non_message` while reporting a healthy-looking histogram. Only session discovery differs (`projects//chats/`), which is why `qwen.rs` exists at all. - **Codex layout:** `~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl`. The tree encodes the **date, not the cwd**, so there is nothing to mangle — each rollout's first record is a `session_meta` carrying `cwd` verbatim, and listing reads exactly that one line per file. - **Codex has two channels, and `event_msg` wins.** `event_msg` is what the UI displayed (flat strings); `response_item` is the raw API traffic. `event_msg` is primary not merely because it parses more easily but because it is *less* noisy: on a real rollout it held 2 user messages where `response_item` held 3, and the extra one was an `` block the harness injects. `response_item` is a fallback used only when a rollout has no `event_msg` conversation at all, so retiring the display channel would degrade rather than silently yield nothing. When the display channel wins, the raw duplicates are counted as `non_message`. diff --git a/CHANGELOG.md b/CHANGELOG.md index c501388..eb34d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions follow ### Added +- **Four native readers, covering five more harnesses**: Opencode (and ZCode, + whose CLI is an Opencode fork sharing its schema), Goose, Copilot CLI, and + Qwen. `engram ingest` now reads nine harnesses without any manual export. + - **Ten more harnesses in the table** — grok, zcode, deepcode, poe-code, kilo, mimocode, warp, cline, aichat, bailian — recorded, not built for. An absent entry is indistinguishable from an unexamined one. diff --git a/src/harness.rs b/src/harness.rs index 4e6fc4a..9c903e5 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -70,6 +70,11 @@ pub enum Harness { pub enum ReaderKind { ClaudeCode, Codex, + /// Serves Opencode and Z.ai's Z Code, which share one schema. + Opencode, + Goose, + CopilotCli, + Qwen, } /// Whether engram can read a harness's session transcripts, and if not, why. @@ -259,10 +264,8 @@ pub const ALL: &[HarnessSpec] = &[ id: Harness::Opencode, name: "opencode", probe: &[".config/opencode/opencode.jsonc", ".config/opencode"], - sessions_dir: None, - transcript: TranscriptSupport::NotImplemented { - detail: "opencode's session storage has not been surveyed", - }, + sessions_dir: Some(".local/share/opencode/opencode.db"), + transcript: TranscriptSupport::Reader(ReaderKind::Opencode), command_surface: CommandSurface::Markdown { dir: ".config/opencode/command", file: "engram-{name}.md", @@ -365,12 +368,8 @@ pub const ALL: &[HarnessSpec] = &[ id: Harness::Goose, name: "goose", probe: &[".config/goose/config.yaml", ".config/goose"], - sessions_dir: None, - transcript: TranscriptSupport::NotImplemented { - detail: "goose stores sessions in .local/share/goose/sessions/sessions.db, \ - whose messages table carries role, content_json and created_timestamp \ - as first-class columns; a reader is not written yet", - }, + sessions_dir: Some(".local/share/goose/sessions/sessions.db"), + transcript: TranscriptSupport::Reader(ReaderKind::Goose), command_surface: CommandSurface::None { detail: "goose has no user command directory engram has surveyed", }, @@ -383,10 +382,8 @@ pub const ALL: &[HarnessSpec] = &[ id: Harness::CopilotCli, name: "copilot-cli", probe: &[".copilot/mcp-config.json", ".copilot"], - sessions_dir: None, - transcript: TranscriptSupport::NotImplemented { - detail: "copilot cli stores sessions in session-store.db, whose turns table is already a flat pre-paired transcript — turns(session_id, turn_index, user_message, assistant_response, timestamp) with UNIQUE(session_id, turn_index), joined to sessions(cwd, repository, branch); a reader is not written yet", - }, + sessions_dir: Some(".copilot/session-store.db"), + transcript: TranscriptSupport::Reader(ReaderKind::CopilotCli), command_surface: CommandSurface::None { detail: "copilot cli takes plugins, not loose command files, and installs \ them from a marketplace, a GitHub repository, or a git URL --- \ @@ -401,10 +398,8 @@ pub const ALL: &[HarnessSpec] = &[ id: Harness::Qwen, name: "qwen", probe: &[".qwen/settings.json", ".qwen"], - sessions_dir: None, - transcript: TranscriptSupport::NotImplemented { - detail: "qwen's session storage has not been surveyed", - }, + sessions_dir: Some(".qwen/projects"), + transcript: TranscriptSupport::Reader(ReaderKind::Qwen), // Verified against Qwen Code's own bundled documentation // (`docs/features/skills.md`): personal skills live in // `~/.qwen/skills//SKILL.md`. @@ -450,14 +445,8 @@ pub const ALL: &[HarnessSpec] = &[ id: Harness::ZCode, name: "zcode", probe: &[".zcode/cli", ".zcode"], - sessions_dir: Some(".zcode/cli/db"), - transcript: TranscriptSupport::NotImplemented { - detail: "z.ai's Z Code ships an Electron app and a CLI whose store is an opencode \ - fork — .zcode/cli/db/db.sqlite with the same session/message/part schema, \ - so one reader would serve both. The sibling rollout/model-io-*.jsonl is \ - an HTTP log that resends the whole conversation per line, not a \ - transcript; a reader is not written yet", - }, + sessions_dir: Some(".zcode/cli/db/db.sqlite"), + transcript: TranscriptSupport::Reader(ReaderKind::Opencode), command_surface: CommandSurface::None { detail: "zcode's skills directory has not been surveyed for writability", }, diff --git a/src/transcript/claude_code.rs b/src/transcript/claude_code.rs index e14daac..b3c4983 100644 --- a/src/transcript/claude_code.rs +++ b/src/transcript/claude_code.rs @@ -205,7 +205,10 @@ pub(crate) fn parse_record( return Ok(None); } - let Some(content) = record.pointer("/message/content") else { + let Some(content) = record + .pointer("/message/content") + .or_else(|| record.pointer("/message/parts")) + else { stats.empty += 1; return Ok(None); }; @@ -295,6 +298,15 @@ fn collect_text(content: &Value, opts: &ReadOptions, stats: &mut FilterStats) -> parts.push(format!("[tool_result: {bytes} bytes]")); } } + // An untyped block carrying text is text. Qwen writes its parts + // that way — `{"text": …}` with no `type` — and counting them as + // non-message would silently discard the entire conversation while + // reporting a healthy-looking filter histogram. + "" if block.get("text").and_then(Value::as_str).is_some() => { + if let Some(t) = block.get("text").and_then(Value::as_str) { + parts.push(t.to_string()); + } + } _ => stats.non_message += 1, } } diff --git a/src/transcript/copilot.rs b/src/transcript/copilot.rs new file mode 100644 index 0000000..77fc238 --- /dev/null +++ b/src/transcript/copilot.rs @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Reader for Copilot CLI's session store. +//! +//! The simplest of the store-backed readers, because the harness has already +//! done the work: `turns(session_id, turn_index, user_message, +//! assistant_response, timestamp)` is a flat, *pre-paired* transcript with no +//! JSON to parse at all, and `sessions.cwd` records the directory verbatim. +//! +//! Its entry in the harness table used to say the schema was undocumented and +//! the store therefore unreadable. It is neither — which is the reason that +//! entry, and two others like it, are now required to state only what was +//! actually probed. +//! +//! One structural difference from every other reader here: a row is *two* +//! turns, not one. The prompt and the reply share a row, so each is emitted +//! separately with the role it deserves, and the pair shares the row's single +//! timestamp — the harness records no separate time for the reply. + +use std::path::Path; + +use super::opencode::{open_readonly, sql_err, store_path}; +use super::{ + normalize_text, normalize_timestamp, redact, FilterStats, ReadOptions, ReadResult, SessionRef, + TranscriptError, Turn, TurnRole, +}; +use crate::harness::HarnessSpec; + +/// Lists the sessions Copilot CLI recorded for `cwd`. +/// +/// # Errors +/// +/// [`TranscriptError::NoHome`] when `$HOME` is unset, and an I/O error when the +/// store exists but cannot be read. A missing store is an empty list. +pub fn sessions(spec: &HarnessSpec, cwd: &Path) -> Result, TranscriptError> { + let path = store_path(spec)?; + if !path.exists() { + return Ok(Vec::new()); + } + let conn = open_readonly(&path)?; + let wanted = cwd.to_string_lossy().into_owned(); + + let mut stmt = conn + .prepare( + "SELECT s.id, s.cwd, \ + COALESCE(SUM(LENGTH(COALESCE(t.user_message, '')) \ + + LENGTH(COALESCE(t.assistant_response, ''))), 0) \ + FROM sessions s LEFT JOIN turns t ON t.session_id = s.id \ + WHERE s.cwd = ?1 \ + GROUP BY s.id, s.cwd, s.created_at \ + ORDER BY s.created_at DESC, s.id DESC", + ) + .map_err(sql_err)?; + let rows = stmt + .query_map([&wanted], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + )) + }) + .map_err(sql_err)?; + + let mut out = Vec::new(); + for row in rows { + let (id, dir, bytes) = row.map_err(sql_err)?; + out.push(SessionRef { + harness: spec.id, + session_id: id, + path: path.to_string_lossy().into_owned(), + cwd: Some(dir), + bytes: u64::try_from(bytes).unwrap_or(0), + }); + } + Ok(out) +} + +/// Reads one Copilot CLI session. +/// +/// # Errors +/// +/// An I/O error when the store cannot be read, [`TranscriptError::TooLarge`] +/// when the session's own content exceeds the ceiling, and +/// [`TranscriptError::BadTimestamp`] for a time that is not a valid instant. +pub fn read( + path: &Path, + session_id: &str, + opts: &ReadOptions, +) -> Result { + let conn = open_readonly(path)?; + let mut stats = FilterStats::default(); + let mut redactions = redact::Redactions::default(); + let mut turns = Vec::new(); + let mut accumulated: u64 = 0; + + let mut stmt = conn + .prepare( + "SELECT turn_index, user_message, assistant_response, timestamp FROM turns \ + WHERE session_id = ?1 ORDER BY turn_index ASC", + ) + .map_err(sql_err)?; + let rows = stmt + .query_map([session_id], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, Option>(3)?, + )) + }) + .map_err(sql_err)?; + + for row in rows { + let (index, user, assistant, timestamp) = row.map_err(sql_err)?; + let record = format!("turn {index}"); + let Some(raw_time) = timestamp else { + // A turn with no time cannot be ordered, and inventing one would + // silently reorder the conversation. Counted, not guessed. + stats.missing_uuid += 1; + continue; + }; + let created_at = + normalize_timestamp(&raw_time).map_err(|value| TranscriptError::BadTimestamp { + record: record.clone(), + value, + })?; + + // The prompt and the reply share one row and one timestamp. `turn_id` + // folds `source_uuid` in, so the two halves need distinct suffixes or + // the second would collide with the first and be dropped by + // `INSERT OR IGNORE`. + for (role, body, suffix) in [ + (TurnRole::User, user, "user"), + (TurnRole::Assistant, assistant, "assistant"), + ] { + let Some(body) = body else { + stats.empty += 1; + continue; + }; + accumulated = accumulated.saturating_add(body.len() as u64); + if accumulated > opts.max_bytes { + return Err(TranscriptError::TooLarge { + bytes: accumulated, + max_bytes: opts.max_bytes, + }); + } + let Some(text) = normalize_text(&body, opts.max_chars_per_turn, &mut stats) else { + continue; + }; + let text = redact::scrub(&text, &mut redactions); + turns.push(Turn { + source_uuid: format!("{index}:{suffix}"), + role, + text, + created_at: created_at.clone(), + }); + } + } + + Ok(ReadResult { + turns, + filtered: stats, + redactions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(path: &Path) { + let conn = rusqlite::Connection::open(path).expect("create store"); + conn.execute_batch( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, cwd TEXT NOT NULL, \ + created_at TEXT NOT NULL); + CREATE TABLE turns (id INTEGER PRIMARY KEY AUTOINCREMENT, \ + session_id TEXT NOT NULL, turn_index INTEGER NOT NULL, \ + user_message TEXT, assistant_response TEXT, timestamp TEXT); + INSERT INTO sessions VALUES ('s1', '/work/project', '2026-06-02T21:50:00.351Z'); + INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp) + VALUES ('s1', 0, 'separate the CLI from the IDE', 'Done, in two commits.', + '2026-06-02T21:50:00.351Z'); + INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp) + VALUES ('s1', 1, 'Make signed commit', NULL, '2026-06-02T21:52:34.150Z');", + ) + .expect("schema"); + } + + fn opts() -> ReadOptions { + ReadOptions { + include_thinking: false, + include_tools: false, + include_sidechains: false, + max_bytes: super::super::DEFAULT_MAX_BYTES, + max_chars_per_turn: super::super::DEFAULT_MAX_CHARS_PER_TURN, + } + } + + /// One row is two turns, and the halves keep distinct identities. + #[test] + fn a_row_becomes_a_prompt_and_a_reply() { + let tmp = std::env::temp_dir().join(format!("engram-cop-{}.db", std::process::id())); + let _ = std::fs::remove_file(&tmp); + fixture(&tmp); + + let result = read(&tmp, "s1", &opts()).expect("reads"); + assert_eq!( + result.turns.len(), + 3, + "two halves, then a prompt with no reply" + ); + assert_eq!(result.turns[0].role, TurnRole::User); + assert_eq!(result.turns[1].role, TurnRole::Assistant); + assert_eq!(result.turns[0].source_uuid, "0:user"); + assert_eq!(result.turns[1].source_uuid, "0:assistant"); + // Both halves share the row's single timestamp — the harness records + // no separate time for the reply. + assert_eq!(result.turns[0].created_at, result.turns[1].created_at); + // The unanswered prompt counts its missing half rather than inventing + // an empty reply. + assert_eq!(result.filtered.empty, 1); + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/src/transcript/goose.rs b/src/transcript/goose.rs new file mode 100644 index 0000000..3be81b0 --- /dev/null +++ b/src/transcript/goose.rs @@ -0,0 +1,268 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Reader for Goose's session store. +//! +//! The friendliest schema of the harnesses surveyed: `messages` carries `role` +//! as a first-class column rather than buried in a JSON blob, and +//! `sessions.working_dir` records the directory verbatim, so — as with Codex +//! and Opencode — there is no name to mangle and no mapping to invert. +//! +//! `content_json` is a small typed array, `[{"type":"text","text":…}]` or +//! `[{"type":"thinking","thinking":…}]`, which maps directly onto the filtering +//! contract in [`super`]: text is speech, thinking is excluded by default, and +//! anything else is counted rather than dropped in silence. +//! +//! Goose also has the best export of any harness here (`goose session export +//! --format markdown`), so this reader is a convenience rather than the only +//! way in — but a reader needs no manual step, which is the difference that +//! matters when capturing history continuously. + +use std::path::Path; + +use super::{ + normalize_text, normalize_timestamp, redact, FilterStats, ReadOptions, ReadResult, SessionRef, + TranscriptError, Turn, TurnRole, +}; +use crate::harness::HarnessSpec; + +use super::opencode::{open_readonly, store_path}; + +/// Lists the sessions Goose recorded for `cwd`. +/// +/// # Errors +/// +/// [`TranscriptError::NoHome`] when `$HOME` is unset, and an I/O error when the +/// store exists but cannot be read. A missing store is an empty list. +pub fn sessions(spec: &HarnessSpec, cwd: &Path) -> Result, TranscriptError> { + let path = store_path(spec)?; + if !path.exists() { + return Ok(Vec::new()); + } + let conn = open_readonly(&path)?; + let wanted = cwd.to_string_lossy().into_owned(); + + let mut stmt = conn + .prepare( + "SELECT s.id, s.working_dir, COALESCE(SUM(LENGTH(m.content_json)), 0) \ + FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \ + WHERE s.working_dir = ?1 \ + GROUP BY s.id, s.working_dir, s.created_at \ + ORDER BY s.created_at DESC, s.id DESC", + ) + .map_err(super::opencode::sql_err)?; + let rows = stmt + .query_map([&wanted], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + )) + }) + .map_err(super::opencode::sql_err)?; + + let mut out = Vec::new(); + for row in rows { + let (id, dir, bytes) = row.map_err(super::opencode::sql_err)?; + out.push(SessionRef { + harness: spec.id, + session_id: id, + path: path.to_string_lossy().into_owned(), + cwd: Some(dir), + bytes: u64::try_from(bytes).unwrap_or(0), + }); + } + Ok(out) +} + +/// Reads one Goose session. +/// +/// # Errors +/// +/// An I/O error when the store cannot be read, [`TranscriptError::TooLarge`] +/// when the session's own content exceeds the ceiling, and +/// [`TranscriptError::BadTimestamp`] for a time that is not a valid instant. +pub fn read( + path: &Path, + session_id: &str, + opts: &ReadOptions, +) -> Result { + let conn = open_readonly(path)?; + let mut stats = FilterStats::default(); + let mut redactions = redact::Redactions::default(); + let mut turns = Vec::new(); + let mut accumulated: u64 = 0; + + let mut stmt = conn + .prepare( + "SELECT message_id, role, content_json, created_timestamp FROM messages \ + WHERE session_id = ?1 ORDER BY created_timestamp ASC, id ASC", + ) + .map_err(super::opencode::sql_err)?; + let rows = stmt + .query_map([session_id], |r| { + Ok(( + r.get::<_, Option>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, i64>(3)?, + )) + }) + .map_err(super::opencode::sql_err)?; + + for (index, row) in rows.enumerate() { + let (message_id, role, content, created) = row.map_err(super::opencode::sql_err)?; + let role = match role.as_str() { + "user" => TurnRole::User, + "assistant" => TurnRole::Assistant, + _ => { + stats.unknown_record += 1; + continue; + } + }; + let Ok(parts) = serde_json::from_str::>(&content) else { + stats.torn_line += 1; + continue; + }; + + let mut text = String::new(); + for part in &parts { + match part.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + if let Some(t) = part.get("text").and_then(serde_json::Value::as_str) { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(t); + } + } + Some("thinking") => { + stats.thinking += 1; + if opts.include_thinking { + if let Some(t) = part.get("thinking").and_then(serde_json::Value::as_str) { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(t); + } + } + } + Some("toolRequest" | "toolResponse" | "toolConfirmationRequest") => { + stats.tool_use += 1; + if opts.include_tools { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(&format!("[tool: {} bytes]", part.to_string().len())); + } + } + Some(_) => stats.non_message += 1, + None => stats.unknown_record += 1, + } + } + + accumulated = accumulated.saturating_add(text.len() as u64); + if accumulated > opts.max_bytes { + return Err(TranscriptError::TooLarge { + bytes: accumulated, + max_bytes: opts.max_bytes, + }); + } + let Some(text) = normalize_text(&text, opts.max_chars_per_turn, &mut stats) else { + continue; + }; + let text = redact::scrub(&text, &mut redactions); + + // `message_id` is nullable in the schema, so identity falls back to the + // row's position — enough to be stable for an append-only log, and the + // same reasoning `codex` uses for records that carry no id of their own. + let source_uuid = message_id.unwrap_or_else(|| format!("row:{index}")); + let created_at = super::opencode::epoch_millis(created, &source_uuid)?; + let created_at = + normalize_timestamp(&created_at).map_err(|value| TranscriptError::BadTimestamp { + record: source_uuid.clone(), + value, + })?; + turns.push(Turn { + source_uuid, + role, + text, + created_at, + }); + } + + Ok(ReadResult { + turns, + filtered: stats, + redactions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(path: &Path) { + let conn = rusqlite::Connection::open(path).expect("create store"); + conn.execute_batch( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, working_dir TEXT NOT NULL, \ + created_at INTEGER NOT NULL); + CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT, \ + session_id TEXT NOT NULL, role TEXT NOT NULL, \ + content_json TEXT NOT NULL, created_timestamp INTEGER NOT NULL); + INSERT INTO sessions VALUES ('s1', '/work/project', 1779491760); + INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) + VALUES ('m1','s1','user','[{\"type\":\"text\",\"text\":\"list my repos\"}]',1779491760000); + INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) + VALUES ('m2','s1','assistant','[{\"type\":\"thinking\",\"thinking\":\"ponder\"},{\"type\":\"text\",\"text\":\"here they are\"}]',1779491761000); + INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) + VALUES (NULL,'s1','assistant','[{\"type\":\"toolRequest\",\"id\":\"t1\"}]',1779491762000);", + ) + .expect("schema"); + } + + fn opts() -> ReadOptions { + ReadOptions { + include_thinking: false, + include_tools: false, + include_sidechains: false, + max_bytes: super::super::DEFAULT_MAX_BYTES, + max_chars_per_turn: super::super::DEFAULT_MAX_CHARS_PER_TURN, + } + } + + #[test] + fn reads_turns_and_excludes_thinking_by_default() { + let tmp = std::env::temp_dir().join(format!("engram-goose-{}.db", std::process::id())); + let _ = std::fs::remove_file(&tmp); + fixture(&tmp); + + let result = read(&tmp, "s1", &opts()).expect("reads"); + assert_eq!( + result.turns.len(), + 2, + "the tool-only message yields no turn" + ); + assert_eq!(result.turns[0].role, TurnRole::User); + assert_eq!(result.turns[1].text, "here they are", "thinking is dropped"); + assert_eq!(result.filtered.thinking, 1); + assert_eq!(result.filtered.tool_use, 1); + let _ = std::fs::remove_file(&tmp); + } + + /// A row with no `message_id` still gets a stable identity from its + /// position, so re-ingesting it does not duplicate. + #[test] + fn a_null_message_id_falls_back_to_the_row_position() { + let tmp = std::env::temp_dir().join(format!("engram-goose2-{}.db", std::process::id())); + let _ = std::fs::remove_file(&tmp); + fixture(&tmp); + + let mut o = opts(); + o.include_tools = true; + let result = read(&tmp, "s1", &o).expect("reads"); + let last = result.turns.last().expect("a tool turn"); + assert_eq!(last.source_uuid, "row:2"); + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/src/transcript/mod.rs b/src/transcript/mod.rs index 9358227..c5c7873 100644 --- a/src/transcript/mod.rs +++ b/src/transcript/mod.rs @@ -36,6 +36,10 @@ pub mod claude_code; pub mod codex; +pub mod copilot; +pub mod goose; +pub mod opencode; +pub mod qwen; pub mod redact; use crate::harness::{Harness, HarnessSpec, ReaderKind, TranscriptSupport}; @@ -406,6 +410,10 @@ pub fn sessions_for(spec: &HarnessSpec, cwd: &Path) -> Result, T match spec.transcript { TranscriptSupport::Reader(ReaderKind::ClaudeCode) => claude_code::sessions(spec, cwd), TranscriptSupport::Reader(ReaderKind::Codex) => codex::sessions(spec, cwd), + TranscriptSupport::Reader(ReaderKind::Opencode) => opencode::sessions(spec, cwd), + TranscriptSupport::Reader(ReaderKind::Goose) => goose::sessions(spec, cwd), + TranscriptSupport::Reader(ReaderKind::CopilotCli) => copilot::sessions(spec, cwd), + TranscriptSupport::Reader(ReaderKind::Qwen) => qwen::sessions(spec, cwd), TranscriptSupport::NotImplemented { detail } | TranscriptSupport::Unsupported { detail } => Err(TranscriptError::NoReader(detail)), } @@ -427,6 +435,22 @@ pub fn read_session( claude_code::read(Path::new(&session.path), opts) } TranscriptSupport::Reader(ReaderKind::Codex) => codex::read(Path::new(&session.path), opts), + // A store-backed harness keeps every session in one file, so the + // reader needs the id as well as the path. + TranscriptSupport::Reader(ReaderKind::Opencode) => { + opencode::read(Path::new(&session.path), &session.session_id, opts) + } + TranscriptSupport::Reader(ReaderKind::Goose) => { + goose::read(Path::new(&session.path), &session.session_id, opts) + } + TranscriptSupport::Reader(ReaderKind::CopilotCli) => { + copilot::read(Path::new(&session.path), &session.session_id, opts) + } + // Qwen writes Claude Code's records, so it shares that reader; only + // session discovery differs. + TranscriptSupport::Reader(ReaderKind::Qwen) => { + claude_code::read(Path::new(&session.path), opts) + } TranscriptSupport::NotImplemented { detail } | TranscriptSupport::Unsupported { detail } => Err(TranscriptError::NoReader(detail)), } diff --git a/src/transcript/opencode.rs b/src/transcript/opencode.rs new file mode 100644 index 0000000..a7cc36d --- /dev/null +++ b/src/transcript/opencode.rs @@ -0,0 +1,378 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Reader for Opencode's session store, and for Z.ai's Z Code. +//! +//! One reader serves both because Z Code's CLI is an Opencode fork carrying the +//! same `session`/`message`/`part` schema — the same relationship +//! [`super::claude_code`] already has with OpenClaude. Only the database's +//! location differs, and that comes from the harness spec. +//! +//! The store is SQLite rather than a file per session, which changes two things +//! about the reader's shape. Every session shares one path, so [`read`] takes +//! the session id as well as the path; and the database belongs to a program +//! that may be running right now, so it is opened **read-only**. Engram has no +//! business writing to another tool's store, and a read-only handle makes that +//! structural rather than a promise. +//! +//! Reconstructing a turn is a two-table join. `message` holds the role and the +//! time; the text lives in `part` rows keyed by message, ordered by their own +//! creation time. A part is typed, and the types are what the filtering +//! contract in [`super`] is expressed over: on this machine a real store held +//! `tool` 9854, `step-start` 6439, `step-finish` 6335, `reasoning` 3945, `text` +//! 3322 and `patch` 461 — so the overwhelming majority of parts are *not* +//! conversation, and counting them rather than dropping them silently is the +//! whole point. + +use std::path::Path; + +use rusqlite::OpenFlags; + +use super::{ + normalize_text, normalize_timestamp, redact, FilterStats, ReadOptions, ReadResult, SessionRef, + TranscriptError, Turn, TurnRole, +}; +use crate::harness::HarnessSpec; + +/// Opens a harness's store without any possibility of writing to it. +/// +/// `SQLITE_OPEN_READ_ONLY` is the point: this is somebody else's database, +/// very possibly open in another process while engram reads it. +/// Maps a `rusqlite` failure into the transcript error type. +/// +/// Shared by the store-backed readers so they cannot disagree about how a +/// query failure surfaces. +pub(super) fn sql_err(e: rusqlite::Error) -> TranscriptError { + TranscriptError::Io(std::io::Error::other(e.to_string())) +} + +pub(super) fn open_readonly(path: &Path) -> Result { + rusqlite::Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(sql_err) +} + +/// Resolves the harness's database file. +pub(super) fn store_path(spec: &HarnessSpec) -> Result { + let rel = spec.sessions_dir.ok_or(TranscriptError::NoReader( + "this harness declares no session store", + ))?; + crate::harness::in_home(rel).ok_or(TranscriptError::NoHome) +} + +/// Lists the sessions this harness recorded for `cwd`. +/// +/// Matched on `session.directory`, which the harness records verbatim, so +/// there is no name to mangle and no mapping to invert — the same situation as +/// Codex, and the opposite of Claude Code. +/// +/// # Errors +/// +/// Returns [`TranscriptError::NoHome`] when `$HOME` is unset, and an I/O error +/// when the store exists but cannot be opened. A store that does not exist is +/// an empty list, not an error: a harness that is installed but has never been +/// run is not a failure. +pub fn sessions(spec: &HarnessSpec, cwd: &Path) -> Result, TranscriptError> { + let path = store_path(spec)?; + if !path.exists() { + return Ok(Vec::new()); + } + let conn = open_readonly(&path)?; + let wanted = cwd.to_string_lossy().into_owned(); + + // `bytes` is the size of this *session*, summed from its parts, not the + // size of the store. Reporting the file would say 550 MB for every session + // in it, which is both useless to a reader and — as `--max-bytes` found out + // — actively wrong to compare a ceiling against. + let mut stmt = conn + .prepare( + "SELECT s.id, s.directory, COALESCE(SUM(LENGTH(p.data)), 0) \ + FROM session s LEFT JOIN part p ON p.session_id = s.id \ + WHERE s.directory = ?1 \ + GROUP BY s.id, s.directory, s.time_created \ + ORDER BY s.time_created DESC, s.id DESC", + ) + .map_err(sql_err)?; + let rows = stmt + .query_map([&wanted], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + )) + }) + .map_err(sql_err)?; + + let mut out = Vec::new(); + for row in rows { + let (id, directory, bytes) = row.map_err(sql_err)?; + out.push(SessionRef { + harness: spec.id, + session_id: id, + path: path.to_string_lossy().into_owned(), + cwd: Some(directory), + bytes: u64::try_from(bytes).unwrap_or(0), + }); + } + Ok(out) +} + +/// Converts the harness's epoch-milliseconds to ISO 8601 UTC. +/// +/// An out-of-range value is an error rather than a substitution, for the reason +/// stated in [`super`]: `recall` orders by `created_at`, so a wall-clock +/// fallback would destroy reading order without saying so. +pub(super) fn epoch_millis(ms: i64, record: &str) -> Result { + jiff::Timestamp::from_millisecond(ms) + .map_err(|_| TranscriptError::BadTimestamp { + record: record.to_string(), + value: ms.to_string(), + }) + .map(|t| t.to_string()) +} + +/// Reads one session out of the store. +/// +/// # Errors +/// +/// Returns an I/O error when the store cannot be read, and +/// [`TranscriptError::BadTimestamp`] for a time that is not a valid instant. +pub fn read( + path: &Path, + session_id: &str, + opts: &ReadOptions, +) -> Result { + // The ceiling applies to this session's own content, not to the file. + // + // For a harness that writes one file per session the two are the same + // thing, which is why the check reads as a file-size check in the other + // readers. Here they are not: the store held 550 MB of *all* sessions, so + // measuring the file refused every session in it with "transcript is + // 550273024 bytes" — a transcript nobody asked for. The guard's real job is + // to stop one runaway conversation exhausting memory, so it is enforced + // below against the text actually accumulated. + let conn = open_readonly(path)?; + let mut stats = FilterStats::default(); + let mut redactions = redact::Redactions::default(); + let mut turns = Vec::new(); + let mut accumulated: u64 = 0; + + let mut messages = conn + .prepare( + "SELECT id, data, time_created FROM message \ + WHERE session_id = ?1 ORDER BY time_created ASC, id ASC", + ) + .map_err(sql_err)?; + let rows = messages + .query_map([session_id], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + )) + }) + .map_err(sql_err)?; + + let mut parts = conn + .prepare( + "SELECT data FROM part WHERE message_id = ?1 \ + ORDER BY time_created ASC, id ASC", + ) + .map_err(sql_err)?; + + for row in rows { + let (id, data, created) = row.map_err(sql_err)?; + let Ok(msg) = serde_json::from_str::(&data) else { + stats.torn_line += 1; + continue; + }; + let role = match msg.get("role").and_then(serde_json::Value::as_str) { + Some("user") => TurnRole::User, + Some("assistant") => TurnRole::Assistant, + // A role engram does not know is a format change in a file it does + // not own — the signal `unknown_record` exists to raise. + _ => { + stats.unknown_record += 1; + continue; + } + }; + + let part_rows = parts + .query_map([&id], |r| r.get::<_, String>(0)) + .map_err(sql_err)?; + + let mut text = String::new(); + for part in part_rows { + let part = part.map_err(sql_err)?; + let Ok(p) = serde_json::from_str::(&part) else { + stats.torn_line += 1; + continue; + }; + match p.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + if let Some(t) = p.get("text").and_then(serde_json::Value::as_str) { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(t); + } + } + Some("reasoning") => { + stats.thinking += 1; + if opts.include_thinking { + if let Some(t) = p.get("text").and_then(serde_json::Value::as_str) { + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(t); + } + } + } + Some("tool") => { + stats.tool_use += 1; + // Summarized at most, never stored: a tool payload is where + // file contents, command output and credentials live. + if opts.include_tools { + let name = p + .get("tool") + .and_then(serde_json::Value::as_str) + .unwrap_or("tool"); + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(&format!("[{name}: {} bytes]", part.len())); + } + } + // Step markers, file references and patches are structure, not + // speech. Counted so a format change is visible. + Some(_) => stats.non_message += 1, + None => stats.unknown_record += 1, + } + } + + accumulated = accumulated.saturating_add(text.len() as u64); + if accumulated > opts.max_bytes { + return Err(TranscriptError::TooLarge { + bytes: accumulated, + max_bytes: opts.max_bytes, + }); + } + let Some(text) = normalize_text(&text, opts.max_chars_per_turn, &mut stats) else { + continue; + }; + let text = redact::scrub(&text, &mut redactions); + let created_at = epoch_millis(created, &id)?; + // The harness's own value is normalized through the same path a + // string-valued timestamp takes, so both readers agree on the shape. + let created_at = + normalize_timestamp(&created_at).map_err(|value| TranscriptError::BadTimestamp { + record: id.clone(), + value, + })?; + turns.push(Turn { + source_uuid: id, + role, + text, + created_at, + }); + } + + Ok(ReadResult { + turns, + filtered: stats, + redactions, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a store with Opencode's schema and one session. + fn fixture(path: &Path) { + let conn = rusqlite::Connection::open(path).expect("create store"); + conn.execute_batch( + "CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL, \ + time_created INTEGER NOT NULL); + CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, \ + time_created INTEGER NOT NULL, data TEXT NOT NULL); + CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, \ + time_created INTEGER NOT NULL, data TEXT NOT NULL);", + ) + .expect("schema"); + conn.execute( + "INSERT INTO session VALUES ('ses_1', '/work/project', 1779491760000)", + [], + ) + .expect("session"); + conn.execute( + "INSERT INTO message VALUES ('msg_1', 'ses_1', 1779491760492, '{\"role\":\"user\"}')", + [], + ) + .expect("m1"); + conn.execute( + "INSERT INTO message VALUES ('msg_2', 'ses_1', 1779491785525, '{\"role\":\"assistant\"}')", + [], + ) + .expect("m2"); + conn.execute_batch( + "INSERT INTO part VALUES ('p1','msg_1',1,'{\"type\":\"text\",\"text\":\"fix the build\"}'); + INSERT INTO part VALUES ('p2','msg_2',1,'{\"type\":\"reasoning\",\"text\":\"secret plan\"}'); + INSERT INTO part VALUES ('p3','msg_2',2,'{\"type\":\"tool\",\"tool\":\"edit\"}'); + INSERT INTO part VALUES ('p4','msg_2',3,'{\"type\":\"step-start\"}'); + INSERT INTO part VALUES ('p5','msg_2',4,'{\"type\":\"text\",\"text\":\"renamed it\"}');", + ) + .expect("parts"); + } + + fn opts() -> ReadOptions { + ReadOptions { + include_thinking: false, + include_tools: false, + include_sidechains: false, + max_bytes: super::super::DEFAULT_MAX_BYTES, + max_chars_per_turn: super::super::DEFAULT_MAX_CHARS_PER_TURN, + } + } + + #[test] + fn reads_two_turns_and_counts_what_it_drops() { + let tmp = std::env::temp_dir().join(format!("engram-oc-{}.db", std::process::id())); + let _ = std::fs::remove_file(&tmp); + fixture(&tmp); + + let result = read(&tmp, "ses_1", &opts()).expect("reads"); + assert_eq!(result.turns.len(), 2); + assert_eq!(result.turns[0].role, TurnRole::User); + assert_eq!(result.turns[0].text, "fix the build"); + // Two text parts of one message join into one turn. + assert_eq!(result.turns[1].text, "renamed it"); + // Everything else is counted, never silently skipped. + assert_eq!(result.filtered.thinking, 1); + assert_eq!(result.filtered.tool_use, 1); + assert_eq!(result.filtered.non_message, 1); + // Epoch milliseconds become ISO 8601 UTC. + assert!(result.turns[0].created_at.ends_with('Z')); + let _ = std::fs::remove_file(&tmp); + } + + /// Thinking is excluded by default and included on request — and a tool + /// payload is summarized either way, never stored. + #[test] + fn include_flags_widen_without_storing_payloads() { + let tmp = std::env::temp_dir().join(format!("engram-oc2-{}.db", std::process::id())); + let _ = std::fs::remove_file(&tmp); + fixture(&tmp); + + let mut o = opts(); + o.include_thinking = true; + o.include_tools = true; + let result = read(&tmp, "ses_1", &o).expect("reads"); + let assistant = &result.turns[1].text; + assert!(assistant.contains("secret plan"), "{assistant}"); + assert!(assistant.contains("[edit:"), "{assistant}"); + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/src/transcript/qwen.rs b/src/transcript/qwen.rs new file mode 100644 index 0000000..dfe448a --- /dev/null +++ b/src/transcript/qwen.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Mohamed Hammad +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Reader for Qwen Code's transcripts. +//! +//! Qwen writes Claude Code's record envelope — `uuid`, `parentUuid`, +//! `sessionId`, `timestamp`, `type` and a verbatim `cwd` on every record — so +//! [`super::claude_code::read`] serves it unchanged, exactly as it already +//! serves OpenClaude. Two differences are real, and both are small: +//! +//! * The body lives at `message.parts[]` rather than `message.content[]`, and +//! its entries carry no `type` field. The shared reader handles both, because +//! an untyped block carrying text is text. +//! * Sessions sit one level deeper — `projects//chats/` — which is +//! the only reason this module exists rather than pointing the harness spec +//! straight at the Claude Code reader. +//! +//! The mangled directory is produced by [`super::mangle_cwd`], so this reader +//! inherits the correction that made dotted paths reachable. + +use std::path::Path; + +use super::{session_id_from_path, sort_newest_first, SessionRef, TranscriptError}; +use crate::harness::{self, HarnessSpec}; + +/// Lists the Qwen sessions recorded for `cwd`. +/// +/// # Errors +/// +/// [`TranscriptError::NoHome`] when `$HOME` is unset, and an I/O error when the +/// directory exists but cannot be read. A missing directory is an empty list, +/// not an error — an installed harness that has never run in this project is +/// not a failure. +pub fn sessions(spec: &HarnessSpec, cwd: &Path) -> Result, TranscriptError> { + let base = harness::sessions_dir(spec).ok_or(TranscriptError::NoHome)?; + let dir = base.join(super::mangle_cwd(cwd)).join("chats"); + + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(TranscriptError::Io(e)), + }; + + let mut found = Vec::new(); + for entry in entries { + let entry = entry.map_err(TranscriptError::Io)?; + let path = entry.path(); + if path.extension().is_none_or(|e| e != "jsonl") { + continue; + } + let meta = entry.metadata().map_err(TranscriptError::Io)?; + if !meta.is_file() { + continue; + } + let modified = meta.modified().unwrap_or(std::time::UNIX_EPOCH); + found.push(( + SessionRef { + harness: spec.id, + session_id: session_id_from_path(&path), + path: path.to_string_lossy().into_owned(), + cwd: Some(cwd.to_string_lossy().into_owned()), + bytes: meta.len(), + }, + modified, + )); + } + + sort_newest_first(&mut found); + Ok(found.into_iter().map(|(s, _)| s).collect()) +} diff --git a/tests/cli.rs b/tests/cli.rs index a824827..388470f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1956,7 +1956,11 @@ fn ingest_from_a_readerless_harness_is_exit_2_with_a_fallback() { let db = tmp.path().join("test.db"); let (home, project) = ingest_fixture(&tmp); - for harness in ["antigravity", "copilot-cli", "goose", "qwen"] { + // Harnesses that genuinely still have no reader. The list shrinks as + // readers land, which is the point — it must never be padded with a + // harness engram can now read, or the test would assert a refusal that + // should no longer happen. + for harness in ["antigravity", "cursor", "kimi", "vscode"] { let assert = ingest(&db, &home, &project, &["--harness", harness, "--list"]) .assert() .failure()