use std::fs::{self, File, OpenOptions};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use nojson::{DisplayJson, Json, JsonFormatter, JsonParseError, RawJson};
use crate::sansio::deepseek::{ChatMessage, ToolCall};
pub struct Session {
dir: PathBuf,
lock_path: PathBuf,
_lock: File,
conversation_path: PathBuf,
pending_path: PathBuf,
writer: File,
}
impl Session {
pub fn open(name: &str) -> io::Result<Self> {
let paths = session_paths(name)?;
fs::create_dir_all(&paths.dir)?;
fs::create_dir_all(&paths.scratchpad)?;
let lock = acquire_lock_with_stale_retry(name, &paths.lock)?;
let writer = OpenOptions::new()
.create(true)
.append(true)
.open(&paths.conversation)?;
Ok(Self {
dir: paths.dir,
lock_path: paths.lock,
_lock: lock,
conversation_path: paths.conversation,
pending_path: paths.pending,
writer,
})
}
pub fn close(self) -> io::Result<()> {
fs::remove_file(&self.lock_path)
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn load_conversation(&self) -> io::Result<Vec<ChatMessage>> {
let file = match File::open(&self.conversation_path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut messages = Vec::new();
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match parse_conversation_line(&line) {
Ok(Some(msg)) => messages.push(msg),
Ok(None) => {}
Err(e) => {
return Err(io::Error::other(format!(
"malformed conversation record at line {}: {e}",
i + 1
)));
}
}
}
Ok(messages)
}
pub fn load_summaries(&self) -> io::Result<Vec<SummaryRecord>> {
let file = match File::open(&self.conversation_path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut summaries = Vec::new();
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match parse_summary_line(&line) {
Ok(Some(s)) => summaries.push(s),
Ok(None) => {}
Err(e) => {
return Err(io::Error::other(format!(
"malformed conversation record at line {}: {e}",
i + 1
)));
}
}
}
Ok(summaries)
}
pub fn load_records_since_last_summary(&self) -> io::Result<Vec<ChatMessageWithTs>> {
read_chat_message_with_ts(&self.conversation_path, false)
}
pub fn latest_prompt_tokens(&self) -> io::Result<Option<u64>> {
let file = match File::open(&self.conversation_path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut latest: Option<u64> = None;
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match parse_prompt_tokens(&line) {
Ok(Some(v)) => latest = Some(v),
Ok(None) => {}
Err(e) => {
return Err(io::Error::other(format!(
"malformed conversation record at line {}: {e}",
i + 1
)));
}
}
}
Ok(latest)
}
pub fn last_invocation_end_reason(&self) -> io::Result<Option<InvocationEndReason>> {
let file = match File::open(&self.conversation_path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut latest: Option<InvocationEndReason> = None;
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match parse_invocation_end_reason(&line) {
Ok(Some(reason)) => latest = Some(reason),
Ok(None) => {}
Err(e) => {
return Err(io::Error::other(format!(
"malformed conversation record at line {}: {e}",
i + 1
)));
}
}
}
Ok(latest)
}
pub fn conversation_path(&self) -> &Path {
&self.conversation_path
}
pub fn pending_path(&self) -> &Path {
&self.pending_path
}
pub fn append(&mut self, record: &SessionRecord) -> io::Result<()> {
let mut line = Json(record).to_string();
line.push('\n');
self.writer.write_all(line.as_bytes())?;
self.writer.flush()
}
pub fn save_pending(&self, pending: &[Pending]) -> io::Result<()> {
let content = Json(pending).to_string();
fs::write(&self.pending_path, content)
}
pub fn load_pending(&self) -> io::Result<Option<Vec<Pending>>> {
let text = match fs::read_to_string(&self.pending_path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let json =
RawJson::parse(&text).map_err(|e| io::Error::other(format!("pending.json: {e}")))?;
Pending::from_json_array(json.value()).map(Some)
}
pub fn clear_pending(&self) -> io::Result<()> {
match fs::remove_file(&self.pending_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
impl Drop for Session {
fn drop(&mut self) {
let _ = fs::remove_file(&self.lock_path);
}
}
pub fn read_chat_message_with_ts(path: &Path, all: bool) -> io::Result<Vec<ChatMessageWithTs>> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut latest_cutoff: Option<u64> = None;
let mut records: Vec<ChatMessageWithTs> = Vec::new();
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match parse_conversation_line_with_meta(&line) {
Ok(LineKind::Message { message, ts }) => {
records.push(ChatMessageWithTs { message, ts });
}
Ok(LineKind::Summary { cutoff_ts }) => {
latest_cutoff = Some(match latest_cutoff {
Some(prev) => prev.max(cutoff_ts),
None => cutoff_ts,
});
}
Ok(LineKind::Other) => {}
Err(e) => {
return Err(io::Error::other(format!(
"malformed conversation record at line {}: {e}",
i + 1
)));
}
}
}
if !all && let Some(cutoff) = latest_cutoff {
records.retain(|r| r.ts > cutoff);
}
Ok(records)
}
#[derive(Debug, Clone)]
pub struct SessionPaths {
pub dir: PathBuf,
pub conversation: PathBuf,
pub pending: PathBuf,
pub lock: PathBuf,
pub scratchpad: PathBuf,
pub ask: PathBuf,
}
pub fn session_root() -> PathBuf {
PathBuf::from(".attini")
}
pub fn session_paths(name: &str) -> io::Result<SessionPaths> {
if name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"session name must not be empty",
));
}
if name.contains(|c: char| c == '/' || c == '\\' || c.is_control()) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"session name must not contain path separators or control characters",
));
}
let dir = session_root().join(name);
Ok(SessionPaths {
conversation: dir.join("conversation.jsonl"),
pending: dir.join("pending.json"),
lock: dir.join("LOCK"),
scratchpad: dir.join("scratchpad"),
ask: dir.join("ask.json"),
dir,
})
}
#[derive(Debug, Clone, Default)]
pub struct ConversationSummary {
pub total_records: u64,
pub last_ts: Option<u64>,
pub last_kind: Option<String>,
pub invocation_starts: u64,
pub invocation_ends_completed: u64,
pub invocation_ends_awaiting_approval: u64,
pub invocation_ends_error: u64,
pub user_messages: u64,
pub assistant_messages: u64,
pub assistant_tool_calls_total: u64,
pub tool_messages: u64,
pub approvals_approve: u64,
pub approvals_reject: u64,
pub approvals_auto_approve: u64,
pub approvals_auto_deny: u64,
pub summaries: u64,
pub last_prompt_tokens: Option<u64>,
pub last_prompt_cache_hit_tokens: Option<u64>,
pub last_prompt_cache_miss_tokens: Option<u64>,
}
pub fn scan_conversation(path: &Path) -> io::Result<ConversationSummary> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(ConversationSummary::default());
}
Err(e) => return Err(e),
};
let mut summary = ConversationSummary::default();
for (i, line) in BufReader::new(file).lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
summary.total_records += 1;
classify_record(&line, &mut summary)
.map_err(|e| io::Error::other(format!("malformed record at line {}: {e}", i + 1)))?;
}
Ok(summary)
}
fn classify_record(line: &str, out: &mut ConversationSummary) -> Result<(), String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
if let Ok(m) = value.to_member("ts")
&& let Some(v) = m.optional()
&& let Ok(ts) = v.try_into()
{
out.last_ts = Some(ts);
}
match kind.as_str() {
"invocation_start" => out.invocation_starts += 1,
"invocation_end" => {
let reason = value
.to_member("reason")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?;
match reason.as_ref() {
"completed" => out.invocation_ends_completed += 1,
"awaiting_approval" => out.invocation_ends_awaiting_approval += 1,
"error" => out.invocation_ends_error += 1,
_ => {}
}
}
"user" => out.user_messages += 1,
"assistant" => {
out.assistant_messages += 1;
if let Ok(m) = value.to_member("tool_calls")
&& let Some(v) = m.optional()
&& let Ok(arr) = v.to_array()
{
out.assistant_tool_calls_total += arr.count() as u64;
}
}
"tool" => out.tool_messages += 1,
"summary" => out.summaries += 1,
"token_usage" => {
if let Ok(usage_m) = value.to_member("usage")
&& let Some(usage) = usage_m.optional()
{
out.last_prompt_tokens = usage
.to_member("prompt_tokens")
.ok()
.and_then(|m| m.optional())
.and_then(|v| v.try_into().ok())
.or(out.last_prompt_tokens);
out.last_prompt_cache_hit_tokens = usage
.to_member("prompt_cache_hit_tokens")
.ok()
.and_then(|m| m.optional())
.and_then(|v| v.try_into().ok())
.or(out.last_prompt_cache_hit_tokens);
out.last_prompt_cache_miss_tokens = usage
.to_member("prompt_cache_miss_tokens")
.ok()
.and_then(|m| m.optional())
.and_then(|v| v.try_into().ok())
.or(out.last_prompt_cache_miss_tokens);
}
}
"tool_approval" => {
let decision = value
.to_member("decision")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?;
let has_auto_sidecar = value
.to_member("auto_decided_by")
.ok()
.and_then(|m| m.optional())
.is_some();
match decision.as_ref() {
"approve" => {
out.approvals_approve += 1;
if has_auto_sidecar {
out.approvals_auto_approve += 1;
}
}
"reject" => {
out.approvals_reject += 1;
if has_auto_sidecar {
out.approvals_auto_deny += 1;
}
}
_ => {}
}
}
_ => {}
}
out.last_kind = Some(kind);
Ok(())
}
#[derive(Debug, Clone)]
pub struct PendingSummary {
pub call_id: String,
pub tool_kind: PendingToolKind,
pub function_name: String,
pub preview: String,
pub ts: u64,
}
pub fn read_pending_summary(path: &Path) -> io::Result<Option<Vec<PendingSummary>>> {
let text = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let json = RawJson::parse(&text).map_err(|e| io::Error::other(format!("pending.json: {e}")))?;
let pendings = Pending::from_json_array(json.value())?;
Ok(Some(
pendings
.into_iter()
.map(|p| PendingSummary {
call_id: p.call_id,
tool_kind: p.tool_kind,
function_name: p.function_name,
preview: p.preview,
ts: p.ts,
})
.collect(),
))
}
fn acquire_lock_with_stale_retry(name: &str, lock_path: &Path) -> io::Result<File> {
match acquire_lock(lock_path) {
Ok(file) => Ok(file),
Err(AcquireError::Io(e)) => Err(e),
Err(AcquireError::Locked) => match inspect_lock(lock_path) {
LockStatus::PidAlive(pid) => Err(lock_conflict_error(name, lock_path, Some(pid))),
LockStatus::PidDead | LockStatus::Corrupted | LockStatus::None => {
let _ = fs::remove_file(lock_path);
match acquire_lock(lock_path) {
Ok(file) => Ok(file),
Err(AcquireError::Io(e)) => Err(e),
Err(AcquireError::Locked) => Err(lock_conflict_error(name, lock_path, None)),
}
}
},
}
}
enum AcquireError {
Locked,
Io(io::Error),
}
fn acquire_lock(path: &Path) -> Result<File, AcquireError> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| {
if e.kind() == io::ErrorKind::AlreadyExists {
AcquireError::Locked
} else {
AcquireError::Io(e)
}
})?;
let body = LockBody {
pid: std::process::id() as i32,
started_at_unix_ms: now_unix_millis(),
};
let text = Json(&body).to_string();
file.write_all(text.as_bytes()).map_err(AcquireError::Io)?;
file.sync_all().map_err(AcquireError::Io)?;
Ok(file)
}
fn lock_conflict_error(name: &str, lock_path: &Path, holder_pid: Option<i32>) -> io::Error {
let pid_hint = match holder_pid {
Some(pid) => format!(" (holder pid {pid})"),
None => String::new(),
};
io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"session {name:?} is locked{pid_hint}: {path}\n\
If no attini process is actually holding it, remove the LOCK manually: \
`rm {path}`.",
path = lock_path.display(),
),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockStatus {
None,
Corrupted,
PidDead,
PidAlive(i32),
}
pub fn inspect_lock(path: &Path) -> LockStatus {
match fs::metadata(path) {
Ok(_) => classify_existing_lock(path),
Err(e) if e.kind() == io::ErrorKind::NotFound => LockStatus::None,
Err(_) => LockStatus::Corrupted,
}
}
fn classify_existing_lock(path: &Path) -> LockStatus {
let text = match fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return LockStatus::Corrupted,
};
let (pid, _started_at) = match parse_lock_body(&text) {
Some(v) => v,
None => return LockStatus::Corrupted,
};
match probe_pid(pid) {
PidStatus::Dead => LockStatus::PidDead,
PidStatus::Alive | PidStatus::EPerm => LockStatus::PidAlive(pid),
}
}
fn parse_lock_body(text: &str) -> Option<(i32, u64)> {
let json = RawJson::parse(text).ok()?;
let value = json.value();
let pid_i64: i64 = value
.to_member("pid")
.ok()?
.required()
.ok()?
.try_into()
.ok()?;
let started_at_unix_ms: u64 = value
.to_member("started_at_unix_ms")
.ok()?
.required()
.ok()?
.try_into()
.ok()?;
let pid: i32 = pid_i64.try_into().ok()?;
if pid <= 0 {
return None;
}
Some((pid, started_at_unix_ms))
}
enum PidStatus {
Alive,
Dead,
EPerm,
}
#[expect(
unsafe_code,
reason = "libc::kill with signal 0 only probes process existence and touches no memory"
)]
fn probe_pid(pid: i32) -> PidStatus {
let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
if ret == 0 {
return PidStatus::Alive;
}
match io::Error::last_os_error().raw_os_error() {
Some(errno) if errno == libc::ESRCH => PidStatus::Dead,
Some(errno) if errno == libc::EPERM => PidStatus::EPerm,
_ => PidStatus::Alive,
}
}
struct LockBody {
pid: i32,
started_at_unix_ms: u64,
}
impl DisplayJson for LockBody {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("pid", self.pid)?;
f.member("started_at_unix_ms", self.started_at_unix_ms)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionRecord {
InvocationStart {
ts: u64,
attini_version: String,
model: String,
},
InvocationEnd {
ts: u64,
reason: InvocationEndReason,
},
User {
ts: u64,
text: String,
},
Assistant {
ts: u64,
content: String,
tool_calls: Vec<ToolCall>,
},
Tool {
ts: u64,
call_id: String,
content: String,
},
ToolApproval {
ts: u64,
call_id: String,
decision: ApprovalDecision,
auto_decided_by: Option<AutoDecidedBy>,
},
MetricsSnapshot {
ts: u64,
counters: MetricsSnapshotBody,
},
TokenUsage {
ts: u64,
body: TokenUsageBody,
},
Summary {
ts: u64,
since_ts: u64,
cutoff_ts: u64,
text: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InvocationEndReason {
Completed,
AwaitingApproval,
Error,
TransportError,
SessionToolCallExhausted,
}
impl InvocationEndReason {
pub fn as_str(self) -> &'static str {
match self {
Self::Completed => "completed",
Self::AwaitingApproval => "awaiting_approval",
Self::Error => "error",
Self::TransportError => "transport_error",
Self::SessionToolCallExhausted => "session_tool_call_exhausted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecision {
Approve,
Reject,
}
impl ApprovalDecision {
fn as_str(self) -> &'static str {
match self {
Self::Approve => "approve",
Self::Reject => "reject",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoDecidedBy {
pub scope: String,
pub args_prefix: Vec<String>,
pub allow: bool,
pub matches: Vec<AutoDecidedMatch>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoDecidedMatch {
pub scope: String,
pub kind: String,
pub allow: bool,
pub args_prefix: Vec<String>,
pub path: String,
pub adopted: bool,
}
impl DisplayJson for AutoDecidedMatch {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("scope", &self.scope)?;
f.member("kind", &self.kind)?;
f.member("allow", self.allow)?;
f.member("args_prefix", &self.args_prefix)?;
f.member("path", &self.path)?;
f.member("adopted", self.adopted)?;
Ok(())
})
}
}
impl DisplayJson for AutoDecidedBy {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("scope", &self.scope)?;
f.member("args_prefix", &self.args_prefix)?;
f.member("allow", self.allow)?;
f.member("matches", &self.matches)?;
Ok(())
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MetricsSnapshotBody {
pub entries: Vec<(String, u64)>,
}
impl DisplayJson for MetricsSnapshotBody {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
for (k, v) in &self.entries {
f.member(k.as_str(), v)?;
}
Ok(())
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TokenUsageBody {
pub prompt_tokens: Option<u64>,
pub completion_tokens: Option<u64>,
pub total_tokens: Option<u64>,
pub prompt_cache_hit_tokens: Option<u64>,
pub prompt_cache_miss_tokens: Option<u64>,
}
impl DisplayJson for TokenUsageBody {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
if let Some(v) = self.prompt_tokens {
f.member("prompt_tokens", v)?;
}
if let Some(v) = self.completion_tokens {
f.member("completion_tokens", v)?;
}
if let Some(v) = self.total_tokens {
f.member("total_tokens", v)?;
}
if let Some(v) = self.prompt_cache_hit_tokens {
f.member("prompt_cache_hit_tokens", v)?;
}
if let Some(v) = self.prompt_cache_miss_tokens {
f.member("prompt_cache_miss_tokens", v)?;
}
Ok(())
})
}
}
impl DisplayJson for SessionRecord {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
match self {
Self::InvocationStart {
ts,
attini_version,
model,
} => f.object(|f| {
f.member("kind", "invocation_start")?;
f.member("ts", ts)?;
f.member("attini_version", attini_version)?;
f.member("model", model)
}),
Self::InvocationEnd { ts, reason } => f.object(|f| {
f.member("kind", "invocation_end")?;
f.member("ts", ts)?;
f.member("reason", reason.as_str())
}),
Self::User { ts, text } => f.object(|f| {
f.member("kind", "user")?;
f.member("ts", ts)?;
f.member("text", text)
}),
Self::Assistant {
ts,
content,
tool_calls,
} => f.object(|f| {
f.member("kind", "assistant")?;
f.member("ts", ts)?;
f.member("content", content)?;
f.member("tool_calls", tool_calls)
}),
Self::Tool {
ts,
call_id,
content,
} => f.object(|f| {
f.member("kind", "tool")?;
f.member("ts", ts)?;
f.member("call_id", call_id)?;
f.member("content", content)
}),
Self::ToolApproval {
ts,
call_id,
decision,
auto_decided_by,
} => f.object(|f| {
f.member("kind", "tool_approval")?;
f.member("ts", ts)?;
f.member("call_id", call_id)?;
f.member("decision", decision.as_str())?;
if let Some(by) = auto_decided_by {
f.member("auto_decided_by", by)?;
}
Ok(())
}),
Self::MetricsSnapshot { ts, counters } => f.object(|f| {
f.member("kind", "metrics_snapshot")?;
f.member("ts", ts)?;
f.member("counters", counters)
}),
Self::TokenUsage { ts, body } => f.object(|f| {
f.member("kind", "token_usage")?;
f.member("ts", ts)?;
f.member("usage", body)
}),
Self::Summary {
ts,
since_ts,
cutoff_ts,
text,
} => f.object(|f| {
f.member("kind", "summary")?;
f.member("ts", ts)?;
f.member("since_ts", since_ts)?;
f.member("cutoff_ts", cutoff_ts)?;
f.member("text", text)
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryRecord {
pub ts: u64,
pub since_ts: u64,
pub cutoff_ts: u64,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatMessageWithTs {
pub message: ChatMessage,
pub ts: u64,
}
enum LineKind {
Message { message: ChatMessage, ts: u64 },
Summary { cutoff_ts: u64 },
Other,
}
fn parse_conversation_line_with_meta(line: &str) -> Result<LineKind, String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
match kind.as_str() {
"user" | "assistant" | "tool" => {
let ts: u64 = value
.to_member("ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| e.to_string())?;
let message = parse_conversation_line(line)?.ok_or_else(|| {
"kind matched user/assistant/tool but parse_conversation_line returned None"
.to_string()
})?;
Ok(LineKind::Message { message, ts })
}
"summary" => {
let cutoff_ts: u64 = value
.to_member("cutoff_ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| e.to_string())?;
Ok(LineKind::Summary { cutoff_ts })
}
_ => Ok(LineKind::Other),
}
}
fn parse_summary_line(line: &str) -> Result<Option<SummaryRecord>, String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
if kind != "summary" {
return Ok(None);
}
let ts: u64 = value
.to_member("ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| e.to_string())?;
let since_ts: u64 = value
.to_member("since_ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| e.to_string())?;
let cutoff_ts: u64 = value
.to_member("cutoff_ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| e.to_string())?;
let text = read_string(value, "text")?;
Ok(Some(SummaryRecord {
ts,
since_ts,
cutoff_ts,
text,
}))
}
fn parse_prompt_tokens(line: &str) -> Result<Option<u64>, String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
if kind != "token_usage" {
return Ok(None);
}
let Some(usage) = value
.to_member("usage")
.map_err(|e| e.to_string())?
.optional()
else {
return Ok(None);
};
let Some(pt) = usage
.to_member("prompt_tokens")
.map_err(|e| e.to_string())?
.optional()
else {
return Ok(None);
};
let n: u64 = pt.try_into().map_err(|e: JsonParseError| e.to_string())?;
Ok(Some(n))
}
fn parse_invocation_end_reason(line: &str) -> Result<Option<InvocationEndReason>, String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
if kind != "invocation_end" {
return Ok(None);
}
let reason = value
.to_member("reason")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
let parsed = match reason.as_str() {
"completed" => InvocationEndReason::Completed,
"awaiting_approval" => InvocationEndReason::AwaitingApproval,
"error" => InvocationEndReason::Error,
"transport_error" => InvocationEndReason::TransportError,
"session_tool_call_exhausted" => InvocationEndReason::SessionToolCallExhausted,
other => return Err(format!("unknown invocation_end.reason {other:?}")),
};
Ok(Some(parsed))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecordKindBytes {
pub count: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReadTargetStats {
pub calls: u64,
pub bytes: u64,
pub max_bytes: u64,
pub ranges: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct CommandFamily {
pub program: String,
pub subcommand: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandFamilyStats {
pub count: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProgramStats {
pub count: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ToolResultStats {
pub count: u64,
pub bytes: u64,
pub max_bytes: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SummaryBytes {
pub count: u64,
pub bytes: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TokenUsageAggregate {
pub records: u64,
pub prompt_total: u64,
pub completion_total: u64,
pub cache_hit_total: u64,
pub cache_miss_total: u64,
pub prompt_max: u64,
pub latest: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ConversationAnalysis {
pub records: u64,
pub total_bytes: u64,
pub kind_bytes: Vec<(String, RecordKindBytes)>,
pub assistant_content_bytes: u64,
pub tool_calls_count: u64,
pub tool_results: Vec<(String, ToolResultStats)>,
pub read_targets: Vec<(String, ReadTargetStats)>,
pub programs: Vec<(String, ProgramStats)>,
pub command_families: Vec<(CommandFamily, CommandFamilyStats)>,
pub token_usage: TokenUsageAggregate,
pub summary_text: SummaryBytes,
}
pub fn analyze_conversation(path: &Path) -> io::Result<ConversationAnalysis> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(ConversationAnalysis::default()),
Err(e) => return Err(e),
};
let reader = io::BufReader::new(file);
use std::collections::BTreeMap;
let mut kind_bytes: BTreeMap<String, RecordKindBytes> = BTreeMap::new();
let mut assistant_content_bytes = 0u64;
let mut tool_calls_count = 0u64;
let mut tool_results: BTreeMap<String, ToolResultStats> = BTreeMap::new();
let mut read_targets: BTreeMap<String, ReadTargetStats> = BTreeMap::new();
let mut programs: BTreeMap<String, ProgramStats> = BTreeMap::new();
let mut command_families: BTreeMap<CommandFamily, CommandFamilyStats> = BTreeMap::new();
let mut token_usage_total = TokenUsageAggregate::default();
let mut summary_bytes = SummaryBytes::default();
let mut records = 0u64;
let mut total_bytes = 0u64;
let mut waiting_tools: Vec<(String, ToolCall)> = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}
total_bytes += (line.len() as u64) + 1;
let parsed = match parse_session_record_line(&line) {
Ok(Some(record)) => record,
Ok(None) => {
kind_bytes.entry("unknown".to_string()).or_default().count += 1;
kind_bytes.get_mut("unknown").unwrap().bytes += (line.len() as u64) + 1;
continue;
}
Err(_) => {
kind_bytes.entry("malformed".to_string()).or_default().count += 1;
kind_bytes.get_mut("malformed").unwrap().bytes += (line.len() as u64) + 1;
continue;
}
};
records += 1;
let this_bytes = (line.len() as u64) + 1;
match &parsed {
SessionRecord::InvocationStart { .. } => {
kind_bytes
.entry("invocation_start".to_string())
.or_default()
.count += 1;
kind_bytes.get_mut("invocation_start").unwrap().bytes += this_bytes;
}
SessionRecord::InvocationEnd { .. } => {
kind_bytes
.entry("invocation_end".to_string())
.or_default()
.count += 1;
kind_bytes.get_mut("invocation_end").unwrap().bytes += this_bytes;
}
SessionRecord::User { .. } => {
kind_bytes.entry("user".to_string()).or_default().count += 1;
kind_bytes.get_mut("user").unwrap().bytes += this_bytes;
}
SessionRecord::Assistant {
content,
tool_calls,
..
} => {
kind_bytes.entry("assistant".to_string()).or_default().count += 1;
kind_bytes.get_mut("assistant").unwrap().bytes += this_bytes;
assistant_content_bytes += content.len() as u64;
for tc in tool_calls {
waiting_tools.push((tc.id.clone(), tc.clone()));
}
tool_calls_count += tool_calls.len() as u64;
}
SessionRecord::Tool {
call_id, content, ..
} => {
kind_bytes.entry("tool".to_string()).or_default().count += 1;
kind_bytes.get_mut("tool").unwrap().bytes += this_bytes;
let matched_idx = waiting_tools.iter().position(|(id, _)| id == call_id);
let fname = matched_idx
.map(|i| waiting_tools[i].1.function_name.clone())
.unwrap_or_else(|| "unknown".to_string());
let bytes = content.len() as u64;
let entry = tool_results.entry(fname.clone()).or_default();
entry.count += 1;
entry.bytes += bytes;
entry.max_bytes = entry.max_bytes.max(bytes);
if let Some(idx) = matched_idx {
let tc = waiting_tools[idx].1.clone();
if fname == "read"
&& let Some(path) = tool_arg_string(&tc, "path")
{
let target = read_targets.entry(path).or_default();
target.calls += 1;
target.bytes += bytes;
target.max_bytes = target.max_bytes.max(bytes);
if tool_arg_present(&tc, "line_range") {
target.ranges += 1;
}
}
if fname == "command"
&& let Some(argv) = tool_arg_array(&tc, "argv")
&& let Some(program) = argv.first()
{
programs.entry(program.clone()).or_default().count += 1;
programs.get_mut(program).unwrap().bytes += bytes;
let family = CommandFamily {
program: program.clone(),
subcommand: argv.get(1).cloned(),
};
command_families.entry(family.clone()).or_default().count += 1;
command_families.get_mut(&family).unwrap().bytes += bytes;
}
waiting_tools.remove(idx);
}
}
SessionRecord::ToolApproval { .. } => {
kind_bytes
.entry("tool_approval".to_string())
.or_default()
.count += 1;
kind_bytes.get_mut("tool_approval").unwrap().bytes += this_bytes;
}
SessionRecord::MetricsSnapshot { .. } => {
kind_bytes
.entry("metrics_snapshot".to_string())
.or_default()
.count += 1;
kind_bytes.get_mut("metrics_snapshot").unwrap().bytes += this_bytes;
}
SessionRecord::TokenUsage { body, .. } => {
kind_bytes
.entry("token_usage".to_string())
.or_default()
.count += 1;
kind_bytes.get_mut("token_usage").unwrap().bytes += this_bytes;
token_usage_total.records += 1;
if let Some(v) = body.prompt_tokens {
token_usage_total.prompt_total += v;
token_usage_total.prompt_max = token_usage_total.prompt_max.max(v);
token_usage_total.latest = Some(v);
}
if let Some(v) = body.completion_tokens {
token_usage_total.completion_total += v;
}
if let Some(v) = body.prompt_cache_hit_tokens {
token_usage_total.cache_hit_total += v;
}
if let Some(v) = body.prompt_cache_miss_tokens {
token_usage_total.cache_miss_total += v;
}
}
SessionRecord::Summary { text, .. } => {
kind_bytes.entry("summary".to_string()).or_default().count += 1;
kind_bytes.get_mut("summary").unwrap().bytes += this_bytes;
summary_bytes.count += 1;
summary_bytes.bytes += text.len() as u64;
}
}
}
Ok(ConversationAnalysis {
records,
total_bytes,
kind_bytes: kind_bytes.into_iter().collect(),
assistant_content_bytes,
tool_calls_count,
tool_results: tool_results.into_iter().collect(),
read_targets: read_targets.into_iter().collect(),
programs: programs.into_iter().collect(),
command_families: command_families.into_iter().collect(),
token_usage: token_usage_total,
summary_text: summary_bytes,
})
}
fn tool_arg_string(tc: &ToolCall, key: &str) -> Option<String> {
let json = RawJson::parse(&tc.arguments_json).ok()?;
let member = json.value().to_member(key).ok()?.optional()?;
member.to_unquoted_string_str().ok().map(|s| s.into_owned())
}
fn tool_arg_present(tc: &ToolCall, key: &str) -> bool {
let Ok(json) = RawJson::parse(&tc.arguments_json) else {
return false;
};
json.value()
.to_member(key)
.ok()
.and_then(|m| m.optional())
.is_some()
}
fn tool_arg_array(tc: &ToolCall, key: &str) -> Option<Vec<String>> {
let json = RawJson::parse(&tc.arguments_json).ok()?;
let member = json.value().to_member(key).ok()?.optional()?;
let mut out = Vec::new();
for child in member.to_array().ok()? {
if let Ok(s) = child.to_unquoted_string_str() {
out.push(s.into_owned());
}
}
Some(out)
}
pub fn read_conversation_records(path: &Path) -> io::Result<Vec<SessionRecord>> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let reader = io::BufReader::new(file);
let mut out = Vec::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if let Ok(Some(record)) = parse_session_record_line(&line) {
out.push(record);
}
}
Ok(out)
}
fn parse_session_record_line(line: &str) -> Result<Option<SessionRecord>, String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
match kind.as_str() {
"invocation_start" => {
let ts = read_u64(value, "ts")?;
let attini_version = read_string(value, "attini_version")?;
let model = read_string(value, "model")?;
Ok(Some(SessionRecord::InvocationStart {
ts,
attini_version,
model,
}))
}
"invocation_end" => {
let ts = read_u64(value, "ts")?;
let reason_str = read_string(value, "reason")?;
let reason = match reason_str.as_str() {
"completed" => InvocationEndReason::Completed,
"awaiting_approval" => InvocationEndReason::AwaitingApproval,
"error" => InvocationEndReason::Error,
"transport_error" => InvocationEndReason::TransportError,
"session_tool_call_exhausted" => InvocationEndReason::SessionToolCallExhausted,
other => return Err(format!("unknown invocation_end.reason {other:?}")),
};
Ok(Some(SessionRecord::InvocationEnd { ts, reason }))
}
"user" => {
let ts = read_u64(value, "ts")?;
let text = read_string(value, "text")?;
Ok(Some(SessionRecord::User { ts, text }))
}
"assistant" => {
let ts = read_u64(value, "ts")?;
let content = read_string(value, "content")?;
let tool_calls = read_tool_calls(value)?;
Ok(Some(SessionRecord::Assistant {
ts,
content,
tool_calls,
}))
}
"tool" => {
let ts = read_u64(value, "ts")?;
let call_id = read_string(value, "call_id")?;
let content = read_string(value, "content")?;
Ok(Some(SessionRecord::Tool {
ts,
call_id,
content,
}))
}
"tool_approval" => {
let ts = read_u64(value, "ts")?;
let call_id = read_string(value, "call_id")?;
let decision_str = read_string(value, "decision")?;
let decision = match decision_str.as_str() {
"approve" => ApprovalDecision::Approve,
"reject" => ApprovalDecision::Reject,
other => return Err(format!("unknown tool_approval.decision {other:?}")),
};
let auto_decided_by = parse_auto_decided_by(value)?;
Ok(Some(SessionRecord::ToolApproval {
ts,
call_id,
decision,
auto_decided_by,
}))
}
"metrics_snapshot" => {
let ts = read_u64(value, "ts")?;
let counters = parse_metrics_counters(value)?;
Ok(Some(SessionRecord::MetricsSnapshot {
ts,
counters: MetricsSnapshotBody { entries: counters },
}))
}
"token_usage" => {
let ts = read_u64(value, "ts")?;
let body = parse_token_usage_body(value)?;
Ok(Some(SessionRecord::TokenUsage { ts, body }))
}
"summary" => {
let ts = read_u64(value, "ts")?;
let since_ts = read_u64(value, "since_ts")?;
let cutoff_ts = read_u64(value, "cutoff_ts")?;
let text = read_string(value, "text")?;
Ok(Some(SessionRecord::Summary {
ts,
since_ts,
cutoff_ts,
text,
}))
}
_ => Ok(None),
}
}
fn read_u64(value: nojson::RawJsonValue<'_, '_>, key: &str) -> Result<u64, String> {
value
.to_member(key)
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e: JsonParseError| e.to_string())
}
fn parse_auto_decided_by(
value: nojson::RawJsonValue<'_, '_>,
) -> Result<Option<AutoDecidedBy>, String> {
let Some(m) = value
.to_member("auto_decided_by")
.map_err(|e| e.to_string())?
.optional()
else {
return Ok(None);
};
if m.as_raw_str().trim() == "null" {
return Ok(None);
}
let scope = read_string(m, "scope")?;
let allow = read_bool(m, "allow")?;
let args_prefix = read_string_array(m, "args_prefix")?;
let mut matches = Vec::new();
let list = m
.to_member("matches")
.and_then(|arr| arr.required())
.map_err(|e| e.to_string())?;
for item in list.to_array().map_err(|e| e.to_string())? {
matches.push(AutoDecidedMatch {
scope: read_string(item, "scope")?,
kind: read_string(item, "kind")?,
allow: read_bool(item, "allow")?,
args_prefix: read_string_array(item, "args_prefix")?,
path: read_string(item, "path")?,
adopted: read_bool(item, "adopted")?,
});
}
Ok(Some(AutoDecidedBy {
scope,
args_prefix,
allow,
matches,
}))
}
fn read_bool(value: nojson::RawJsonValue<'_, '_>, key: &str) -> Result<bool, String> {
let raw = value
.to_member(key)
.and_then(|m| m.required())
.and_then(|m| m.as_boolean_str())
.map_err(|e: JsonParseError| e.to_string())?;
match raw {
"true" => Ok(true),
"false" => Ok(false),
other => Err(format!("{key}: expected a boolean (got {other})")),
}
}
fn read_string_array(
value: nojson::RawJsonValue<'_, '_>,
key: &str,
) -> Result<Vec<String>, String> {
let list = value
.to_member(key)
.and_then(|arr| arr.required())
.map_err(|e| format!("{key}: {e}"))?;
let mut out = Vec::new();
for item in list.to_array().map_err(|e| format!("{key}: {e}"))? {
out.push(
item.to_unquoted_string_str()
.map_err(|e| format!("{key}: {e}"))?
.into_owned(),
);
}
Ok(out)
}
fn parse_metrics_counters(
value: nojson::RawJsonValue<'_, '_>,
) -> Result<Vec<(String, u64)>, String> {
let counters = value
.to_member("counters")
.and_then(|m| m.required())
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for (k, v) in counters.to_object().map_err(|e| e.to_string())? {
let name = k
.to_unquoted_string_str()
.map_err(|e| e.to_string())?
.into_owned();
let n: u64 = v.try_into().map_err(|e: JsonParseError| e.to_string())?;
out.push((name, n));
}
Ok(out)
}
fn parse_token_usage_body(value: nojson::RawJsonValue<'_, '_>) -> Result<TokenUsageBody, String> {
let usage = value
.to_member("usage")
.and_then(|m| m.required())
.map_err(|e| e.to_string())?;
let opt_u64 = |key: &str| -> Result<Option<u64>, String> {
let Some(v) = usage.to_member(key).map_err(|e| e.to_string())?.optional() else {
return Ok(None);
};
if v.as_raw_str().trim() == "null" {
return Ok(None);
}
let n: u64 = v.try_into().map_err(|e: JsonParseError| e.to_string())?;
Ok(Some(n))
};
Ok(TokenUsageBody {
prompt_tokens: opt_u64("prompt_tokens")?,
completion_tokens: opt_u64("completion_tokens")?,
total_tokens: opt_u64("total_tokens")?,
prompt_cache_hit_tokens: opt_u64("prompt_cache_hit_tokens")?,
prompt_cache_miss_tokens: opt_u64("prompt_cache_miss_tokens")?,
})
}
fn parse_conversation_line(line: &str) -> Result<Option<ChatMessage>, String> {
let json = RawJson::parse(line).map_err(|e| e.to_string())?;
let value = json.value();
let kind = value
.to_member("kind")
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned();
match kind.as_str() {
"user" => {
let text = read_string(value, "text")?;
Ok(Some(ChatMessage::User(text)))
}
"assistant" => {
let content = read_string(value, "content")?;
let tool_calls = read_tool_calls(value)?;
Ok(Some(ChatMessage::Assistant {
content,
tool_calls,
}))
}
"tool" => {
let call_id = read_string(value, "call_id")?;
let content = read_string(value, "content")?;
Ok(Some(ChatMessage::Tool {
tool_call_id: call_id,
content,
}))
}
_ => Ok(None),
}
}
fn read_string(value: nojson::RawJsonValue<'_, '_>, key: &str) -> Result<String, String> {
Ok(value
.to_member(key)
.and_then(|m| m.required())
.and_then(|m| m.to_unquoted_string_str())
.map_err(|e| e.to_string())?
.into_owned())
}
fn read_optional_string(
value: nojson::RawJsonValue<'_, '_>,
key: &str,
) -> Result<Option<String>, String> {
let member = value.to_member(key).map_err(|e| e.to_string())?;
let Some(v) = member.optional() else {
return Ok(None);
};
let is_null = v.as_raw_str().trim() == "null";
if is_null {
return Ok(None);
}
Ok(Some(
v.to_unquoted_string_str()
.map_err(|e| e.to_string())?
.into_owned(),
))
}
fn read_tool_calls(value: nojson::RawJsonValue<'_, '_>) -> Result<Vec<ToolCall>, String> {
let member = value.to_member("tool_calls").map_err(|e| e.to_string())?;
let Some(v) = member.optional() else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for item in v.to_array().map_err(|e| e.to_string())? {
let id = read_string(item, "id")?;
let function = item
.to_member("function")
.and_then(|m| m.required())
.map_err(|e| e.to_string())?;
let function_name = read_string(function, "name")?;
let arguments_json = read_string(function, "arguments")?;
out.push(ToolCall {
id,
function_name,
arguments_json,
});
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pending {
pub ts: u64,
pub call_id: String,
pub tool_kind: PendingToolKind,
pub function_name: String,
pub arguments_json: String,
pub preview: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingToolKind {
Patch,
Command,
Read,
}
impl PendingToolKind {
fn as_str(self) -> &'static str {
match self {
Self::Patch => "patch",
Self::Command => "command",
Self::Read => "read",
}
}
fn parse(s: &str) -> Option<Self> {
match s {
"patch" => Some(Self::Patch),
"command" => Some(Self::Command),
"read" => Some(Self::Read),
_ => None,
}
}
}
impl DisplayJson for Pending {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("ts", self.ts)?;
f.member("call_id", &self.call_id)?;
f.member("tool_kind", self.tool_kind.as_str())?;
f.member("function_name", &self.function_name)?;
f.member("arguments_json", &self.arguments_json)?;
f.member("preview", &self.preview)
})
}
}
impl Pending {
fn from_json(value: nojson::RawJsonValue<'_, '_>) -> io::Result<Self> {
let map_err = |e: String| io::Error::other(format!("pending.json: {e}"));
let ts: u64 = value
.to_member("ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| map_err(e.to_string()))?;
let call_id = read_string(value, "call_id").map_err(map_err)?;
let tool_kind_str = read_string(value, "tool_kind").map_err(map_err)?;
let tool_kind = PendingToolKind::parse(&tool_kind_str).ok_or_else(|| {
io::Error::other(format!("pending.json: unknown tool_kind {tool_kind_str:?}"))
})?;
let function_name = read_string(value, "function_name").map_err(map_err)?;
let arguments_json = read_string(value, "arguments_json").map_err(map_err)?;
let preview = read_string(value, "preview").map_err(map_err)?;
Ok(Self {
ts,
call_id,
tool_kind,
function_name,
arguments_json,
preview,
})
}
fn from_json_array(value: nojson::RawJsonValue<'_, '_>) -> io::Result<Vec<Self>> {
let iter = value
.to_array()
.map_err(|e| io::Error::other(format!("pending.json: {e}")))?;
let mut out = Vec::new();
for elem in iter {
out.push(Self::from_json(elem)?);
}
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskEntry {
pub ts: u64,
pub question: Option<String>,
pub answer: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskState {
pub conversation_fingerprint: u64,
pub entries: Vec<AskEntry>,
}
impl DisplayJson for AskEntry {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("ts", self.ts)?;
f.member("question", &self.question)?;
f.member("answer", &self.answer)
})
}
}
impl DisplayJson for AskState {
fn fmt(&self, f: &mut JsonFormatter<'_, '_>) -> std::fmt::Result {
f.object(|f| {
f.member("conversation_fingerprint", self.conversation_fingerprint)?;
f.member("entries", &self.entries)
})
}
}
impl AskState {
fn from_json(value: nojson::RawJsonValue<'_, '_>) -> io::Result<Self> {
let map_err = |e: String| io::Error::other(format!("ask.json: {e}"));
let conversation_fingerprint: u64 = value
.to_member("conversation_fingerprint")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| map_err(e.to_string()))?;
let iter = value
.to_member("entries")
.and_then(|m| m.required())
.and_then(|m| m.to_array())
.map_err(|e| map_err(e.to_string()))?;
let mut entries = Vec::new();
for elem in iter {
entries.push(Self::entry_from_json(elem).map_err(&map_err)?);
}
Ok(Self {
conversation_fingerprint,
entries,
})
}
fn entry_from_json(value: nojson::RawJsonValue<'_, '_>) -> Result<AskEntry, String> {
let ts: u64 = value
.to_member("ts")
.and_then(|m| m.required())
.and_then(|m| m.try_into())
.map_err(|e| e.to_string())?;
let question = read_optional_string(value, "question")?;
let answer = read_string(value, "answer")?;
Ok(AskEntry {
ts,
question,
answer,
})
}
}
pub fn load_ask_state(path: &Path) -> io::Result<Option<AskState>> {
let text = match fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let json = RawJson::parse(&text).map_err(|e| io::Error::other(format!("ask.json: {e}")))?;
AskState::from_json(json.value()).map(Some)
}
pub fn save_ask_state(path: &Path, state: &AskState) -> io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "ask.json has no parent"))?;
fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("ask.json");
let tmp = parent.join(format!(".{file_name}.tmp-{}", std::process::id()));
fs::write(&tmp, Json(state).to_string())?;
fs::rename(&tmp, path)
}
pub fn conversation_fingerprint(records: &[ChatMessageWithTs]) -> u64 {
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for rec in records {
for b in rec.ts.to_string().as_bytes() {
hash ^= *b as u64;
hash = hash.wrapping_mul(PRIME);
}
hash ^= 0xFF;
hash = hash.wrapping_mul(PRIME);
let body = Json(&rec.message).to_string();
for b in body.as_bytes() {
hash ^= *b as u64;
hash = hash.wrapping_mul(PRIME);
}
hash ^= 0xFE;
hash = hash.wrapping_mul(PRIME);
}
hash
}
pub fn now_unix_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn analyze_conversation_splits_payload_and_attributes_result() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("attini-analyze-{}", now_unix_millis()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("conversation.jsonl");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::InvocationStart {
ts: 1,
attini_version: "0.0.0".to_string(),
model: "m".to_string(),
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::User {
ts: 2,
text: "hi".to_string(),
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Assistant {
ts: 3,
content: "".to_string(),
tool_calls: vec![ToolCall {
id: "c1".to_string(),
function_name: "read".to_string(),
arguments_json: r#"{"path":"src/main.rs","line_range":[1,2]}"#.to_string(),
}],
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Tool {
ts: 4,
call_id: "c1".to_string(),
content: "the file contents".to_string(),
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Assistant {
ts: 5,
content: "done".to_string(),
tool_calls: vec![ToolCall {
id: "c2".to_string(),
function_name: "command".to_string(),
arguments_json: r#"{"argv":["cargo","test"]}"#.to_string(),
}],
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Tool {
ts: 6,
call_id: "c2".to_string(),
content: "test output".to_string(),
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::TokenUsage {
ts: 7,
body: TokenUsageBody {
prompt_tokens: Some(100),
completion_tokens: Some(20),
prompt_cache_hit_tokens: Some(30),
prompt_cache_miss_tokens: Some(70),
total_tokens: Some(120),
},
})
)
.unwrap();
drop(f);
let a = analyze_conversation(&path).unwrap();
assert_eq!(a.records, 7);
assert_eq!(a.assistant_content_bytes, "done".len() as u64);
assert_eq!(a.tool_calls_count, 2);
assert_eq!(a.tool_results.len(), 2);
let read = a.tool_results.iter().find(|(k, _)| k == "read").unwrap();
assert_eq!(read.1.count, 1);
assert_eq!(read.1.bytes, "the file contents".len() as u64);
let cmd = a.tool_results.iter().find(|(k, _)| k == "command").unwrap();
assert_eq!(cmd.1.count, 1);
assert_eq!(cmd.1.bytes, "test output".len() as u64);
let read_targets = a
.read_targets
.iter()
.find(|(p, _)| p == "src/main.rs")
.unwrap();
assert!(read_targets.1.ranges > 0);
let program = a.programs.iter().find(|(p, _)| p == "cargo").unwrap();
assert_eq!(program.1.count, 1);
let fam = a
.command_families
.iter()
.find(|(fam, _)| fam.program == "cargo" && fam.subcommand.as_deref() == Some("test"))
.unwrap();
assert_eq!(fam.1.count, 1);
assert_eq!(a.token_usage.records, 1);
assert_eq!(a.token_usage.prompt_total, 100);
assert_eq!(a.token_usage.completion_total, 20);
assert_eq!(a.token_usage.cache_hit_total, 30);
assert_eq!(a.token_usage.cache_miss_total, 70);
assert_eq!(a.token_usage.latest, Some(100));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn analyze_conversation_joins_multiple_calls_in_one_turn() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("attini-analyze-multi-{}", now_unix_millis()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("conversation.jsonl");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Assistant {
ts: 1,
content: "".to_string(),
tool_calls: vec![
ToolCall {
id: "a".to_string(),
function_name: "read".to_string(),
arguments_json: r#"{"path":"src/x.rs","line_range":[1,2]}"#.to_string(),
},
ToolCall {
id: "b".to_string(),
function_name: "command".to_string(),
arguments_json: r#"{"argv":["cargo","check"]}"#.to_string(),
},
],
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Tool {
ts: 2,
call_id: "b".to_string(),
content: "cargo check ok".to_string(),
})
)
.unwrap();
writeln!(
f,
"{}",
Json(&SessionRecord::Tool {
ts: 3,
call_id: "a".to_string(),
content: "src/x.rs contents".to_string(),
})
)
.unwrap();
drop(f);
let a = analyze_conversation(&path).unwrap();
assert_eq!(a.tool_calls_count, 2);
let read = a.tool_results.iter().find(|(k, _)| k == "read").unwrap();
assert_eq!(read.1.count, 1);
let cmd = a.tool_results.iter().find(|(k, _)| k == "command").unwrap();
assert_eq!(cmd.1.count, 1);
assert!(!a.tool_results.iter().any(|(k, _)| k == "unknown"));
let target = a
.read_targets
.iter()
.find(|(p, _)| p == "src/x.rs")
.unwrap();
assert_eq!(target.1.calls, 1);
assert!(target.1.ranges > 0);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn conversation_fingerprint_is_deterministic_and_window_sensitive() {
let a = ChatMessageWithTs {
message: ChatMessage::User("hello".to_string()),
ts: 1,
};
let b = ChatMessageWithTs {
message: ChatMessage::User("world".to_string()),
ts: 2,
};
let fp1 = conversation_fingerprint(&[a.clone(), b.clone()]);
let fp2 = conversation_fingerprint(&[a.clone(), b.clone()]);
assert_eq!(fp1, fp2, "fingerprint must be deterministic");
let fp_more = conversation_fingerprint(&[a.clone(), b.clone(), a.clone()]);
assert_ne!(fp1, fp_more, "adding a record must change the fingerprint");
let fp_window = conversation_fingerprint(&[b]);
assert_ne!(
fp1, fp_window,
"a different window must change the fingerprint"
);
}
#[test]
fn ask_state_roundtrips_through_json() {
let state = AskState {
conversation_fingerprint: 42,
entries: vec![
AskEntry {
ts: 1,
question: Some("what?".to_string()),
answer: "answer one".to_string(),
},
AskEntry {
ts: 2,
question: None,
answer: "answer two".to_string(),
},
],
};
let json = Json(&state).to_string();
let parsed = RawJson::parse(&json).expect("ask.json should parse");
let got = AskState::from_json(parsed.value()).expect("ask.json should convert");
assert_eq!(got, state);
}
#[test]
fn approval_decision_serialises_stable_strings() {
assert_eq!(ApprovalDecision::Approve.as_str(), "approve");
assert_eq!(ApprovalDecision::Reject.as_str(), "reject");
}
#[test]
fn invocation_end_reason_serialises_stable_strings() {
assert_eq!(InvocationEndReason::Completed.as_str(), "completed");
assert_eq!(
InvocationEndReason::AwaitingApproval.as_str(),
"awaiting_approval"
);
assert_eq!(InvocationEndReason::Error.as_str(), "error");
assert_eq!(
InvocationEndReason::SessionToolCallExhausted.as_str(),
"session_tool_call_exhausted"
);
}
#[test]
fn pending_tool_kind_roundtrips() {
for kind in [
PendingToolKind::Patch,
PendingToolKind::Command,
PendingToolKind::Read,
] {
assert_eq!(PendingToolKind::parse(kind.as_str()), Some(kind));
}
assert!(PendingToolKind::parse("bogus").is_none());
}
#[test]
fn pending_batch_roundtrips_through_json_array() {
let a = Pending {
ts: 1,
call_id: "call_1".to_string(),
tool_kind: PendingToolKind::Patch,
function_name: "patch".to_string(),
arguments_json: r#"{"edits":[]}"#.to_string(),
preview: "patch preview".to_string(),
};
let b = Pending {
ts: 2,
call_id: "call_2".to_string(),
tool_kind: PendingToolKind::Command,
function_name: "command".to_string(),
arguments_json: r#"{"argv":["git","status"]}"#.to_string(),
preview: "command preview".to_string(),
};
let json = Json(&[a.clone(), b.clone()]).to_string();
let parsed = RawJson::parse(&json).expect("array should parse");
let got = Pending::from_json_array(parsed.value()).expect("array should convert");
assert_eq!(got, vec![a, b]);
}
#[test]
fn assistant_record_with_tool_calls_roundtrips_through_parse() {
let record = SessionRecord::Assistant {
ts: 42,
content: "hi".to_string(),
tool_calls: vec![ToolCall {
id: "call_1".to_string(),
function_name: "read".to_string(),
arguments_json: r#"{"path":"src/foo.rs"}"#.to_string(),
}],
};
let line = nojson::Json(&record).to_string();
let parsed = parse_conversation_line(&line)
.expect("parse must succeed")
.expect("assistant record must yield a ChatMessage");
match parsed {
ChatMessage::Assistant {
content,
tool_calls,
} => {
assert_eq!(content, "hi");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id, "call_1");
assert_eq!(tool_calls[0].function_name, "read");
assert_eq!(tool_calls[0].arguments_json, r#"{"path":"src/foo.rs"}"#);
}
other => panic!("expected assistant, got {other:?}"),
}
}
#[test]
fn tool_record_roundtrips_through_parse() {
let record = SessionRecord::Tool {
ts: 7,
call_id: "call_x".to_string(),
content: r#"{"ok":true}"#.to_string(),
};
let line = nojson::Json(&record).to_string();
let parsed = parse_conversation_line(&line)
.expect("parse must succeed")
.expect("tool record must yield a ChatMessage");
match parsed {
ChatMessage::Tool {
tool_call_id,
content,
} => {
assert_eq!(tool_call_id, "call_x");
assert_eq!(content, r#"{"ok":true}"#);
}
other => panic!("expected tool, got {other:?}"),
}
}
#[test]
fn summary_record_roundtrips_through_parse_summary_line() {
let record = SessionRecord::Summary {
ts: 100,
since_ts: 10,
cutoff_ts: 90,
text: "user asked for X".to_string(),
};
let line = nojson::Json(&record).to_string();
let parsed = parse_summary_line(&line)
.expect("parse ok")
.expect("summary yields SummaryRecord");
assert_eq!(parsed.ts, 100);
assert_eq!(parsed.since_ts, 10);
assert_eq!(parsed.cutoff_ts, 90);
assert_eq!(parsed.text, "user asked for X");
}
#[test]
fn summary_record_is_not_returned_by_parse_conversation_line() {
let record = SessionRecord::Summary {
ts: 100,
since_ts: 10,
cutoff_ts: 90,
text: "should not appear as ChatMessage".to_string(),
};
let line = nojson::Json(&record).to_string();
let parsed = parse_conversation_line(&line).expect("parse ok");
assert!(parsed.is_none());
}
#[test]
fn token_usage_record_serialises_only_present_fields() {
let record = SessionRecord::TokenUsage {
ts: 42,
body: TokenUsageBody {
prompt_tokens: Some(1000),
completion_tokens: None,
total_tokens: Some(1050),
prompt_cache_hit_tokens: Some(800),
prompt_cache_miss_tokens: Some(200),
},
};
let line = nojson::Json(&record).to_string();
assert_eq!(
line,
r#"{"kind":"token_usage","ts":42,"usage":{"prompt_tokens":1000,"total_tokens":1050,"prompt_cache_hit_tokens":800,"prompt_cache_miss_tokens":200}}"#
);
}
#[test]
fn parse_prompt_tokens_returns_none_for_non_token_usage_lines() {
let record = SessionRecord::User {
ts: 1,
text: "hi".to_string(),
};
let line = nojson::Json(&record).to_string();
assert!(parse_prompt_tokens(&line).expect("parse ok").is_none());
}
#[test]
fn parse_prompt_tokens_extracts_value_from_token_usage_line() {
let record = SessionRecord::TokenUsage {
ts: 42,
body: TokenUsageBody {
prompt_tokens: Some(17_000),
..Default::default()
},
};
let line = nojson::Json(&record).to_string();
assert_eq!(parse_prompt_tokens(&line).expect("parse ok"), Some(17_000));
}
#[test]
fn parse_prompt_tokens_tolerates_missing_prompt_tokens_field() {
let record = SessionRecord::TokenUsage {
ts: 42,
body: TokenUsageBody {
prompt_tokens: None,
total_tokens: Some(5),
..Default::default()
},
};
let line = nojson::Json(&record).to_string();
assert!(parse_prompt_tokens(&line).expect("parse ok").is_none());
}
#[test]
fn parse_invocation_end_reason_round_trips_transport_error() {
let record = SessionRecord::InvocationEnd {
ts: 7,
reason: InvocationEndReason::TransportError,
};
let line = nojson::Json(&record).to_string();
assert_eq!(
parse_invocation_end_reason(&line).expect("parse ok"),
Some(InvocationEndReason::TransportError)
);
}
#[test]
fn parse_invocation_end_reason_returns_none_for_other_kinds() {
let record = SessionRecord::User {
ts: 1,
text: "hi".to_string(),
};
let line = nojson::Json(&record).to_string();
assert!(
parse_invocation_end_reason(&line)
.expect("parse ok")
.is_none()
);
}
#[test]
fn parse_conversation_line_with_meta_carries_ts_for_user_records() {
let record = SessionRecord::User {
ts: 999,
text: "hi".to_string(),
};
let line = nojson::Json(&record).to_string();
match parse_conversation_line_with_meta(&line).expect("parse ok") {
LineKind::Message { ts, message } => {
assert_eq!(ts, 999);
assert!(matches!(message, ChatMessage::User(_)));
}
other => panic!("expected message, got {other:?}"),
}
}
#[test]
fn parse_conversation_line_with_meta_recognises_summary_cutoff() {
let record = SessionRecord::Summary {
ts: 500,
since_ts: 100,
cutoff_ts: 450,
text: "…".to_string(),
};
let line = nojson::Json(&record).to_string();
match parse_conversation_line_with_meta(&line).expect("parse ok") {
LineKind::Summary { cutoff_ts } => assert_eq!(cutoff_ts, 450),
other => panic!("expected summary, got {other:?}"),
}
}
impl std::fmt::Debug for LineKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LineKind::Message { ts, .. } => write!(f, "Message(ts={ts})"),
LineKind::Summary { cutoff_ts } => write!(f, "Summary(cutoff_ts={cutoff_ts})"),
LineKind::Other => write!(f, "Other"),
}
}
}
}