diff --git a/README.md b/README.md index 0e5af46..8976e44 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ An explicit `--placement` or `PLANNOTATOR_TUI_PLACEMENT` still takes precedence. `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, Oh My Pi, GitHub Copilot CLI, -Droid, Hermes CLI, OpenCode (1 and 2), Command Code (`cmd`). `--host`, `--pid`, `--session ` (format sniffed when +Droid, Hermes CLI, OpenCode (1 and 2), Command Code (`cmd`), Antigravity CLI (`agy`). `--host`, `--pid`, `--session ` (format sniffed when no host is named) and `--session-id ` (Hermes, OpenCode) override detection; `--stdin` reads a document; `--newest` skips the picker and opens the newest reply straight away, with `p` still opening the picker on the rest; diff --git a/crates/plannotator-tui-hosts/src/antigravity.rs b/crates/plannotator-tui-hosts/src/antigravity.rs new file mode 100644 index 0000000..4ae81a0 --- /dev/null +++ b/crates/plannotator-tui-hosts/src/antigravity.rs @@ -0,0 +1,224 @@ +//! Antigravity CLI (`agy`): one directory per conversation under the app data directory +//! (`$ANTIGRAVITY_CLI_HOME`, default `~/.gemini/antigravity-cli`), its steps in +//! `brain//.system_generated/logs/transcript_full.jsonl`. +//! +//! Each line is one step: `USER_INPUT` steps carry the prompt in `content`, `PLANNER_RESPONSE` +//! steps carry the reply in `content` (a step that only calls tools has none), and every other +//! type is a tool result or bookkeeping. `transcript.jsonl` next to it truncates long +//! `content`, so it is never read. +//! +//! A transcript does not record its folder. Two files do: `conversation_summaries.db` +//! (`SQLite`, `workspace_uris` as a JSON array of `file://` URIs per conversation) and +//! `history.jsonl` (one line per prompt, with `workspace` and, after the first prompt, the +//! `conversationId`). A conversation counts for a cwd only when one of them says so, so a +//! missing or unreadable database narrows the choice to what the history proves and never +//! widens it to the newest transcript of some other folder. + +use std::io::BufRead as _; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::sqlite::{open_read_only, sql_error}; +use crate::{HostError, Message, Role}; + +/// The app data directory relative to `$HOME` (`%USERPROFILE%` on Windows). +pub const DEFAULT_APP_DATA_DIR: &str = ".gemini/antigravity-cli"; +/// Conversation id → workspace URIs, relative to the app data directory. +pub const SUMMARIES_DB: &str = "conversation_summaries.db"; +/// One line per prompt, relative to the app data directory. +pub const HISTORY_FILE: &str = "history.jsonl"; + +const WHAT: &str = "Antigravity"; + +/// The untruncated transcript of `conversation_id`. +pub fn transcript_path(app_data: &Path, conversation_id: &str) -> PathBuf { + app_data + .join("brain") + .join(conversation_id) + .join(".system_generated") + .join("logs") + .join("transcript_full.jsonl") +} + +/// The transcript of the conversation Herdr (or `--session-id`) named, when it exists. +pub fn find_transcript_by_id(app_data: &Path, conversation_id: &str) -> Result, HostError> { + let id = crate::validate_session_id(conversation_id)?; + let path = transcript_path(app_data, id); + Ok(path.is_file().then_some(path)) +} + +/// The conversation id a transcript path carries: the `brain/` directory above +/// `.system_generated/logs/`. +pub fn conversation_of(transcript: &Path) -> Option { + let logs = transcript.parent()?; + let generated = logs.parent()?; + (logs.file_name()? == "logs" && generated.file_name()? == ".system_generated") + .then(|| generated.parent()?.file_name()?.to_str().map(str::to_owned)) + .flatten() +} + +/// The conversation for `cwd` with the newest transcript that holds a message: one whose +/// workspace is `cwd`, else one whose workspace contains it. Workspaces come from the summaries +/// database and the prompt history together; when the database cannot be read the error says +/// so, and the history alone decides. +pub fn find_transcript(app_data: &Path, cwd: &Path) -> Result { + let wanted = normalize(&cwd.to_string_lossy()); + let db = app_data.join(SUMMARIES_DB); + let history = app_data.join(HISTORY_FILE); + let (mut workspaces, db_error) = match summaries(&db) { + Ok(rows) => (rows, None), + Err(err) => (Vec::new(), Some(err)), + }; + workspaces.extend(history_workspaces(&history)); + let newest = |matches: &dyn Fn(&str) -> bool| { + let ids = workspaces.iter().filter(|(_, workspace)| matches(workspace)).map(|(id, _)| id.as_str()); + newest_with_messages(app_data, ids) + }; + if let Some(found) = + newest(&|workspace| workspace == wanted).or_else(|| newest(&|w| is_ancestor(w, &wanted))) + { + return Ok(found); + } + let unread = db_error.map(|err| format!("; {err}")).unwrap_or_default(); + Err(HostError::NoTranscript(format!( + "no Antigravity conversation started in {} (looked in {} and {}{unread})", + cwd.display(), + db.display(), + history.display() + ))) +} + +/// `(conversation id, workspace)` for every top-level conversation in the summaries database. +/// Subagent conversations (a non-empty `parent_conversation_id`, when the column exists) are +/// left out: their replies are their parent's tool results, not the agent's. +fn summaries(db: &Path) -> Result, HostError> { + let connection = open_read_only(db, WHAT)?; + let has_parent: bool = connection + .query_row( + "SELECT count(*) FROM pragma_table_info('conversation_summaries') \ + WHERE name = 'parent_conversation_id'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| sql_error(WHAT, &e))? + > 0; + let filter = if has_parent { " WHERE coalesce(parent_conversation_id, '') = ''" } else { "" }; + let mut statement = connection + .prepare(&format!("SELECT conversation_id, workspace_uris FROM conversation_summaries{filter}")) + .map_err(|e| sql_error(WHAT, &e))?; + let rows = statement + .query_map([], |row| Ok((row.get::<_, Option>(0)?, row.get::<_, Option>(1)?))) + .map_err(|e| sql_error(WHAT, &e))?; + let mut out = Vec::new(); + for row in rows { + let (Some(id), Some(uris)) = row.map_err(|e| sql_error(WHAT, &e))? else { continue }; + let Ok(Value::Array(uris)) = serde_json::from_str::(&uris) else { continue }; + for uri in uris.iter().filter_map(Value::as_str).filter_map(path_of_file_uri) { + out.push((id.clone(), normalize(&uri))); + } + } + Ok(out) +} + +/// `(conversation id, workspace)` for every history line that names both. +fn history_workspaces(history: &Path) -> Vec<(String, String)> { + let Ok(file) = std::fs::File::open(history) else { return Vec::new() }; + std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .filter_map(|line| { + let entry: Value = serde_json::from_str(line.trim()).ok()?; + let id = entry.get("conversationId")?.as_str()?; + let workspace = entry.get("workspace")?.as_str()?; + Some((id.to_owned(), normalize(workspace))) + }) + .collect() +} + +/// The last-written transcript among `ids` that holds at least one message. Ids that are not +/// one safe path component are skipped, whatever wrote them. +fn newest_with_messages<'a>(app_data: &Path, ids: impl Iterator) -> Option { + let mut ranked: Vec<(Option, PathBuf)> = ids + .filter(|id| crate::validate_session_id(id).is_ok()) + .map(|id| transcript_path(app_data, id)) + .filter(|path| path.is_file()) + .map(|path| (std::fs::metadata(&path).and_then(|data| data.modified()).ok(), path)) + .collect(); + ranked.sort_by(|(a_time, a), (b_time, b)| b_time.cmp(a_time).then_with(|| a.cmp(b))); + ranked.dedup_by(|(_, a), (_, b)| a == b); + ranked + .into_iter() + .map(|(_, path)| path) + .find(|path| std::fs::read_to_string(path).is_ok_and(|text| !parse_messages(&text, 1).is_empty())) +} + +/// The newest `n` prompts and replies, newest first. A reply is a `PLANNER_RESPONSE` step's +/// `content` as written; a prompt is a `USER_INPUT` step's `content`, wrappers and all (only +/// replies are shown). A line that does not parse, such as one still being written, is +/// skipped. +pub fn parse_messages(jsonl: &str, n: usize) -> Vec { + jsonl.lines().rev().filter_map(step_message).take(n).collect() +} + +fn step_message(line: &str) -> Option { + let step: Value = serde_json::from_str(line.trim()).ok()?; + let role = match step.get("type")?.as_str()? { + "PLANNER_RESPONSE" => Role::Assistant, + "USER_INPUT" => Role::Human, + _ => return None, + }; + let text = step.get("content")?.as_str().filter(|text| !text.trim().is_empty())?; + Some(Message { + id: step.get("step_index").and_then(Value::as_u64).map(|index| index.to_string()).unwrap_or_default(), + role, + text: text.to_owned(), + at: step.get("created_at").and_then(Value::as_str).map(str::to_owned), + }) +} + +/// The local path of a `file://` URI, percent-decoded: `file:///w/a%20b` → `/w/a b`, and the +/// Windows forms `file:///C:/w` and `file:///c%3A/w` → `C:/w` and `c:/w`. Other schemes and +/// remote hosts are not local paths. +pub fn path_of_file_uri(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let slash = rest.find('/')?; + if !matches!(rest.get(..slash)?, "" | "localhost") { + return None; + } + let path = percent_decode(rest.get(slash..)?); + match path.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => path.get(1..).map(str::to_owned), + _ => Some(path), + } +} + +fn percent_decode(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while let Some(&byte) = bytes.get(i) { + let escaped = (byte == b'%') + .then(|| text.get(i + 1..i + 3)) + .flatten() + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + if let Some(decoded) = escaped { + out.push(decoded); + i += 3; + } else { + out.push(byte); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Paths compare without trailing separators; on Windows also up to case and slash direction. +fn normalize(path: &str) -> String { + let trimmed = path.trim_end_matches(['/', '\\']); + if cfg!(windows) { trimmed.replace('\\', "/").to_lowercase() } else { trimmed.to_owned() } +} + +fn is_ancestor(workspace: &str, cwd: &str) -> bool { + !workspace.is_empty() && cwd.starts_with(workspace) && cwd.as_bytes().get(workspace.len()) == Some(&b'/') +} diff --git a/crates/plannotator-tui-hosts/src/lib.rs b/crates/plannotator-tui-hosts/src/lib.rs index 797fcdd..9f01635 100644 --- a/crates/plannotator-tui-hosts/src/lib.rs +++ b/crates/plannotator-tui-hosts/src/lib.rs @@ -5,6 +5,7 @@ //! `projects_dir`, a process-table snapshot, and an env lookup. The rules come from //! Plannotator's `last` implementation (see `docs/decisions.md`, decision 9). +pub mod antigravity; pub mod claude; pub mod codex; pub mod commandcode; @@ -39,6 +40,10 @@ pub enum Host { /// Command Code: pi's format under `~/.commandcode/projects//.jsonl`; Herdr /// reports it as `cmd`. CommandCode, + /// Antigravity CLI: `/brain//.../transcript_full.jsonl`, the + /// app data directory `~/.gemini/antigravity-cli` unless `ANTIGRAVITY_CLI_HOME` moves it; + /// Herdr reports it as `agy` with the conversation id. + Antigravity, } impl Host { @@ -54,11 +59,12 @@ impl Host { Self::Hermes => "hermes", Self::OpenCode => "opencode", Self::CommandCode => "cmd", + Self::Antigravity => "agy", } } /// Every host with a transcript reader, for messages that list them. - pub const ALL: [Host; 9] = [ + pub const ALL: [Host; 10] = [ Host::ClaudeCode, Host::Codex, Host::Pi, @@ -68,6 +74,7 @@ impl Host { Host::Hermes, Host::OpenCode, Host::CommandCode, + Host::Antigravity, ]; } @@ -84,6 +91,10 @@ pub fn sniff(head: &str) -> Option { if any(r#""type":"assistant.message""#) || any(r#""type":"session.start""#) { return Some(Host::Copilot); } + // Antigravity: numbered steps, prompts and replies typed in capitals. + if any(r#""step_index""#) && (any(r#""type":"USER_INPUT""#) || any(r#""type":"PLANNER_RESPONSE""#)) { + return Some(Host::Antigravity); + } if any(r#""type":"response_item""#) || any(r#""type":"event_msg""#) || any(r#""type":"session_meta""#) { return Some(Host::Codex); } @@ -178,15 +189,17 @@ impl From for HostError { } /// The host-assigned session id a transcript's name carries, when the host's naming -/// scheme makes it unambiguous: Claude Code, Droid and Command Code file a session as `.jsonl`, -/// a Codex rollout ends in its thread uuid, and a Copilot session is a directory named by -/// its uuid. Anything that is not uuid-shaped yields `None`: an arbitrary path handed in -/// with `--session` must never be mistaken for an id, and a path is never one. +/// scheme makes it unambiguous: Claude Code, Droid and Command Code file a session as +/// `.jsonl`, a Codex rollout ends in its thread uuid, a Copilot session is a directory +/// named by its uuid, and an Antigravity transcript sits under `brain//`. Anything that +/// is not uuid-shaped yields `None`: an arbitrary path handed in with `--session` must never +/// be mistaken for an id, and a path is never one. pub fn session_id_of(host: Host, transcript: &Path) -> Option { let candidate = match host { Host::ClaudeCode | Host::Droid | Host::CommandCode => transcript.file_stem()?.to_str()?.to_owned(), Host::Codex => codex::thread_of(transcript)?, Host::Copilot => transcript.file_name()?.to_str()?.to_owned(), + Host::Antigravity => antigravity::conversation_of(transcript)?, Host::Pi | Host::Omp | Host::Hermes | Host::OpenCode => return None, }; is_uuid(&candidate).then_some(candidate) @@ -220,6 +233,7 @@ pub fn detect_host(env: impl Fn(&str) -> Option) -> Result return Ok(Host::Hermes), "opencode" | "open-code" | "open_code" => return Ok(Host::OpenCode), "cmd" | "command-code" | "command_code" | "commandcode" => return Ok(Host::CommandCode), + "agy" | "antigravity" | "antigravity-cli" | "antigravity_cli" => return Ok(Host::Antigravity), _ => {} } } diff --git a/crates/plannotator-tui-hosts/tests/antigravity.rs b/crates/plannotator-tui-hosts/tests/antigravity.rs new file mode 100644 index 0000000..66454d2 --- /dev/null +++ b/crates/plannotator-tui-hosts/tests/antigravity.rs @@ -0,0 +1,256 @@ +//! Antigravity CLI: `brain//.system_generated/logs/transcript_full.jsonl`, matched to a cwd +//! through `conversation_summaries.db` and `history.jsonl`. + +#![allow(clippy::expect_used, reason = "tests assert by panicking")] + +use std::path::{Path, PathBuf}; + +use plannotator_tui_hosts::{Host, HostError, Role, antigravity, session_id_of, sniff}; +use rusqlite::{Connection, params}; + +const FIXTURE_ID: &str = "4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c"; +const OLD: &str = "11111111-1111-4111-8111-111111111111"; +const NEW: &str = "22222222-2222-4222-8222-222222222222"; +const OTHER: &str = "33333333-3333-4333-8333-333333333333"; +const CHILD: &str = "44444444-4444-4444-8444-444444444444"; + +/// The columns this reader touches, as `agy` 1.2.7 declares them. +const SCHEMA: &str = "CREATE TABLE `conversation_summaries` (`conversation_id` text, \ + `last_modified_time` datetime NOT NULL, `workspace_uris` text NOT NULL, \ + `parent_conversation_id` text NOT NULL DEFAULT \"\", PRIMARY KEY (`conversation_id`))"; + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/antigravity") +} + +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("plannotator-tui-agy-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +/// A conversation whose one reply says `reply` (none when empty), last written at `modified`. +fn conversation(app: &Path, id: &str, reply: &str, modified: u64) -> PathBuf { + let path = antigravity::transcript_path(app, id); + std::fs::create_dir_all(path.parent().expect("logs")).expect("logs dir"); + let prompt = r#"{"step_index":0,"type":"USER_INPUT","content":"\nhi\n"}"#; + let text = if reply.is_empty() { + format!("{prompt}\n") + } else { + format!("{prompt}\n{{\"step_index\":1,\"type\":\"PLANNER_RESPONSE\",\"content\":\"{reply}\"}}\n") + }; + std::fs::write(&path, text).expect("transcript"); + let at = std::time::UNIX_EPOCH + std::time::Duration::from_secs(modified); + std::fs::File::options().write(true).open(&path).expect("open").set_modified(at).expect("mtime"); + path +} + +fn summaries(app: &Path, rows: &[(&str, &str, &str)]) { + let c = Connection::open(app.join(antigravity::SUMMARIES_DB)).expect("db"); + c.execute(SCHEMA, []).expect("schema"); + for (id, uris, parent) in rows { + c.execute( + "INSERT INTO conversation_summaries (conversation_id, last_modified_time, workspace_uris, \ + parent_conversation_id) VALUES (?1, '2026-10-21 06:00:00+00:00', ?2, ?3)", + params![id, uris, parent], + ) + .expect("row"); + } +} + +fn reply(path: &Path) -> String { + let text = std::fs::read_to_string(path).expect("transcript"); + antigravity::parse_messages(&text, 25) + .into_iter() + .find(|m| m.role == Role::Assistant) + .expect("a reply") + .text +} + +#[test] +fn replies_are_planner_responses_with_content_newest_first() { + let path = antigravity::transcript_path(&fixtures(), FIXTURE_ID); + let text = std::fs::read_to_string(&path).expect("fixture"); + let messages = antigravity::parse_messages(&text, 25); + let replies: Vec<(&str, &str)> = messages + .iter() + .filter(|m| m.role == Role::Assistant) + .map(|m| (m.id.as_str(), m.text.as_str())) + .collect(); + assert_eq!( + replies, + [ + ("7", "Done: both settings are applied in `retroarch.cfg`."), + ( + "3", + "Here is the recommended configuration update:\n\n### Changes\n- Set `video_threaded = \"false\"`\n- Set `video_vsync = \"true\"`" + ), + ], + "tool-call-only steps and tool results are not replies" + ); + let prompts = messages.iter().filter(|m| m.role == Role::Human).count(); + assert_eq!(prompts, 2); + assert_eq!(messages.first().and_then(|m| m.at.as_deref()), Some("2026-10-21T06:01:15Z")); + assert_eq!(antigravity::parse_messages(&text, 1).len(), 1, "pick caps the count"); +} + +#[test] +fn a_half_written_last_line_is_skipped() { + let text = "{\"step_index\":1,\"type\":\"PLANNER_RESPONSE\",\"content\":\"whole\"}\n{\"step_index\":2,\"type\":\"PLANNER_RESPONSE\",\"cont"; + let messages = antigravity::parse_messages(text, 25); + assert_eq!(messages.iter().map(|m| m.text.as_str()).collect::>(), ["whole"]); +} + +#[test] +fn an_id_names_its_transcript_directly() { + let found = antigravity::find_transcript_by_id(&fixtures(), FIXTURE_ID).expect("valid id"); + assert_eq!(found, Some(antigravity::transcript_path(&fixtures(), FIXTURE_ID))); + assert_eq!(antigravity::find_transcript_by_id(&fixtures(), NEW).expect("valid id"), None); + assert!(matches!( + antigravity::find_transcript_by_id(&fixtures(), "../brain"), + Err(HostError::InvalidSessionId(_)) + )); +} + +#[test] +fn the_transcript_path_carries_the_conversation_id_and_its_format_is_sniffed() { + let path = antigravity::transcript_path(&fixtures(), FIXTURE_ID); + assert_eq!(session_id_of(Host::Antigravity, &path).as_deref(), Some(FIXTURE_ID)); + let elsewhere = Path::new("/tmp").join(FIXTURE_ID).join("transcript_full.jsonl"); + assert_eq!(session_id_of(Host::Antigravity, &elsewhere), None, "only brain//.system_generated/logs"); + let head = std::fs::read_to_string(&path).expect("fixture"); + assert_eq!(sniff(&head), Some(Host::Antigravity)); +} + +#[test] +fn the_database_picks_the_newest_conversation_started_in_the_cwd() { + let app = temp_dir("db"); + conversation(&app, OLD, "old", 1_000); + conversation(&app, NEW, "new", 2_000); + conversation(&app, OTHER, "other folder", 9_000); + conversation(&app, CHILD, "subagent", 9_500); + summaries( + &app, + &[ + (OLD, r#"["file:///w/my%20proj"]"#, ""), + (NEW, r#"["file:///w/elsewhere","file:///w/my%20proj/"]"#, ""), + (OTHER, r#"["file:///w/other"]"#, ""), + (CHILD, r#"["file:///w/my%20proj"]"#, NEW), + ], + ); + let found = antigravity::find_transcript(&app, Path::new("/w/my proj")).expect("found"); + assert_eq!(reply(&found), "new", "newest by last write; subagents and other folders never"); + let inside = antigravity::find_transcript(&app, Path::new("/w/other/src")).expect("found"); + assert_eq!(reply(&inside), "other folder", "a workspace that contains the cwd counts"); + let err = antigravity::find_transcript(&app, Path::new("/w/none")).expect_err("no conversation"); + assert!(err.to_string().contains("no Antigravity conversation started in /w/none"), "{err}"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + +#[test] +fn an_exact_workspace_beats_a_newer_one_that_merely_contains_the_cwd() { + let app = temp_dir("tiers"); + conversation(&app, OLD, "exact", 1_000); + conversation(&app, NEW, "parent", 2_000); + summaries(&app, &[(OLD, r#"["file:///w/repo/sub"]"#, ""), (NEW, r#"["file:///w/repo"]"#, "")]); + let found = antigravity::find_transcript(&app, Path::new("/w/repo/sub")).expect("found"); + assert_eq!(reply(&found), "exact"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + +#[test] +fn a_conversation_without_messages_is_passed_over() { + let app = temp_dir("empty"); + conversation(&app, OLD, "older reply", 1_000); + let empty = antigravity::transcript_path(&app, NEW); + std::fs::create_dir_all(empty.parent().expect("logs")).expect("logs dir"); + std::fs::write(&empty, "").expect("empty transcript"); + summaries(&app, &[(OLD, r#"["file:///w/p"]"#, ""), (NEW, r#"["file:///w/p"]"#, "")]); + let found = antigravity::find_transcript(&app, Path::new("/w/p")).expect("found"); + assert_eq!(reply(&found), "older reply"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + +#[test] +fn without_a_readable_database_only_the_history_can_vouch_for_a_folder() { + let app = temp_dir("history"); + conversation(&app, OLD, "verified", 1_000); + conversation(&app, NEW, "unverified but newer", 2_000); + std::fs::write(app.join(antigravity::SUMMARIES_DB), "not a database").expect("garbage db"); + std::fs::write( + app.join(antigravity::HISTORY_FILE), + format!( + "{{\"display\":\"first prompt\",\"timestamp\":1,\"workspace\":\"/w/p\"}}\n\ + {{\"display\":\"second\",\"timestamp\":2,\"workspace\":\"/w/p\",\"conversationId\":\"{OLD}\"}}\n\ + {{\"display\":\"x\",\"timestamp\":3,\"workspace\":\"/w/q\",\"conversationId\":\"{NEW}\"}}\n" + ), + ) + .expect("history"); + let found = antigravity::find_transcript(&app, Path::new("/w/p")).expect("found"); + assert_eq!(reply(&found), "verified"); + std::fs::remove_file(app.join(antigravity::HISTORY_FILE)).expect("drop history"); + let err = antigravity::find_transcript(&app, Path::new("/w/p")).expect_err("nothing vouches"); + let message = err.to_string(); + assert!(message.contains("no Antigravity conversation started in /w/p"), "{message}"); + assert!(message.contains("reading the Antigravity database"), "the unread database is named: {message}"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + +#[test] +fn a_database_without_the_subagent_column_still_matches() { + let app = temp_dir("legacy"); + conversation(&app, OLD, "legacy", 1_000); + let c = Connection::open(app.join(antigravity::SUMMARIES_DB)).expect("db"); + c.execute( + "CREATE TABLE conversation_summaries (conversation_id text, workspace_uris text, last_modified_time datetime)", + [], + ) + .expect("schema"); + c.execute( + "INSERT INTO conversation_summaries VALUES (?1, '[\"file:///w/p\"]', '2026-10-21 06:00:00+00:00')", + params![OLD], + ) + .expect("row"); + drop(c); // Windows cannot remove a database file that is still open. + let found = antigravity::find_transcript(&app, Path::new("/w/p")).expect("found"); + assert_eq!(reply(&found), "legacy"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + +#[test] +fn ids_from_the_database_are_never_joined_as_paths_unchecked() { + let app = temp_dir("traversal"); + conversation(&app, OLD, "real", 1_000); + summaries(&app, &[("../../etc", r#"["file:///w/p"]"#, ""), (OLD, r#"["file:///w/p"]"#, "")]); + let found = antigravity::find_transcript(&app, Path::new("/w/p")).expect("found"); + assert_eq!(reply(&found), "real"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + +#[test] +fn file_uris_become_local_paths() { + let cases = [ + ("file:///Users/me/my%20proj", Some("/Users/me/my proj")), + ("file://localhost/w/p", Some("/w/p")), + ("file:///C:/Users/Me/Proj", Some("C:/Users/Me/Proj")), + ("file:///c%3A/Users/Me/Proj", Some("c:/Users/Me/Proj")), + ("file:///w/%E3%83%97%E3%83%AD", Some("/w/プロ")), + ("file://server/share/p", None), + ("vscode-remote://ssh-remote+box/w/p", None), + ]; + for (uri, path) in cases { + assert_eq!(antigravity::path_of_file_uri(uri).as_deref(), path, "{uri}"); + } +} + +#[cfg(windows)] +#[test] +fn on_windows_a_workspace_matches_the_cwd_up_to_case_and_slashes() { + let app = temp_dir("windows"); + conversation(&app, OLD, "windows", 1_000); + summaries(&app, &[(OLD, r#"["file:///c%3A/Users/Me/Proj"]"#, "")]); + let found = antigravity::find_transcript(&app, Path::new(r"C:\Users\me\proj\")).expect("found"); + assert_eq!(reply(&found), "windows"); + std::fs::remove_dir_all(&app).expect("cleanup"); +} diff --git a/crates/plannotator-tui-hosts/tests/detect.rs b/crates/plannotator-tui-hosts/tests/detect.rs index b119a99..9324c04 100644 --- a/crates/plannotator-tui-hosts/tests/detect.rs +++ b/crates/plannotator-tui-hosts/tests/detect.rs @@ -64,6 +64,13 @@ fn command_code_is_selected_by_herdrs_label_or_its_names_only() { assert_eq!(detect_host(env(&[("PLANNOTATOR_TUI_HOST", "node")])).expect("host"), Host::ClaudeCode); } +#[test] +fn antigravity_is_selected_by_herdrs_label_or_its_names() { + for name in ["agy", "Antigravity", "antigravity-cli", "antigravity_cli"] { + assert_eq!(detect_host(env(&[("PLANNOTATOR_TUI_HOST", name)])).expect("host"), Host::Antigravity); + } +} + #[test] fn the_copilot_marker_selects_copilot_and_droid_needs_the_override() { assert_eq!(detect_host(env(&[("COPILOT_CLI", "1")])).expect("host"), Host::Copilot); @@ -86,5 +93,6 @@ fn labels_are_the_short_names_herdr_uses() { assert_eq!(Host::Hermes.label(), "hermes"); assert_eq!(Host::OpenCode.label(), "opencode"); assert_eq!(Host::CommandCode.label(), "cmd"); - assert_eq!(Host::ALL.len(), 9); + assert_eq!(Host::Antigravity.label(), "agy"); + assert_eq!(Host::ALL.len(), 10); } diff --git a/crates/plannotator-tui-hosts/tests/fixtures/antigravity/brain/4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c/.system_generated/logs/transcript_full.jsonl b/crates/plannotator-tui-hosts/tests/fixtures/antigravity/brain/4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c/.system_generated/logs/transcript_full.jsonl new file mode 100644 index 0000000..5ae8b00 --- /dev/null +++ b/crates/plannotator-tui-hosts/tests/fixtures/antigravity/brain/4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c/.system_generated/logs/transcript_full.jsonl @@ -0,0 +1,8 @@ +{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-10-21T06:00:34Z","content":"\nFix the video settings in retroarch.cfg.\n\n\nThe current local time is: 2026-10-21T15:00:34+09:00.\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-10-21T06:00:36Z","input_tokens":10372,"cache_read_tokens":0,"output_tokens":114,"thinking":"Let's inspect retroarch.cfg first.","tool_calls":[{"name":"read_file","args":{"Path":"\"/home/user/proj/retroarch.cfg\""}}]} +{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","created_at":"2026-10-21T06:00:38Z","content":"Created At: 2026-10-21T15:00:38+09:00\nCompleted At: 2026-10-21T15:00:39+09:00\nShowing lines 1-20 of 20\n..."} +{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-10-21T06:00:45Z","input_tokens":4978,"cache_read_tokens":10373,"output_tokens":320,"content":"Here is the recommended configuration update:\n\n### Changes\n- Set `video_threaded = \"false\"`\n- Set `video_vsync = \"true\"`"} +{"step_index":4,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-10-21T06:01:10Z","content":"\nApply it.\n\n\nThe current local time is: 2026-10-21T15:01:10+09:00.\n"} +{"step_index":5,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-10-21T06:01:12Z","thinking":"Edit the two keys.","tool_calls":[{"name":"replace_file_content","args":{"TargetFile":"\"/home/user/proj/retroarch.cfg\""}}]} +{"step_index":6,"source":"MODEL","type":"CODE_ACTION","status":"DONE","created_at":"2026-10-21T06:01:13Z","content":"Edited /home/user/proj/retroarch.cfg"} +{"step_index":7,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-10-21T06:01:15Z","content":"Done: both settings are applied in `retroarch.cfg`."} diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 4d565a3..232fbf9 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -37,7 +37,7 @@ const USAGE: &str = "usage: plannotator-tui herdr last [--placement P] [--deliver-to ] [--newest] plannotator-tui herdr terminal [--lines N] [--placement P] [--deliver-to ] [--print] plannotator-tui herdr pane - plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode|cmd] [--pid N] [--session ] + plannotator-tui last [--host claude|codex|pi|omp|copilot|droid|hermes|opencode|cmd|agy] [--pid N] [--session ] [--session-id ] [--stdin] [--print] [--pick N] [--newest]"; /// Width the document gets when nothing else is known: gutter + rail + gap subtracted. diff --git a/crates/plannotator-tui/src/herdr/launch.rs b/crates/plannotator-tui/src/herdr/launch.rs index c7b2c80..d56479e 100644 --- a/crates/plannotator-tui/src/herdr/launch.rs +++ b/crates/plannotator-tui/src/herdr/launch.rs @@ -125,6 +125,7 @@ fn known_host(name: &str) -> Option<&'static str> { "codex" => Some("codex"), "pi" => Some("pi"), "opencode" => Some("opencode"), + "agy" => Some("agy"), _ => None, } } diff --git a/crates/plannotator-tui/src/herdr/launch/tests.rs b/crates/plannotator-tui/src/herdr/launch/tests.rs index 5f3b0eb..0a0fcac 100644 --- a/crates/plannotator-tui/src/herdr/launch/tests.rs +++ b/crates/plannotator-tui/src/herdr/launch/tests.rs @@ -263,6 +263,25 @@ fn herdrs_cmd_label_selects_command_code_while_the_pid_comes_from_the_process() assert!(argv(&launch).contains(&"PLANNOTATOR_TUI_HOST=cmd".to_owned())); } +/// Antigravity under Herdr: agent `agy` with the conversation id as its session. +#[test] +fn herdrs_agy_label_and_conversation_id_select_antigravity_exactly() { + let agent_get = r#"{"id":"cli:agent:get","result":{"agent":{"agent":"agy","agent_session":{"agent":"agy","kind":"id","source":"herdr:antigravity_cli","value":"4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c"},"pane_id":"w1:p1"},"type":"agent_info"}}"#; + let launch = plan_last( + &env(Some("w1:p1"), None), + &Config::default(), + OpenArgs::default(), + Path::new("/"), + None, + Some(agent_get), + ) + .expect("plans"); + assert_eq!(launch.message, Some(AgentMessage { host: "agy".into(), pid: None })); + let args = argv(&launch); + assert!(args.contains(&"PLANNOTATOR_TUI_HOST=agy".to_owned())); + assert!(args.contains(&"PLANNOTATOR_TUI_SESSION_ID=4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c".to_owned())); +} + const AGENT_GET_PATH: &str = r#"{"id":"cli:agent:get","result":{"agent":{"agent":"omp","agent_session":{"agent":"omp","kind":"path","source":"herdr:omp","value":"~/.omp/agent/sessions/--w--/2026-08-29T01-00-00-000Z_0199.jsonl"},"pane_id":"w1:p1"},"type":"agent_info"}}"#; const AGENT_GET_ID: &str = r#"{"id":"cli:agent:get","result":{"agent":{"agent":"hermes","agent_session":{"agent":"hermes","kind":"id","source":"herdr:hermes","value":"sess_abc123"},"pane_id":"w1:p1"},"type":"agent_info"}}"#; const AGENT_GET_PI: &str = r#"{"id":"cli:agent:get","result":{"agent":{"agent":"pi","agent_session":{"agent":"pi","kind":"path","source":"herdr:pi","value":"~/.pi/agent/sessions/--w--/session.jsonl"},"pane_id":"w1:p1"},"type":"agent_info"}}"#; diff --git a/crates/plannotator-tui/src/last/exact.rs b/crates/plannotator-tui/src/last/exact.rs index 9134b3a..567a2e7 100644 --- a/crates/plannotator-tui/src/last/exact.rs +++ b/crates/plannotator-tui/src/last/exact.rs @@ -3,7 +3,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use plannotator_tui_hosts::{Host, claude, codex, commandcode, copilot, droid, omp, opencode, pi}; +use plannotator_tui_hosts::{ + Host, antigravity, claude, codex, commandcode, copilot, droid, omp, opencode, pi, +}; use super::roots::Roots; @@ -60,6 +62,12 @@ pub(super) fn resolve(host: Host, session_id: &str, cwd: &Path, roots: &Roots) - .map(ExactSession::File) .with_context(|| format!("no Command Code session {id} in {}", root.display())) } + Host::Antigravity => { + let root = roots.antigravity_home.join("brain"); + antigravity::find_transcript_by_id(&roots.antigravity_home, id)? + .map(ExactSession::File) + .with_context(|| format!("no Antigravity conversation {id} in {}", root.display())) + } Host::Hermes => Ok(ExactSession::Hermes(roots.hermes_database())), Host::OpenCode => { let databases = roots.opencode_databases(); diff --git a/crates/plannotator-tui/src/last/fallback.rs b/crates/plannotator-tui/src/last/fallback.rs index fa641c7..aeeecb5 100644 --- a/crates/plannotator-tui/src/last/fallback.rs +++ b/crates/plannotator-tui/src/last/fallback.rs @@ -7,7 +7,7 @@ use std::process::Command; use anyhow::{Context, Result, bail}; #[cfg(unix)] use plannotator_tui_hosts::copilot; -use plannotator_tui_hosts::{Host, Match, Message, claude, commandcode, droid, opencode, pi}; +use plannotator_tui_hosts::{Host, Match, Message, antigravity, claude, commandcode, droid, opencode, pi}; use super::LastOptions; use super::exact; @@ -73,6 +73,11 @@ pub(super) fn read( let messages = readers::commandcode_messages(&path, pick)?; Ok((path, messages, Discovery::Folder)) } + Host::Antigravity => { + let path = antigravity::find_transcript(&roots.antigravity_home, cwd)?; + let messages = readers::antigravity_messages(&path, pick)?; + Ok((path, messages, Discovery::Folder)) + } Host::Omp => bail!( "OMP session discovery without an exact path or id is unsupported; pass --session or --session-id" ), diff --git a/crates/plannotator-tui/src/last/readers.rs b/crates/plannotator-tui/src/last/readers.rs index 63960e4..a6e7257 100644 --- a/crates/plannotator-tui/src/last/readers.rs +++ b/crates/plannotator-tui/src/last/readers.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use plannotator_tui_hosts::{ - Host, Message, claude, codex, commandcode, copilot, droid, hermes, omp, opencode, pi, + Host, Message, antigravity, claude, codex, commandcode, copilot, droid, hermes, omp, opencode, pi, }; use super::exact::ExactSession; @@ -27,6 +27,7 @@ pub(super) fn explicit( Host::Pi => Ok((path.to_path_buf(), pi_messages(path, pick)?)), Host::Omp => Ok((path.to_path_buf(), omp_messages(path, pick)?)), Host::CommandCode => Ok((path.to_path_buf(), commandcode_messages(path, pick)?)), + Host::Antigravity => Ok((path.to_path_buf(), antigravity_messages(path, pick)?)), Host::Hermes => { let id = required_session_id(session_id, "hermes")?; Ok((path.to_path_buf(), hermes::messages_for_session(path, id, pick)?)) @@ -71,6 +72,10 @@ pub(super) fn exact( let messages = commandcode_messages(&path, pick)?; Ok((path, messages)) } + (Host::Antigravity, ExactSession::File(path)) => { + let messages = antigravity_messages(&path, pick)?; + Ok((path, messages)) + } (Host::Hermes, ExactSession::Hermes(database)) => { let messages = hermes::messages_for_session(&database, session_id, pick)?; Ok((database, messages)) @@ -149,6 +154,11 @@ pub(super) fn commandcode_messages(path: &Path, pick: usize) -> Result Result> { + let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + Ok(antigravity::parse_messages(&text, pick)) +} + pub(super) 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/roots.rs b/crates/plannotator-tui/src/last/roots.rs index b31c994..5f03223 100644 --- a/crates/plannotator-tui/src/last/roots.rs +++ b/crates/plannotator-tui/src/last/roots.rs @@ -3,7 +3,7 @@ use std::ffi::OsString; use std::path::PathBuf; -use plannotator_tui_hosts::{commandcode, hermes, opencode}; +use plannotator_tui_hosts::{antigravity, commandcode, hermes, opencode}; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct Roots { @@ -12,6 +12,8 @@ pub(super) struct Roots { pub(super) codex_home: PathBuf, pub(super) copilot_home: PathBuf, pub(super) factory_config: PathBuf, + /// Antigravity CLI's app data directory: `brain/`, `conversation_summaries.db`, `history.jsonl`. + pub(super) antigravity_home: PathBuf, pi_session_dir: Option, pi_agent_dir: Option, hermes_home: PathBuf, @@ -34,6 +36,8 @@ impl Roots { codex_home: path("CODEX_HOME").unwrap_or_else(|| home.join(".codex")), copilot_home: path("COPILOT_HOME").unwrap_or_else(|| home.join(".copilot")), factory_config: path("FACTORY_CONFIG_DIR").unwrap_or_else(|| home.join(".factory")), + antigravity_home: path("ANTIGRAVITY_CLI_HOME") + .unwrap_or_else(|| home.join(antigravity::DEFAULT_APP_DATA_DIR)), pi_session_dir: path("PI_CODING_AGENT_SESSION_DIR"), pi_agent_dir: path("PI_CODING_AGENT_DIR"), hermes_home, @@ -111,6 +115,7 @@ mod tests { ("CODEX_HOME", "root/codex".into()), ("COPILOT_HOME", "root/copilot".into()), ("FACTORY_CONFIG_DIR", "root/factory".into()), + ("ANTIGRAVITY_CLI_HOME", "root/agy".into()), ("PI_CODING_AGENT_SESSION_DIR", "root/pi sessions".into()), ("PI_CODING_AGENT_DIR", "root/pi agent ignored".into()), ("HERMES_HOME", "root/hermes".into()), @@ -124,6 +129,7 @@ mod tests { assert_eq!(roots.codex_home, PathBuf::from("root/codex")); assert_eq!(roots.copilot_home, PathBuf::from("root/copilot")); assert_eq!(roots.factory_config, PathBuf::from("root/factory")); + assert_eq!(roots.antigravity_home, PathBuf::from("root/agy")); assert_eq!(roots.pi_sessions(".pi/agent"), PathBuf::from("root/pi sessions")); assert_eq!(roots.hermes_database(), PathBuf::from("root/hermes").join(hermes::DB_FILE)); assert_eq!(roots.opencode_databases(), vec![PathBuf::from("root/opencode/custom.db")]); diff --git a/crates/plannotator-tui/tests/last.rs b/crates/plannotator-tui/tests/last.rs index e2bb691..39462de 100644 --- a/crates/plannotator-tui/tests/last.rs +++ b/crates/plannotator-tui/tests/last.rs @@ -286,6 +286,60 @@ fn command_code_reads_the_session_started_in_the_agents_cwd() { std::fs::remove_dir_all(&home).expect("cleanup"); } +/// Herdr's conversation id reads its transcript directly; without one, the conversation the +/// prompt history ties to the pane's cwd is read. +#[test] +fn antigravity_reads_the_conversation_herdr_names_or_the_one_started_in_the_cwd() { + let id = "4f1c9e2a-7b3d-4c8e-9a10-2b6d8e0f1a3c"; + let app = temp_dir("agy"); + let relative = format!("brain/{id}/.system_generated/logs/transcript_full.jsonl"); + let target = app.join(&relative); + std::fs::create_dir_all(target.parent().expect("logs")).expect("logs dir"); + std::fs::copy(fixtures().join("antigravity").join(&relative), &target).expect("copy"); + let newest = "Done: both settings are applied in `retroarch.cfg`."; + let by_id = bin() + .env("ANTIGRAVITY_CLI_HOME", &app) + .env("PLANNOTATOR_TUI_CWD", "/w/unrelated") + .args(["last", "--host", "agy", "--session-id", id, "--print"]) + .output() + .expect("runs"); + assert_eq!( + String::from_utf8_lossy(&by_id.stdout).trim_end(), + newest, + "{}", + String::from_utf8_lossy(&by_id.stderr) + ); + let unvouched = bin() + .env("ANTIGRAVITY_CLI_HOME", &app) + .env("PLANNOTATOR_TUI_CWD", "/w/proj") + .args(["last", "--host", "agy", "--print"]) + .output() + .expect("runs"); + assert!(unvouched.status.success(), "exit 0 is the contract"); + assert_eq!(String::from_utf8_lossy(&unvouched.stdout), ""); + assert!( + String::from_utf8_lossy(&unvouched.stderr).contains("no Antigravity conversation started in /w/proj") + ); + std::fs::write( + app.join("history.jsonl"), + format!("{{\"display\":\"Apply it.\",\"timestamp\":1,\"workspace\":\"/w/proj\",\"conversationId\":\"{id}\"}}\n"), + ) + .expect("history"); + let by_cwd = bin() + .env("ANTIGRAVITY_CLI_HOME", &app) + .env("PLANNOTATOR_TUI_CWD", "/w/proj") + .args(["last", "--host", "agy", "--print"]) + .output() + .expect("runs"); + assert_eq!( + String::from_utf8_lossy(&by_cwd.stdout).trim_end(), + newest, + "{}", + String::from_utf8_lossy(&by_cwd.stderr) + ); + std::fs::remove_dir_all(&app).expect("cleanup"); +} + #[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())); diff --git a/docs/decisions.md b/docs/decisions.md index 1d147cf..bc83288 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -379,6 +379,29 @@ host and the pane's cwd selects the session. Command Code reports its session id when it runs its own Herdr integration; that id is looked up as `.jsonl`, slug directory first. +**Antigravity CLI** (2026-10-27, herdr-annotate#70). `agy` keeps one directory per +conversation in its app data directory (`$ANTIGRAVITY_CLI_HOME`, else +`~/.gemini/antigravity-cli`, `%USERPROFILE%` on Windows): +`brain//.system_generated/logs/transcript_full.jsonl`, one step per line. +`transcript.jsonl` beside it truncates long `content` and is never read. A reply is a +`PLANNER_RESPONSE` step's non-empty `content`, verbatim; a step that only calls tools has +none. Prompts are `USER_INPUT` steps wrapped in ``/``, +returned as written because only replies are shown. Each step's `step_index` is its message +id. Herdr labels the agent `agy` and reports the conversation id as `agent_session` +(`kind: id`), which names the transcript directly. Without an id, the conversation is chosen +by folder, and a transcript does not record its folder. Two other files do: +`conversation_summaries.db` (`workspace_uris`, a JSON array of `file://` URIs, per +`conversation_id`) and `history.jsonl` (each prompt's `workspace`, and its `conversationId` +from the second prompt on). Both are read, the database read-only through the same +`immutable` fallback as the other `SQLite` hosts. Subagent rows (`parent_conversation_id` +set) are skipped. A conversation whose workspace is the cwd wins over one whose workspace only +contains it, and the newest transcript by last write wins within each. When the database is +missing or unreadable, the history alone decides. A transcript nobody ties to the cwd is never +shown, so an unverified newer conversation does not stand in for this folder's. The error +names both files and the database failure. Verified against `agy` 1.2.7's files on macOS and +the sample in herdr-annotate#70. The `presence/.lock` files outlive their sessions and are +not used. + ## 15. The cursor can roam before selecting (2026-09-18)