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
91 changes: 91 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <transcript>` 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 <transcript>` (format sniffed when no host is
named) and `--session-id <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.

Expand Down
1 change: 1 addition & 0 deletions crates/plannotator-tui-hosts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
86 changes: 86 additions & 0 deletions crates/plannotator-tui-hosts/src/hermes.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Message>, 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<String> = row.get(2)?;
let timestamp: Option<f64> = 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<Connection, HostError> {
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}"))
}
54 changes: 51 additions & 3 deletions crates/plannotator-tui-hosts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -23,6 +26,10 @@ pub enum Host {
/// Droid (Factory): `~/.factory/sessions/<slug>/<session>.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 {
Expand All @@ -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<Host> {
// 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)]
Expand Down Expand Up @@ -109,6 +145,8 @@ pub fn detect_host(env: impl Fn(&str) -> Option<String>) -> Result<Host, HostErr
"copilot" | "copilot-cli" | "copilot_cli" => 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),
_ => {}
}
}
Expand All @@ -118,14 +156,24 @@ pub fn detect_host(env: impl Fn(&str) -> Option<String>) -> Result<Host, HostErr
if set("COPILOT_CLI") {
return Ok(Host::Copilot);
}
let ai_agent = env("AI_AGENT").map(|v| v.trim().to_ascii_lowercase());
// Oh My Pi is a pi harness: it reuses pi's `PI_CODING_AGENT` flag, so its own name must
// be checked before pi's flag. `OMPCODE` is exported into every shell OMP spawns, which is
// why Plannotator checks it last of all the markers.
if ai_agent.as_deref() == Some("omp") {
return Ok(Host::Omp);
}
// pi exports both: the generic marker names the agent, the specific one is a flag.
if env("AI_AGENT").is_some_and(|v| v.trim().eq_ignore_ascii_case("pi")) || set("PI_CODING_AGENT") {
if ai_agent.as_deref() == Some("pi") || set("PI_CODING_AGENT") {
return Ok(Host::Pi);
}
for (key, name) in [("OPENCODE", "OpenCode"), ("GEMINI_CLI", "Gemini CLI"), ("OMPCODE", "OMP")] {
for (key, name) in [("OPENCODE", "OpenCode"), ("GEMINI_CLI", "Gemini CLI")] {
if set(key) {
return Err(HostError::Unsupported(name.to_owned()));
}
}
if set("OMPCODE") {
return Ok(Host::Omp);
}
Ok(Host::ClaudeCode)
}
Loading
Loading