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
25 changes: 14 additions & 11 deletions crates/plannotator-tui-hosts/src/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub const DEFAULT_APP_DATA_DIR: &str = ".gemini/antigravity-cli";
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";
/// Set to `1` in the shells `agy` runs.
pub const AGENT_ENV: &str = "ANTIGRAVITY_AGENT";
/// The conversation id of the `agy` session that runs the shell.
pub const CONVERSATION_ENV: &str = "ANTIGRAVITY_CONVERSATION_ID";

const WHAT: &str = "Antigravity";

Expand Down Expand Up @@ -58,7 +62,7 @@ pub fn conversation_of(transcript: &Path) -> Option<String> {
.flatten()
}

/// The conversation for `cwd` with the newest transcript that holds a message: one whose
/// The conversation for `cwd` with the newest transcript that holds a reply: 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.
Expand Down Expand Up @@ -136,7 +140,7 @@ fn history_workspaces(history: &Path) -> Vec<(String, String)> {
.collect()
}

/// The last-written transcript among `ids` that holds at least one message. Ids that are not
/// The last-written transcript among `ids` that holds at least one reply. Ids that are not
/// one safe path component are skipped, whatever wrote them.
fn newest_with_messages<'a>(app_data: &Path, ids: impl Iterator<Item = &'a str>) -> Option<PathBuf> {
let mut ranked: Vec<(Option<std::time::SystemTime>, PathBuf)> = ids
Expand All @@ -147,18 +151,17 @@ fn newest_with_messages<'a>(app_data: &Path, ids: impl Iterator<Item = &'a str>)
.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()))
ranked.into_iter().map(|(_, path)| path).find(|path| {
std::fs::read_to_string(path).is_ok_and(|text| crate::has_reply(&parse_messages(&text, 1)))
})
}

/// 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.
/// Prompts and replies, newest first, through the `n`th newest reply. 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<Message> {
jsonl.lines().rev().filter_map(step_message).take(n).collect()
crate::through_replies(jsonl.lines().rev().filter_map(step_message), n)
}

fn step_message(line: &str) -> Option<Message> {
Expand Down
12 changes: 6 additions & 6 deletions crates/plannotator-tui-hosts/src/claude/ladder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{Match, SessionMeta};

const MAX_ANCESTOR_HOPS: usize = 8;

/// The ladder, most precise first; the first candidate that yields a message wins, and
/// The ladder, most precise first; the first candidate that yields a reply wins, and
/// the returned [`Match`] says which rung it came from:
/// 1. `sessions/<pid>.json` for `start_pid` and up to eight of its ancestors — with a ghost
/// check: a newer transcript in the same project dir that no running session claims is a
Expand Down Expand Up @@ -76,7 +76,7 @@ fn transcript_for_session(
{
return Some(ghost);
}
(own_mtime.is_some() && has_messages(&own)).then_some(own)
(own_mtime.is_some() && holds_reply(&own)).then_some(own)
}

/// The project dir for `cwd`, exact name first, then case-insensitively (Windows lowercases).
Expand All @@ -91,7 +91,7 @@ pub(super) fn project_dir(projects_dir: &Path, cwd: &Path) -> Option<PathBuf> {
})
}

/// Newest transcript in `dir` that renders at least one message. With `unregistered`, only
/// Newest transcript in `dir` that renders at least one reply. With `unregistered`, only
/// transcripts whose session id is not in that set count.
fn newest_with_messages(dir: &Path, unregistered: Option<&HashSet<&str>>) -> Option<PathBuf> {
let mut files: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(dir)
Expand All @@ -106,11 +106,11 @@ fn newest_with_messages(dir: &Path, unregistered: Option<&HashSet<&str>>) -> Opt
.filter_map(|p| Some((mtime(&p)?, p)))
.collect();
files.sort_by_key(|(when, _)| std::cmp::Reverse(*when));
files.into_iter().map(|(_, p)| p).find(|p| has_messages(p))
files.into_iter().map(|(_, p)| p).find(|p| holds_reply(p))
}

fn has_messages(path: &Path) -> bool {
std::fs::read_to_string(path).is_ok_and(|text| !parse_messages(&text, 1).is_empty())
fn holds_reply(path: &Path) -> bool {
std::fs::read_to_string(path).is_ok_and(|text| crate::has_reply(&parse_messages(&text, 1)))
}

fn mtime(path: &Path) -> Option<SystemTime> {
Expand Down
10 changes: 5 additions & 5 deletions crates/plannotator-tui-hosts/src/claude/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet};
use super::entries::Entry;
use crate::{Message, Role};

/// The newest `n` rendered messages, newest first.
/// Rendered messages, newest first, through the `n`th newest reply.
///
/// The active branch is the `parentUuid` chain from the newest entry that has a uuid.
/// File order is used instead when that chain cannot be trusted (missing ids, a dangling
Expand All @@ -25,14 +25,14 @@ pub fn parse_messages(jsonl: &str, n: usize) -> Vec<Message> {
}
None => render_all(&entries),
};
rendered.into_iter().rev().take(n).collect()
crate::through_replies(rendered.into_iter().rev(), n)
}

/// The newest `n` rendered messages in plain file order, newest first — for transcripts
/// in Claude's shape that have no rewind tree (Droid).
/// Rendered messages in plain file order, newest first, through the `n`th newest reply — for
/// transcripts in Claude's shape that have no rewind tree (Droid).
pub fn parse_messages_file_order(jsonl: &str, n: usize) -> Vec<Message> {
let entries: Vec<Entry> = jsonl.lines().filter_map(Entry::parse).collect();
render_all(&entries).into_iter().rev().take(n).collect()
crate::through_replies(render_all(&entries).into_iter().rev(), n)
}

fn render_all(entries: &[Entry]) -> Vec<Message> {
Expand Down
53 changes: 24 additions & 29 deletions crates/plannotator-tui-hosts/src/copilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,36 +66,31 @@ pub fn find_session(
.or_else(|| sessions.first().map(|s| pick(s, Match::Newest)))
}

/// Human prompts and assistant replies from `events.jsonl`, newest first, at most `n`.
/// `assistant.message` carries its text as a plain string; empty ones are skipped.
/// Human prompts and assistant replies from `events.jsonl`, newest first, through the `n`th
/// newest reply. `assistant.message` carries its text as a plain string; empty ones are
/// skipped.
pub fn parse_messages(events_jsonl: &str, n: usize) -> Vec<Message> {
let mut out: Vec<Message> = Vec::new();
for line in events_jsonl.lines().rev() {
if out.len() >= n {
break;
}
let Ok(event) = serde_json::from_str::<Value>(line.trim()) else { continue };
let kind = event.get("type").and_then(Value::as_str).unwrap_or("");
let role = match kind {
"assistant.message" => Role::Assistant,
"user.message" => Role::Human,
_ => continue,
};
let data = event.get("data");
let Some(text) = data.and_then(|d| d.get("content")).and_then(Value::as_str) else { continue };
if text.trim().is_empty() {
continue;
}
let id = data
.and_then(|d| d.get("messageId"))
.or_else(|| event.get("id"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let at = event.get("timestamp").and_then(Value::as_str).map(str::to_owned);
out.push(Message { id, role, text: text.to_owned(), at });
}
out
crate::through_replies(events_jsonl.lines().rev().filter_map(event_message), n)
}

fn event_message(line: &str) -> Option<Message> {
let event = serde_json::from_str::<Value>(line.trim()).ok()?;
let role = match event.get("type").and_then(Value::as_str)? {
"assistant.message" => Role::Assistant,
"user.message" => Role::Human,
_ => return None,
};
let data = event.get("data");
let text =
data.and_then(|d| d.get("content")).and_then(Value::as_str).filter(|t| !t.trim().is_empty())?;
let id = data
.and_then(|d| d.get("messageId"))
.or_else(|| event.get("id"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let at = event.get("timestamp").and_then(Value::as_str).map(str::to_owned);
Some(Message { id, role, text: text.to_owned(), at })
}

struct Session {
Expand Down
2 changes: 1 addition & 1 deletion crates/plannotator-tui-hosts/src/droid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pub fn find_transcript(factory_dir: &Path, cwd: &Path) -> Option<PathBuf> {
cwd.ancestors().find_map(|dir| newest_log(&slug_dir(&sessions_dir, dir)?))
}

/// The newest `n` assistant and human messages, newest first, in file order.
/// Assistant and human messages, newest first, in file order, through the `n`th newest reply.
pub fn parse_messages(jsonl: &str, n: usize) -> Vec<Message> {
parse_messages_file_order(jsonl, n)
}
Expand Down
12 changes: 9 additions & 3 deletions crates/plannotator-tui-hosts/src/hermes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ pub const DB_FILE: &str = "state.db";

const WHAT: &str = "Hermes";

/// The newest `n` user and assistant messages of `session_id`, newest first.
/// The user and assistant messages of `session_id`, newest first, through the `n`th newest
/// assistant reply.
///
/// 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
Expand All @@ -28,11 +29,11 @@ pub fn messages_for_session(db: &Path, session_id: &str, n: usize) -> Result<Vec
"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",
ORDER BY timestamp DESC, id DESC",
)
.map_err(|e| sql_error(WHAT, &e))?;
let rows = statement
.query_map(params![session_id, n as i64], |row| {
.query_map(params![session_id], |row| {
let id: i64 = row.get(0)?;
let role: String = row.get(1)?;
let content: Option<String> = row.get(2)?;
Expand All @@ -41,14 +42,19 @@ pub fn messages_for_session(db: &Path, session_id: &str, n: usize) -> Result<Vec
})
.map_err(|e| sql_error(WHAT, &e))?;
let mut messages = Vec::new();
let mut replies = 0;
for row in rows {
if replies >= n {
break;
}
let (id, role, content, timestamp) = row.map_err(|e| sql_error(WHAT, &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,
};
replies += usize::from(role == Role::Assistant);
messages.push(Message { id: id.to_string(), role, text, at: timestamp.map(iso) });
}
if messages.is_empty() {
Expand Down
26 changes: 26 additions & 0 deletions crates/plannotator-tui-hosts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ pub struct Message {
pub at: Option<String>,
}

/// Newest-first messages up to and including the `n`th assistant reply. `n` counts replies,
/// never prompts: `last` offers replies, so a prompt still waiting for its answer must not
/// crowd out the reply before it. Prompts newer than that reply are kept for context.
pub(crate) fn through_replies(newest_first: impl IntoIterator<Item = Message>, n: usize) -> Vec<Message> {
let mut out = Vec::new();
let mut replies = 0;
for message in newest_first {
if replies >= n {
break;
}
replies += usize::from(message.role == Role::Assistant);
out.push(message);
}
out
}

/// Does `messages` hold an assistant reply? A transcript with prompts only (a conversation
/// just started) has nothing to annotate, so discovery passes over it.
pub(crate) fn has_reply(messages: &[Message]) -> bool {
messages.iter().any(|message| message.role == Role::Assistant)
}

/// Which rung of a discovery ladder chose a session, so a caller can say whether the
/// result identifies the agent's own session or is the best guess for its folder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -243,6 +265,10 @@ pub fn detect_host(env: impl Fn(&str) -> Option<String>) -> Result<Host, HostErr
if set("COPILOT_CLI") {
return Ok(Host::Copilot);
}
// Antigravity CLI exports both into the shells it runs, the id naming the conversation.
if set(antigravity::AGENT_ENV) || set(antigravity::CONVERSATION_ENV) {
return Ok(Host::Antigravity);
}
let ai_agent = env("AI_AGENT").map(|v| v.trim().to_ascii_lowercase());
// Oh My Pi exports `OMPCODE=1` (and `CLAUDECODE=1`) into the shells it spawns and no
// pi marker (verified in oh-my-pi `packages/utils/src/procmgr.ts`), so it is found by
Expand Down
14 changes: 10 additions & 4 deletions crates/plannotator-tui-hosts/src/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ fn has_table(connection: &rusqlite::Connection, name: &str) -> Result<bool, Host
.map_err(|e| sql_error(WHAT, &e))
}

/// The newest `n` user and assistant messages of `session_id` that carry text, newest first.
/// The user and assistant messages of `session_id` that carry text, newest first, through the
/// `n`th newest assistant reply.
pub fn messages_for_session(
db: &Path,
session_id: &str,
Expand Down Expand Up @@ -173,7 +174,7 @@ fn messages_v1(db: &Path, session_id: &str, n: usize) -> Result<Vec<Message>, Ho
let (id, message_data, part_data) = row.map_err(|e| sql_error(WHAT, &e))?;
if current.as_ref().is_none_or(|c| c.0 != id) {
flush(&mut current, &mut messages);
if messages.len() >= n {
if replies(&messages) >= n {
break;
}
let Some((role, at)) = message_meta(&message_data) else { continue };
Expand All @@ -185,7 +186,7 @@ fn messages_v1(db: &Path, session_id: &str, n: usize) -> Result<Vec<Message>, Ho
parts.push(text);
}
}
if messages.len() < n {
if replies(&messages) < n {
flush(&mut current, &mut messages);
}
if messages.is_empty() {
Expand Down Expand Up @@ -219,7 +220,7 @@ fn messages_v2(db: &Path, session_id: &str, n: usize) -> Result<Vec<Message>, Ho
.map_err(|e| sql_error(WHAT, &e))?;
let mut messages = Vec::new();
for row in rows {
if messages.len() >= n {
if replies(&messages) >= n {
break;
}
let (id, kind, data, created) = row.map_err(|e| sql_error(WHAT, &e))?;
Expand Down Expand Up @@ -262,6 +263,11 @@ fn messages_v2(db: &Path, session_id: &str, n: usize) -> Result<Vec<Message>, Ho
Ok(messages)
}

/// How many of `messages` are assistant replies: `n` counts those, not prompts.
fn replies(messages: &[Message]) -> usize {
messages.iter().filter(|message| message.role == Role::Assistant).count()
}

/// Role and creation time from a `message.data` payload; `None` for roles we do not render.
fn message_meta(data: &str) -> Option<(Role, Option<String>)> {
let json: serde_json::Value = serde_json::from_str(data).ok()?;
Expand Down
20 changes: 8 additions & 12 deletions crates/plannotator-tui-hosts/src/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub fn find_transcript(sessions_dir: &Path, cwd: &Path) -> Option<PathBuf> {
newest_with_messages(all)
}

/// The last-written candidate that holds at least one message. Ranked by modification
/// The last-written candidate that holds at least one reply. Ranked by modification
/// time, newest first; a file whose metadata cannot be read ranks after every file that
/// has one, and ties fall back to the timestamp in the filename, so a directory whose
/// files all share an mtime keeps the old ordering.
Expand All @@ -116,7 +116,7 @@ pub(crate) fn newest_with_messages(mut files: Vec<PathBuf>) -> Option<PathBuf> {
ranked
.into_iter()
.map(|(_, path)| path)
.find(|p| std::fs::read_to_string(p).is_ok_and(|text| !parse_messages(&text, 1).is_empty()))
.find(|p| std::fs::read_to_string(p).is_ok_and(|text| crate::has_reply(&parse_messages(&text, 1))))
}

fn modified(path: &Path) -> Option<std::time::SystemTime> {
Expand Down Expand Up @@ -200,18 +200,15 @@ fn iso_timestamp(value: &Value) -> Option<String> {
}
}

/// 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
/// Messages on the active branch, newest first, through the `n`th newest reply. 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
/// reconstructed (missing parent, cycle) yields nothing rather than the wrong messages.
pub fn parse_messages(jsonl: &str, n: usize) -> Vec<Message> {
let entries: Vec<Entry> = jsonl.lines().filter_map(Entry::parse).collect();
let Some(active) = active_branch(&entries) else { return Vec::new() };
entries
.iter()
.rev()
.filter(|e| e.id.as_ref().is_some_and(|i| active.contains(i)))
.filter_map(|e| {
let messages =
entries.iter().rev().filter(|e| e.id.as_ref().is_some_and(|i| active.contains(i))).filter_map(|e| {
let (role, text) = e.message.as_ref()?;
let role = match role.as_str() {
"assistant" => Role::Assistant,
Expand All @@ -224,9 +221,8 @@ pub fn parse_messages(jsonl: &str, n: usize) -> Vec<Message> {
text: text.clone(),
at: e.timestamp.clone(),
})
})
.take(n)
.collect()
});
crate::through_replies(messages, n)
}

fn active_branch(entries: &[Entry]) -> Option<std::collections::HashSet<String>> {
Expand Down
Loading
Loading