From d968574345e9decd7b47283e38555b854f2ba45c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 29 Aug 2026 06:04:11 -0700 Subject: [PATCH 1/4] feat(hosts): omp sessions Oh My Pi writes pi's format under ~/.omp/agent/sessions and reuses pi's override variables; the omp module is pi's reader over that root. Timestamp formatting moves to a shared time module. refs #24 --- crates/plannotator-tui-hosts/src/omp.rs | 21 ++++++++++++++++ crates/plannotator-tui-hosts/src/pi.rs | 30 +++-------------------- crates/plannotator-tui-hosts/src/time.rs | 27 ++++++++++++++++++++ crates/plannotator-tui-hosts/tests/omp.rs | 23 +++++++++++++++++ 4 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 crates/plannotator-tui-hosts/src/omp.rs create mode 100644 crates/plannotator-tui-hosts/src/time.rs create mode 100644 crates/plannotator-tui-hosts/tests/omp.rs diff --git a/crates/plannotator-tui-hosts/src/omp.rs b/crates/plannotator-tui-hosts/src/omp.rs new file mode 100644 index 0000000..86586d6 --- /dev/null +++ b/crates/plannotator-tui-hosts/src/omp.rs @@ -0,0 +1,21 @@ +//! Oh My Pi (OMP): a pi harness with pi's session format and layout, rooted at +//! `~/.omp/agent/sessions` (`PI_CODING_AGENT_SESSION_DIR` / `PI_CODING_AGENT_DIR` apply +//! unchanged: OMP reuses pi's variable names, `oh-my-pi/packages/coding-agent/src/cli/args.ts`). +//! Under Herdr, the exact transcript path arrives as `agent_session` and no discovery runs. + +use std::path::{Path, PathBuf}; + +use crate::{Message, pi}; + +/// The agent directory relative to `$HOME`. +pub const DEFAULT_AGENT_DIR: &str = ".omp/agent"; + +/// The newest OMP session for `cwd`; pi's rules over OMP's root. +pub fn find_transcript(sessions_dir: &Path, cwd: &Path) -> Option { + pi::find_transcript(sessions_dir, cwd) +} + +/// OMP writes pi's entries; the same reader applies. +pub fn parse_messages(jsonl: &str, n: usize) -> Vec { + pi::parse_messages(jsonl, n) +} diff --git a/crates/plannotator-tui-hosts/src/pi.rs b/crates/plannotator-tui-hosts/src/pi.rs index 15c00bb..74539d9 100644 --- a/crates/plannotator-tui-hosts/src/pi.rs +++ b/crates/plannotator-tui-hosts/src/pi.rs @@ -116,36 +116,14 @@ impl Entry { fn iso_timestamp(value: &Value) -> Option { match value { Value::String(s) if !s.trim().is_empty() => Some(s.clone()), - Value::Number(n) => n.as_f64().filter(|f| f.is_finite() && *f >= 0.0).map(|ms| ms_to_iso(ms as u64)), + Value::Number(n) => n + .as_f64() + .filter(|f| f.is_finite() && *f >= 0.0) + .map(|ms| crate::time::iso_from_unix_ms(ms as u64)), _ => None, } } -fn ms_to_iso(ms: u64) -> String { - let secs = ms / 1000; - let (year, month, day) = civil_from_days(secs / 86_400); - format!( - "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z", - (secs / 3600) % 24, - (secs / 60) % 60, - secs % 60, - ms % 1000 - ) -} - -/// Howard Hinnant's days-to-civil. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - (yoe + era * 400 + u64::from(m <= 2), m, d) -} - /// The newest `n` messages on the active branch, newest first. The branch is the /// `parentId` chain from the newest entry with an id to the root — what pi's own /// `getBranch()` returns; entries orphaned by a rewind are absent. A chain that cannot be diff --git a/crates/plannotator-tui-hosts/src/time.rs b/crates/plannotator-tui-hosts/src/time.rs new file mode 100644 index 0000000..054ba0e --- /dev/null +++ b/crates/plannotator-tui-hosts/src/time.rs @@ -0,0 +1,27 @@ +//! Unix time to ISO 8601 without a date crate; hosts stamp messages in seconds or ms. + +/// `ms` since the Unix epoch as `YYYY-MM-DDTHH:MM:SS.mmmZ`. +pub(crate) fn iso_from_unix_ms(ms: u64) -> String { + let secs = ms / 1000; + let (year, month, day) = civil_from_days(secs / 86_400); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z", + (secs / 3600) % 24, + (secs / 60) % 60, + secs % 60, + ms % 1000 + ) +} + +/// Howard Hinnant's days-to-civil. +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days + 719_468; + let era = z / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + u64::from(m <= 2), m, d) +} diff --git a/crates/plannotator-tui-hosts/tests/omp.rs b/crates/plannotator-tui-hosts/tests/omp.rs new file mode 100644 index 0000000..c4f10c6 --- /dev/null +++ b/crates/plannotator-tui-hosts/tests/omp.rs @@ -0,0 +1,23 @@ +//! OMP reads exactly like pi, from its own root. + +#![allow(clippy::expect_used, reason = "tests assert by panicking")] + +use std::path::{Path, PathBuf}; + +use plannotator_tui_hosts::{Role, omp, pi}; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +#[test] +fn omp_sessions_resolve_and_parse_with_pis_rules() { + let root = fixtures().join("pi-sessions"); + let found = omp::find_transcript(&root, Path::new("/work/project")).expect("a session for the cwd"); + assert_eq!(found, pi::find_transcript(&root, Path::new("/work/project")).expect("pi agrees")); + let text = std::fs::read_to_string(&found).expect("session"); + let messages = omp::parse_messages(&text, 25); + assert_eq!(messages, pi::parse_messages(&text, 25)); + assert!(messages.iter().any(|m| m.role == Role::Assistant)); + assert_eq!(omp::DEFAULT_AGENT_DIR, ".omp/agent"); +} From 619a57bb9ec7d38ed81a18bf5f23f5f91cee37f3 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 29 Aug 2026 06:04:11 -0700 Subject: [PATCH 2/4] feat(hosts): hermes cli sessions from sqlite Hermes keeps conversations in ~/.hermes/state.db (WAL); messages_for_session opens it read-only (mode=ro, immutable=1 fallback) and reads the newest active rows of a session id with one indexed query. rusqlite 0.31 with the bundled feature. refs #25 --- Cargo.lock | 91 ++++++++++++++ crates/plannotator-tui-hosts/Cargo.toml | 1 + crates/plannotator-tui-hosts/src/hermes.rs | 86 ++++++++++++++ crates/plannotator-tui-hosts/tests/hermes.rs | 119 +++++++++++++++++++ 4 files changed, 297 insertions(+) create mode 100644 crates/plannotator-tui-hosts/src/hermes.rs create mode 100644 crates/plannotator-tui-hosts/tests/hermes.rs diff --git a/Cargo.lock b/Cargo.lock index 25a01d1..45274e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -447,6 +457,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -479,6 +501,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -616,6 +644,15 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -638,6 +675,15 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -818,6 +864,17 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "line-clipping" version = "0.3.8" @@ -1228,6 +1285,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plannotator-tui" version = "0.3.0" @@ -1237,6 +1300,7 @@ dependencies = [ "plannotator-tui-schema", "pulldown-cmark", "ratatui", + "rusqlite", "serde", "serde_json", "similar", @@ -1250,6 +1314,7 @@ dependencies = [ name = "plannotator-tui-hosts" version = "0.3.0" dependencies = [ + "rusqlite", "serde_json", ] @@ -1570,6 +1635,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -1701,6 +1780,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -2150,6 +2235,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/crates/plannotator-tui-hosts/Cargo.toml b/crates/plannotator-tui-hosts/Cargo.toml index 3441b4f..15dce4d 100644 --- a/crates/plannotator-tui-hosts/Cargo.toml +++ b/crates/plannotator-tui-hosts/Cargo.toml @@ -9,6 +9,7 @@ categories = ["text-processing"] description = "Find a coding agent's transcript and read its recent messages (Claude Code, Codex, pi)." [dependencies] +rusqlite = { version = "0.31.0", features = ["bundled"] } serde_json.workspace = true [lints] diff --git a/crates/plannotator-tui-hosts/src/hermes.rs b/crates/plannotator-tui-hosts/src/hermes.rs new file mode 100644 index 0000000..3327ab6 --- /dev/null +++ b/crates/plannotator-tui-hosts/src/hermes.rs @@ -0,0 +1,86 @@ +//! Hermes CLI: conversations live in `SQLite` (`~/.hermes/state.db`, WAL mode), one row per +//! message, addressed by the session id Herdr reports through `agent_session`. There is no +//! transcript file. Schema (`sessions`, `messages`) as documented in plannotator-tui#25; the +//! newest reply is one indexed query over `idx_messages_session_active`. + +use std::path::Path; + +use rusqlite::{Connection, OpenFlags, params}; + +use crate::{HostError, Message, Role}; + +/// The database relative to `$HERMES_HOME` (default `~/.hermes`). +pub const DB_FILE: &str = "state.db"; + +/// The newest `n` user and assistant messages of `session_id`, newest first. +/// +/// The database is opened read-only and never written. A plain read-only open sees the +/// live WAL; when the shared-memory index cannot be opened, the read falls back to +/// `immutable=1`, which reads the main file only and may lag an active writer. +pub fn messages_for_session(db: &Path, session_id: &str, n: usize) -> Result, HostError> { + if !db.is_file() { + return Err(HostError::NoTranscript(format!("no Hermes database at {}", db.display()))); + } + let connection = open_read_only(db)?; + let mut statement = connection + .prepare( + "SELECT id, role, content, timestamp FROM messages \ + WHERE session_id = ?1 AND role IN ('assistant', 'user') AND active = 1 \ + AND content IS NOT NULL AND trim(content) <> '' \ + ORDER BY timestamp DESC, id DESC LIMIT ?2", + ) + .map_err(|e| sql_error(&e))?; + let rows = statement + .query_map(params![session_id, n as i64], |row| { + let id: i64 = row.get(0)?; + let role: String = row.get(1)?; + let content: Option = row.get(2)?; + let timestamp: Option = row.get(3)?; + Ok((id, role, content, timestamp)) + }) + .map_err(|e| sql_error(&e))?; + let mut messages = Vec::new(); + for row in rows { + let (id, role, content, timestamp) = row.map_err(|e| sql_error(&e))?; + let Some(text) = content.filter(|c| !c.trim().is_empty()) else { continue }; + let role = match role.as_str() { + "assistant" => Role::Assistant, + "user" => Role::Human, + _ => continue, + }; + messages.push(Message { id: id.to_string(), role, text, at: timestamp.map(iso) }); + } + if messages.is_empty() { + return Err(HostError::NoMessages(format!( + "no messages for Hermes session {session_id} in {}", + db.display() + ))); + } + Ok(messages) +} + +fn open_read_only(db: &Path) -> Result { + let flags = + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX | OpenFlags::SQLITE_OPEN_URI; + let uri = |query: &str| format!("file:{}?{query}", db.display()); + match Connection::open_with_flags(uri("mode=ro"), flags) { + Ok(connection) if probe(&connection) => Ok(connection), + _ => Connection::open_with_flags(uri("mode=ro&immutable=1"), flags).map_err(|e| sql_error(&e)), + } +} + +/// A read-only WAL open can succeed and still fail on first read when the `-shm` cannot be +/// mapped; probe once so the fallback happens before any query. +fn probe(connection: &Connection) -> bool { + connection.query_row("SELECT count(*) FROM sqlite_master", [], |row| row.get::<_, i64>(0)).is_ok() +} + +/// Hermes stamps rows in Unix seconds (REAL). +fn iso(seconds: f64) -> String { + let ms = if seconds.is_finite() && seconds >= 0.0 { (seconds * 1000.0) as u64 } else { 0 }; + crate::time::iso_from_unix_ms(ms) +} + +fn sql_error(err: &rusqlite::Error) -> HostError { + HostError::NoTranscript(format!("reading the Hermes database: {err}")) +} diff --git a/crates/plannotator-tui-hosts/tests/hermes.rs b/crates/plannotator-tui-hosts/tests/hermes.rs new file mode 100644 index 0000000..a7f337b --- /dev/null +++ b/crates/plannotator-tui-hosts/tests/hermes.rs @@ -0,0 +1,119 @@ +//! Hermes CLI messages out of a generated `state.db`, read-only. + +#![allow( + clippy::expect_used, + clippy::indexing_slicing, + clippy::type_complexity, + reason = "tests assert by panicking" +)] + +use std::path::{Path, PathBuf}; + +use plannotator_tui_hosts::{HostError, Role, hermes}; +use rusqlite::{Connection, params}; + +const SCHEMA: &str = " +CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT NOT NULL, display_name TEXT, model TEXT, + parent_session_id TEXT, started_at REAL NOT NULL, ended_at REAL, message_count INTEGER DEFAULT 0); +CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, + timestamp REAL NOT NULL, reasoning TEXT, observed INTEGER DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0); +CREATE INDEX idx_messages_session_active ON messages(session_id, active, timestamp); +"; + +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("plannotator-tui-hermes-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +/// Three sessions; `s1` holds mixed roles, an inactive row, a compacted row and out-of-order +/// timestamps so ordering by time (not insertion) is what the reader must do. +fn populate(db: &Path, wal: bool) -> Connection { + let connection = Connection::open(db).expect("create"); + if wal { + connection.pragma_update(None, "journal_mode", "WAL").expect("wal"); + } + connection.execute_batch(SCHEMA).expect("schema"); + for id in ["s1", "s2", "s3"] { + connection + .execute("INSERT INTO sessions (id, source, started_at) VALUES (?1, 'cli', 1.0)", params![id]) + .expect("session"); + } + let rows: &[(&str, &str, Option<&str>, f64, i64, i64)] = &[ + ("s1", "user", Some("first question"), 100.0, 1, 0), + ("s1", "assistant", Some("oldest reply"), 101.0, 1, 1), + ("s1", "tool", Some("tool output"), 102.0, 1, 0), + ("s1", "assistant", Some("newest reply"), 300.0, 1, 0), + ("s1", "assistant", Some("middle reply"), 200.0, 1, 0), + ("s1", "assistant", Some("retracted reply"), 400.0, 0, 0), + ("s1", "assistant", None, 500.0, 1, 0), + ("s2", "assistant", Some("other session"), 900.0, 1, 0), + ]; + for (session, role, content, at, active, compacted) in rows { + connection + .execute( + "INSERT INTO messages (session_id, role, content, timestamp, active, compacted) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![session, role, content, at, active, compacted], + ) + .expect("message"); + } + connection +} + +#[test] +fn newest_active_assistant_reply_comes_first_and_n_is_honoured() { + let dir = temp_dir("order"); + let db = dir.join("state.db"); + let _writer = populate(&db, false); + let messages = hermes::messages_for_session(&db, "s1", 25).expect("messages"); + let texts: Vec<&str> = messages.iter().map(|m| m.text.as_str()).collect(); + assert_eq!(texts, ["newest reply", "middle reply", "oldest reply", "first question"]); + assert_eq!(messages[0].role, Role::Assistant); + assert_eq!(messages[3].role, Role::Human); + assert_eq!(messages[0].at.as_deref(), Some("1970-01-01T00:05:00.000Z")); + assert_eq!(hermes::messages_for_session(&db, "s1", 2).expect("two").len(), 2); + std::fs::remove_dir_all(&dir).expect("cleanup"); +} + +#[test] +fn unknown_sessions_and_missing_databases_are_distinct_errors() { + let dir = temp_dir("errors"); + let db = dir.join("state.db"); + let _writer = populate(&db, false); + assert!(matches!(hermes::messages_for_session(&db, "s3", 5), Err(HostError::NoMessages(_)))); + assert!(matches!(hermes::messages_for_session(&db, "nope", 5), Err(HostError::NoMessages(_)))); + assert!(matches!( + hermes::messages_for_session(&dir.join("absent.db"), "s1", 5), + Err(HostError::NoTranscript(_)) + )); + std::fs::remove_dir_all(&dir).expect("cleanup"); +} + +#[test] +fn a_live_wal_database_is_read_without_being_touched() { + let dir = temp_dir("wal"); + let db = dir.join("state.db"); + // The writer stays open with rows still in its WAL, as a running Hermes would. + let writer = populate(&db, true); + let before = listing(&dir); + assert!(before.iter().any(|n| n.ends_with("-wal")), "writer holds a WAL: {before:?}"); + let messages = hermes::messages_for_session(&db, "s1", 1).expect("reads the live database"); + assert_eq!(messages[0].text, "newest reply", "rows still in the WAL are visible"); + assert_eq!(listing(&dir), before, "the reader created no journal or lock files"); + drop(writer); + std::fs::remove_dir_all(&dir).expect("cleanup"); +} + +fn listing(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("dir") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} From 9c7d3469ed4631fd56bd5479bb9b98b7f4b26d66 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 29 Aug 2026 06:04:11 -0700 Subject: [PATCH 3/4] feat(last): sniff transcript formats; session ids Host::Omp and Host::Hermes in the host table and detection chain (OMPCODE selects omp last, AI_AGENT=omp before pi's flag). A --session path given without a host is recognised by its first lines. --session-id addresses hosts without transcript files. README and decision 14 updated. refs #24 refs #25 --- README.md | 5 +- crates/plannotator-tui-hosts/src/lib.rs | 54 ++++++++++++- crates/plannotator-tui-hosts/tests/detect.rs | 26 ++++++- crates/plannotator-tui-hosts/tests/sniff.rs | 57 ++++++++++++++ crates/plannotator-tui/Cargo.toml | 3 + crates/plannotator-tui/src/cli.rs | 6 +- crates/plannotator-tui/src/last/locate.rs | 70 +++++++++++++++-- crates/plannotator-tui/src/last/mod.rs | 4 +- crates/plannotator-tui/tests/last.rs | 81 +++++++++++++++++++- docs/decisions.md | 15 ++++ 10 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 crates/plannotator-tui-hosts/tests/sniff.rs diff --git a/README.md b/README.md index 52f20f7..e416889 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ directory in this repo is the development manifest; users should install Herdr A ## Agent replies `plannotator-tui last` finds the transcript of the agent that launched your shell and shows a -picker of its recent replies. Hosts: Claude Code, Codex, pi, GitHub Copilot CLI, Droid. -`--host`, `--pid`, `--session ` override detection; `--stdin` reads a document; +picker of its recent replies. Hosts: Claude Code, Codex, pi, Oh My Pi, GitHub Copilot CLI, +Droid, Hermes CLI. `--host`, `--pid`, `--session ` (format sniffed when no host is +named) and `--session-id ` (Hermes) override detection; `--stdin` reads a document; `--print` writes the newest reply to stdout and always exits 0 (for hooks and scripts). Reply reviews are never written to disk. diff --git a/crates/plannotator-tui-hosts/src/lib.rs b/crates/plannotator-tui-hosts/src/lib.rs index 2206cf5..33f9c7a 100644 --- a/crates/plannotator-tui-hosts/src/lib.rs +++ b/crates/plannotator-tui-hosts/src/lib.rs @@ -9,7 +9,10 @@ pub mod claude; pub mod codex; pub mod copilot; pub mod droid; +pub mod hermes; +pub mod omp; pub mod pi; +pub(crate) mod time; use std::path::PathBuf; @@ -23,6 +26,10 @@ pub enum Host { /// Droid (Factory): `~/.factory/sessions//.jsonl`, Claude's shape, file order. Droid, Pi, + /// Oh My Pi: pi's format and layout under `~/.omp/agent/sessions`. + Omp, + /// Hermes CLI: conversations in `SQLite` (`~/.hermes/state.db`), addressed by session id. + Hermes, } impl Host { @@ -34,11 +41,40 @@ impl Host { Self::Copilot => "copilot", Self::Droid => "droid", Self::Pi => "pi", + Self::Omp => "omp", + Self::Hermes => "hermes", } } /// Every host with a transcript reader, for messages that list them. - pub const ALL: [Host; 5] = [Host::ClaudeCode, Host::Codex, Host::Pi, Host::Copilot, Host::Droid]; + pub const ALL: [Host; 7] = + [Host::ClaudeCode, Host::Codex, Host::Pi, Host::Omp, Host::Copilot, Host::Droid, Host::Hermes]; +} + +/// Which reader a transcript file wants, from its first lines. For a path handed to us +/// without a host name (Herdr's `agent_session`, a user's `--session`). +pub fn sniff(head: &str) -> Option { + // Hand-written or pretty-printed JSON puts a space after the colon; compact writers don't. + let compact = head.replace(": ", ":"); + let lines: Vec<&str> = compact.lines().filter(|l| !l.trim().is_empty()).take(50).collect(); + let any = |needle: &str| lines.iter().any(|l| l.contains(needle)); + if any(r#""type":"session""#) && (any(r#""parentId""#) || any(r#""version""#)) { + return Some(Host::Pi); + } + if any(r#""type":"assistant.message""#) || any(r#""type":"session.start""#) { + return Some(Host::Copilot); + } + if any(r#""type":"response_item""#) || any(r#""type":"event_msg""#) || any(r#""type":"session_meta""#) { + return Some(Host::Codex); + } + // Droid: Claude's message shape keyed by `id`/`parentId`, no pi session header. + if any(r#""parentId""#) && any(r#""type":"message""#) { + return Some(Host::Droid); + } + if any(r#""parentUuid""#) || any(r#""uuid""#) { + return Some(Host::ClaudeCode); + } + None } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -109,6 +145,8 @@ pub fn detect_host(env: impl Fn(&str) -> Option) -> Result return Ok(Host::Copilot), "droid" | "factory" => return Ok(Host::Droid), "pi" => return Ok(Host::Pi), + "omp" | "oh-my-pi" | "ohmypi" => return Ok(Host::Omp), + "hermes" | "hermes-cli" | "hermes_cli" => return Ok(Host::Hermes), _ => {} } } @@ -118,14 +156,24 @@ pub fn detect_host(env: impl Fn(&str) -> Option) -> Result PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn head(relative: &str) -> String { + let text = std::fs::read_to_string(fixtures().join(relative)).expect("fixture"); + text.chars().take(64 * 1024).collect() +} + +#[test] +fn each_fixture_format_is_recognised() { + assert_eq!(sniff(&head("claude-code.jsonl")), Some(Host::ClaudeCode)); + assert_eq!(sniff(&head("pi.jsonl")), Some(Host::Pi)); + assert_eq!( + sniff(&head( + "pi-sessions/--work-project--/2026-08-28T10-30-00-000Z_01a00000-0000-7000-8000-000000000002.jsonl" + )), + Some(Host::Pi) + ); + assert_eq!( + sniff(&head("copilot/session-state/aaaa1111-0000-4000-8000-000000000001/events.jsonl")), + Some(Host::Copilot) + ); + assert_eq!( + sniff(&head("droid/sessions/-Users-me-repo/be4202cc-4266-4e3b-b0f1-9324af19e4be.jsonl")), + Some(Host::Droid), + "droid logs: Claude's message shape keyed by id/parentId, read in file order" + ); + let codex = std::fs::read_dir(fixtures().join("codex/sessions/2026/08/28")) + .expect("codex day") + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|x| x == "jsonl")) + .expect("a codex rollout"); + let text = std::fs::read_to_string(codex).expect("rollout"); + assert_eq!(sniff(&text), Some(Host::Codex)); +} + +#[test] +fn spacing_after_colons_does_not_matter() { + assert_eq!(sniff(r#"{"type": "session_meta", "payload": {}}"#), Some(Host::Codex)); + assert_eq!(sniff(r#"{"uuid": "a", "parentUuid": null, "type": "user"}"#), Some(Host::ClaudeCode)); +} + +#[test] +fn unknown_text_is_not_guessed() { + assert_eq!(sniff("# just markdown\n\nhello\n"), None); + assert_eq!(sniff(""), None); +} diff --git a/crates/plannotator-tui/Cargo.toml b/crates/plannotator-tui/Cargo.toml index 8b331d1..fbb83ac 100644 --- a/crates/plannotator-tui/Cargo.toml +++ b/crates/plannotator-tui/Cargo.toml @@ -28,5 +28,8 @@ ratatui = "0.30" tui-markdown = { version = "0.3.9", default-features = false } tui-input = "0.15" +[dev-dependencies] +rusqlite = { version = "0.31.0", features = ["bundled"] } + [lints] workspace = true diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 594b863..734dfe6 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -33,7 +33,8 @@ const USAGE: &str = "usage: plannotator-tui herdr open [file.md | folder] [--placement overlay|split|popup] [--deliver-to ] plannotator-tui herdr last [--placement P] [--deliver-to ] plannotator-tui herdr pane - plannotator-tui last [--host claude|codex] [--pid N] [--session ] [--stdin] [--print] [--pick N]"; + plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes] [--pid N] [--session ] + [--session-id ] [--stdin] [--print] [--pick N]"; /// Width the document gets when nothing else is known: gutter + rail + gap subtracted. fn doc_width(cols: u16) -> usize { @@ -217,6 +218,9 @@ fn last_command(args: &[String]) -> Result<()> { "--session" => { options.session = Some(PathBuf::from(rest.next().context("--session needs a value")?)); } + "--session-id" => { + options.session_id = Some(rest.next().context("--session-id needs a value")?.clone()); + } "--stdin" => options.stdin = true, "--print" => options.print = true, "--pick" => options.pick = rest.next().context("--pick needs a value")?.parse()?, diff --git a/crates/plannotator-tui/src/last/locate.rs b/crates/plannotator-tui/src/last/locate.rs index 93049ce..0a20b24 100644 --- a/crates/plannotator-tui/src/last/locate.rs +++ b/crates/plannotator-tui/src/last/locate.rs @@ -5,7 +5,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; -use plannotator_tui_hosts::{Host, HostError, Message, Role, claude, codex, copilot, detect_host, droid, pi}; +use plannotator_tui_hosts::{ + Host, HostError, Message, Role, claude, codex, copilot, detect_host, droid, hermes, omp, pi, sniff, +}; use plannotator_tui_schema::{DocumentSource, Provenance}; use super::LastOptions; @@ -19,9 +21,15 @@ pub(crate) struct Located { } pub(crate) fn locate(options: &LastOptions) -> Result { - let host = host_for(options)?; + let session = options.session.as_deref().map(expand_home); + let host = match &session { + // A path without a host name: Herdr hands us the exact transcript for agents it + // integrates, whatever their format. Its first lines say which reader applies. + Some(path) if !host_named(options) => sniff(&head(path)?).map_or_else(|| host_for(options), Ok)?, + _ => host_for(options)?, + }; let pick = options.pick.max(1); - let (transcript, messages) = match (host, &options.session) { + let (transcript, messages) = match (host, &session) { (Host::ClaudeCode, Some(path)) => (path.clone(), claude_messages(path, pick)?), (Host::ClaudeCode, None) => { let path = find_claude_transcript(options.pid)?; @@ -49,10 +57,26 @@ pub(crate) fn locate(options: &LastOptions) -> Result { } (Host::Pi, Some(path)) => (path.clone(), pi_messages(path, pick)?), (Host::Pi, None) => { - let path = find_pi_transcript()?; + let path = find_pi_transcript(".pi/agent", "pi")?; let messages = pi_messages(&path, pick)?; (path, messages) } + (Host::Omp, Some(path)) => (path.clone(), omp_messages(path, pick)?), + (Host::Omp, None) => { + let path = find_pi_transcript(omp::DEFAULT_AGENT_DIR, "omp")?; + let messages = omp_messages(&path, pick)?; + (path, messages) + } + (Host::Hermes, _) => { + let Some(id) = options.session_id.as_deref().filter(|s| !s.trim().is_empty()) else { + bail!("hermes needs a session id (Herdr provides it; or pass --session-id)"); + }; + let db = std::env::var_os("HERMES_HOME") + .map_or_else(|| home().join(".hermes"), PathBuf::from) + .join(hermes::DB_FILE); + let messages = hermes::messages_for_session(&db, id, pick)?; + (db, messages) + } }; let messages: Vec = messages.into_iter().filter(|m| m.role == Role::Assistant).collect(); if messages.is_empty() { @@ -61,6 +85,30 @@ pub(crate) fn locate(options: &LastOptions) -> Result { Ok(Located { host, transcript, messages }) } +/// Was a host named explicitly, by flag or by the launcher? +fn host_named(options: &LastOptions) -> bool { + options.host.as_deref().is_some_and(|h| !h.trim().is_empty()) + || std::env::var("PLANNOTATOR_TUI_HOST").is_ok_and(|h| !h.trim().is_empty()) +} + +/// The first 64 KiB of a transcript, enough for `sniff`. +fn head(path: &Path) -> Result { + use std::io::Read as _; + let mut file = std::fs::File::open(path).with_context(|| format!("reading {}", path.display()))?; + let mut bytes = vec![0u8; 64 * 1024]; + let n = file.read(&mut bytes).with_context(|| format!("reading {}", path.display()))?; + bytes.truncate(n); + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// `~/x` as Herdr reports some session paths. +fn expand_home(path: &Path) -> PathBuf { + match path.to_str().and_then(|s| s.strip_prefix("~/")) { + Some(rest) => home().join(rest), + None => path.to_path_buf(), + } +} + fn host_for(options: &LastOptions) -> Result { let override_host = options.host.clone(); let lookup = |key: &str| match key { @@ -184,20 +232,26 @@ fn droid_messages(path: &Path, pick: usize) -> Result> { Ok(droid::parse_messages(&text, pick)) } -/// Pi has no pid registry either: the newest session for the agent's cwd. -fn find_pi_transcript() -> Result { +/// Pi and OMP have no pid registry: the newest session for the agent's cwd, under the +/// agent dir (`default_agent_dir` relative to `$HOME`; OMP reuses pi's override variables). +fn find_pi_transcript(default_agent_dir: &str, label: &str) -> Result { let sessions_dir = match std::env::var_os("PI_CODING_AGENT_SESSION_DIR") { Some(dir) => PathBuf::from(dir), None => std::env::var_os("PI_CODING_AGENT_DIR") - .map_or_else(|| home().join(".pi").join("agent"), PathBuf::from) + .map_or_else(|| home().join(default_agent_dir), PathBuf::from) .join("sessions"), }; let cwd = agent_cwd()?; pi::find_transcript(&sessions_dir, &cwd).ok_or_else(|| { - anyhow::anyhow!("no pi session for {} (looked in {})", cwd.display(), sessions_dir.display()) + anyhow::anyhow!("no {label} session for {} (looked in {})", cwd.display(), sessions_dir.display()) }) } +fn omp_messages(path: &Path, pick: usize) -> Result> { + let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + Ok(omp::parse_messages(&text, pick)) +} + fn pi_messages(path: &Path, pick: usize) -> Result> { let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; Ok(pi::parse_messages(&text, pick)) diff --git a/crates/plannotator-tui/src/last/mod.rs b/crates/plannotator-tui/src/last/mod.rs index addd2e7..c3e692c 100644 --- a/crates/plannotator-tui/src/last/mod.rs +++ b/crates/plannotator-tui/src/last/mod.rs @@ -24,8 +24,10 @@ pub(crate) struct LastOptions { pub(crate) host: Option, /// The agent process to start the transcript search from. pub(crate) pid: Option, - /// An explicit transcript; skips detection. + /// An explicit transcript; skips detection. Its format is sniffed when no host is named. pub(crate) session: Option, + /// A session id for hosts that keep conversations in a store rather than files (Hermes). + pub(crate) session_id: Option, /// Read the document from stdin instead of a transcript. pub(crate) stdin: bool, /// Print the newest message and exit instead of opening the UI. diff --git a/crates/plannotator-tui/tests/last.rs b/crates/plannotator-tui/tests/last.rs index 3928805..0bec162 100644 --- a/crates/plannotator-tui/tests/last.rs +++ b/crates/plannotator-tui/tests/last.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::process::Command; -use plannotator_tui_hosts::{Role, claude, codex, copilot, droid, pi}; +use plannotator_tui_hosts::{Role, claude, codex, copilot, droid, omp, pi}; fn bin() -> Command { Command::new(env!("CARGO_BIN_EXE_plannotator-tui")) @@ -124,3 +124,82 @@ fn print_writes_the_newest_assistant_message_of_a_pi_session() { assert!(out.status.success()); assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), expected.trim_end()); } + +fn newest_pi_reply() -> (PathBuf, String) { + let transcript = fixtures().join("pi.jsonl"); + let text = std::fs::read_to_string(&transcript).expect("fixture"); + let expected = omp::parse_messages(&text, 25) + .into_iter() + .find(|m| m.role == Role::Assistant) + .expect("fixture has an assistant message") + .text; + (transcript, expected) +} + +#[test] +fn omp_reads_pi_format_sessions() { + let (transcript, expected) = newest_pi_reply(); + let out = bin() + .args(["last", "--host", "omp", "--session"]) + .arg(&transcript) + .arg("--print") + .output() + .expect("runs"); + assert!(out.status.success()); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), expected.trim_end()); +} + +#[test] +fn a_session_path_without_a_host_is_sniffed() { + let (transcript, expected) = newest_pi_reply(); + let out = bin() + .env_remove("PLANNOTATOR_TUI_HOST") + .args(["last", "--session"]) + .arg(&transcript) + .arg("--print") + .output() + .expect("runs"); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim_end(), + expected.trim_end(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn hermes_reads_the_session_named_by_id_from_hermes_home() { + let home = std::env::temp_dir().join(format!("plannotator-tui-hermes-home-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).expect("home"); + let db = rusqlite::Connection::open(home.join("state.db")).expect("db"); + db.execute_batch( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT NOT NULL, started_at REAL NOT NULL); + CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, + content TEXT, timestamp REAL NOT NULL, active INTEGER NOT NULL DEFAULT 1, compacted INTEGER NOT NULL DEFAULT 0); + INSERT INTO sessions VALUES ('abc', 'cli', 1.0); + INSERT INTO messages (session_id, role, content, timestamp) VALUES ('abc', 'user', 'hi', 1.0); + INSERT INTO messages (session_id, role, content, timestamp) VALUES ('abc', 'assistant', 'older', 2.0); + INSERT INTO messages (session_id, role, content, timestamp) VALUES ('abc', 'assistant', 'the newest reply', 3.0);", + ) + .expect("rows"); + drop(db); + let out = bin() + .env("HERMES_HOME", &home) + .args(["last", "--host", "hermes", "--session-id", "abc", "--print"]) + .output() + .expect("runs"); + assert!(out.status.success()); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim_end(), + "the newest reply", + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let missing = + bin().env("HERMES_HOME", &home).args(["last", "--host", "hermes", "--print"]).output().expect("runs"); + assert!(missing.status.success(), "exit 0 is the contract"); + assert!(String::from_utf8_lossy(&missing.stderr).contains("needs a session id")); + std::fs::remove_dir_all(&home).expect("cleanup"); +} diff --git a/docs/decisions.md b/docs/decisions.md index afe9dd8..8d97c9f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -305,3 +305,18 @@ no pid registry, so a running pi is matched by cwd (the Herdr launcher passes th pane's cwd as `PLANNOTATOR_TUI_CWD`), newest first, skipping sessions that hold no message yet. pi exports `PI_CODING_AGENT=true` and `AI_AGENT=pi` into the shells it spawns; both select the pi host after the Codex marker. + +**Oh My Pi and Hermes CLI** (2026-08-29, plannotator-tui#24, #25). OMP is a pi harness: same +entry format, same encoded-cwd layout, rooted at `~/.omp/agent/sessions`, and it reuses pi's +`PI_CODING_AGENT_SESSION_DIR` / `PI_CODING_AGENT_DIR` overrides (`oh-my-pi/packages/coding-agent/src/cli/args.ts`), +so `omp` is pi's reader over another root. Its `OMPCODE` marker selects it last of all the +markers, as Plannotator orders them, and `AI_AGENT=omp` first, before pi's own flag which OMP +also sets. Hermes CLI has no transcript files: conversations are rows in SQLite +(`~/.hermes/state.db`, `HERMES_HOME` override, WAL mode), addressed by the session id Herdr +reports. The reader opens the database read-only (`mode=ro`, so the live WAL is visible), +falls back to `immutable=1` only when the shared-memory index cannot be mapped (a stale read +beats touching a running agent's store), and issues one query over +`idx_messages_session_active`, newest first. Both arrive from Herdr through `agent_session` +(`kind: path | id`) rather than host+pid discovery; a transcript path handed over without a +host name is recognised by its first lines (`sniff`), so any Herdr-integrated agent that +writes one of the known formats works without a host table entry. From a48248ad555cc8a32020e537d9602a334467ff87 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Sat, 29 Aug 2026 06:05:07 -0700 Subject: [PATCH 4/4] feat(herdr): pane entrypoint passes the session id to last refs #25 --- crates/plannotator-tui/src/cli.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 3c50ad8..a78104b 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -189,6 +189,7 @@ fn herdr_pane() -> Result<()> { host: env.host.clone(), pid: Some(pid), session: env.session.clone(), + session_id: env.session_id.clone(), pick: 25, ..crate::last::LastOptions::default() })