use super::codebuff::{
derive_context_from_path, extract_assistant_usage, is_assistant_role, message_timestamp,
parse_chat_id_to_millis,
};
use super::utils::{file_modified_timestamp_ms, read_file_or_none};
use super::UnifiedMessage;
use crate::{provider_identity, TokenBreakdown};
use serde_json::Value;
use std::path::Path;
const CLIENT_ID: &str = "freebuff";
const DEFAULT_MODEL: &str = "freebuff-unknown";
const FREEBUFF_ROOT_AGENT_PREFIX: &str = "base2-free";
pub fn parse_freebuff_file(path: &Path) -> Vec<UnifiedMessage> {
let Some(bytes) = read_file_or_none(path) else {
return Vec::new();
};
let mut bytes = bytes;
let root: Value = match simd_json::from_slice(&mut bytes) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let messages = match root.as_array() {
Some(arr) => arr,
None => return Vec::new(),
};
if !is_freebuff_chat(messages) {
return Vec::new();
}
if messages
.iter()
.any(|m| is_assistant_role(m) && extract_assistant_usage(m).has_signal())
{
return Vec::new();
}
let (channel, project_basename, chat_id) = derive_context_from_path(path);
let session_id = format!("{}/{}/{}", channel, project_basename, chat_id);
let chat_id_ts = parse_chat_id_to_millis(&chat_id).unwrap_or(0);
let file_mtime_ms = file_modified_timestamp_ms(path);
let model = model_from_settings(path).unwrap_or_else(|| DEFAULT_MODEL.to_string());
let provider = provider_identity::inferred_provider_from_model(&model).unwrap_or("unknown");
let mut results = Vec::new();
let mut turn_input_chars: usize = 0;
let mut pending_turn_start = false;
let mut assistant_index = 0usize;
for msg in messages.iter() {
let msg_chars = message_text_chars(msg);
if !is_assistant_role(msg) {
pending_turn_start = true;
turn_input_chars += msg_chars;
continue;
}
if msg_chars == 0 {
continue;
}
let input = estimate_tokens(turn_input_chars);
let output = estimate_tokens(msg_chars);
turn_input_chars = 0;
let chat_id_fallback = if chat_id_ts > 0 {
Some(chat_id_ts)
} else {
None
};
let ts = message_timestamp(msg)
.or(chat_id_fallback)
.unwrap_or(file_mtime_ms);
let mut message = UnifiedMessage::new_with_dedup(
CLIENT_ID,
&model,
provider,
&session_id,
ts,
TokenBreakdown {
input,
output,
cache_read: 0,
cache_write: 0,
reasoning: 0,
},
0.0,
Some(format!("{}:{}", session_id, assistant_index)),
);
message.message_count = 1;
message.is_turn_start = pending_turn_start;
results.push(message);
assistant_index += 1;
pending_turn_start = false;
}
results
}
fn root_agent_id(msg: &Value) -> Option<&str> {
msg.get("metadata")?
.get("runState")?
.get("sessionState")?
.get("mainAgentState")?
.get("agentType")?
.as_str()
.filter(|s| !s.trim().is_empty())
}
fn is_freebuff_chat(messages: &[Value]) -> bool {
messages
.iter()
.filter_map(root_agent_id)
.any(|agent| agent.starts_with(FREEBUFF_ROOT_AGENT_PREFIX))
}
fn estimate_tokens(chars: usize) -> i64 {
chars.div_ceil(4) as i64
}
fn message_text_chars(msg: &Value) -> usize {
let mut chars = 0usize;
if let Some(s) = msg.get("content").and_then(|v| v.as_str()) {
chars += s.chars().count();
}
if let Some(blocks) = msg.get("blocks").and_then(|v| v.as_array()) {
for block in blocks {
if let Some(content) = block.get("content").and_then(|v| v.as_str()) {
chars += content.chars().count();
}
}
}
chars
}
fn model_from_settings(path: &Path) -> Option<String> {
let settings_path = path
.parent()? .parent()? .parent()? .parent()? .parent()? .join("settings.json");
let bytes = read_file_or_none(&settings_path)?;
let value: Value = serde_json::from_slice(&bytes).ok()?;
value
.get("freebuffModel")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
}