Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update all reader support documentation

This updated list says the new readers are supported, but the other authoritative documentation still says readers exist only for Claude Code and Codex (AGENTS.md:34, doc/engram.texi:797) and the manual's support table marks Opencode, Qwen, Goose, and Copilot CLI as unsupported (doc/engram.texi:1338-1342). Users following the command reference or manual will therefore be told not to use the functionality added by this commit.

Useful? React with 👍 / 👎.


**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/<mangled-cwd>/chats/`), which is why `qwen.rs` exists at all.

- **Codex layout:** `~/.codex/sessions/YYYY/MM/DD/rollout-<ISO>-<uuid>.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 `<environment_context>` 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`.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 15 additions & 26 deletions src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
},
Expand All @@ -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 --- \
Expand All @@ -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/<name>/SKILL.md`.
Expand Down Expand Up @@ -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",
},
Expand Down
14 changes: 13 additions & 1 deletion src/transcript/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down Expand Up @@ -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,
}
}
Expand Down
225 changes: 225 additions & 0 deletions src/transcript/copilot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
// SPDX-FileCopyrightText: 2026 Mohamed Hammad <Mohamed.Hammad@SpacecraftSoftware.org>
// 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<Vec<SessionRef>, 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<ReadResult, TranscriptError> {
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<String>>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, Option<String>>(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;
Comment on lines +117 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject Copilot turns with missing timestamps

When a Copilot row has a NULL timestamp, this branch silently skips both nonempty halves and reports missing_uuid, even though the row has a stable turn_index identity. The capture therefore succeeds while omitting real conversation text; treat an absent timestamp as BadTimestamp, just like an invalid timestamp, rather than converting it into an unrelated filter count.

AGENTS.md reference: AGENTS.md:L276-L277

Useful? React with 👍 / 👎.

};
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);
}
}
Loading
Loading