use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
static CLOCK_TIEBREAKER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolEvent {
pub tool: String,
pub args_digest: String,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
impl ToolEvent {
pub fn from_call(
tool: impl Into<String>,
args: &serde_json::Value,
ok: bool,
duration_ms: Option<u64>,
) -> Self {
let hash = blake3::hash(args.to_string().as_bytes()).to_hex();
let short = &hash.as_str()[..16];
let keys = args
.as_object()
.map(|m| m.keys().cloned().collect::<Vec<_>>().join(" "))
.unwrap_or_default();
let args_digest = if keys.is_empty() {
format!("b3:{short}")
} else {
format!("{keys} b3:{short}")
};
Self {
tool: tool.into(),
args_digest,
ok,
duration_ms,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PhantomReach {
pub name_as_called: String,
pub resolution: PhantomResolution,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub active_context_features: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
pub enum PhantomResolution {
Rewrite(String),
Correct(String),
Unknown,
RealToolMiss(String),
GatedOff(String),
NarratedNoCall,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversationTurn {
pub user: String,
pub assistant: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<ToolEvent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub phantom_reaches: Vec<PhantomReach>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_in: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens_out: Option<u32>,
}
impl ConversationTurn {
pub fn new(user: impl Into<String>, assistant: impl Into<String>) -> Self {
Self {
user: user.into(),
assistant: assistant.into(),
events: Vec::new(),
phantom_reaches: Vec::new(),
tokens_in: None,
tokens_out: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversationRecord {
pub id: String,
pub title: String,
pub workspace: String,
pub workspace_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<String>,
#[serde(default)]
pub turns: Vec<ConversationTurn>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub scratchpad: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "crate::PlanSnapshot::is_empty")]
pub plan: crate::PlanSnapshot,
pub created_at_unix_nanos: u128,
pub updated_at_unix_nanos: u128,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationSummary {
pub id: String,
pub title: String,
pub persona: Option<String>,
pub turn_count: usize,
pub updated_at_unix_nanos: u128,
}
pub fn new_conversation_id() -> String {
format!("{}-{}", unix_nanos(), uuid::Uuid::new_v4())
}
pub fn session_plan_dir(conversation_id: &str) -> PathBuf {
Path::new(&crate::scratch::scratch_workspace_subdir())
.join("sessions")
.join(conversation_id)
}
pub fn session_plan_path(conversation_id: &str) -> PathBuf {
session_plan_dir(conversation_id).join("plan.md")
}
fn unix_nanos() -> u128 {
let base = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
base + CLOCK_TIEBREAKER.fetch_add(1, Ordering::Relaxed) as u128
}