use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::core::knowledge::ProjectKnowledge;
use crate::core::session::SessionState;
use crate::core::workflow::WorkflowRun;
use crate::tools::ToolCallRecord;
const SCHEMA_VERSION: u32 = 1;
const MAX_KNOWLEDGE_FACTS: usize = 50;
const MAX_CURATED_REFS: usize = 20;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffLedgerV1 {
pub schema_version: u32,
pub created_at: String,
pub content_md5: String,
pub manifest_md5: String,
pub project_root: Option<String>,
pub agent_id: Option<String>,
pub client_name: Option<String>,
pub workflow: Option<WorkflowRun>,
pub session_snapshot: String,
pub session: SessionExcerpt,
pub tool_calls: ToolCallsSummary,
pub evidence_keys: Vec<String>,
pub knowledge: KnowledgeExcerpt,
pub curated_refs: Vec<CuratedRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionExcerpt {
pub id: String,
pub task: Option<String>,
pub decisions: Vec<String>,
pub findings: Vec<String>,
pub next_steps: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolCallsSummary {
pub total: usize,
pub by_tool: BTreeMap<String, u64>,
pub by_ctx_read_mode: BTreeMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct KnowledgeExcerpt {
pub project_hash: Option<String>,
pub facts: Vec<KnowledgeFactMini>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeFactMini {
pub category: String,
pub key: String,
pub value: String,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CuratedRef {
pub path: String,
pub mode: String,
pub content_md5: String,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct CreateLedgerInput {
pub agent_id: Option<String>,
pub client_name: Option<String>,
pub project_root: Option<String>,
pub session: SessionState,
pub tool_calls: Vec<ToolCallRecord>,
pub workflow: Option<WorkflowRun>,
pub curated_refs: Vec<(String, String)>, }
pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
let manifest_md5 = manifest_md5();
let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
for ev in &input.session.evidence {
evidence_keys.insert(ev.key.clone());
}
let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
for call in &input.tool_calls {
*by_tool.entry(call.tool.clone()).or_insert(0) += 1;
if call.tool == "ctx_read" {
if let Some(m) = call.mode.as_deref() {
*by_mode.entry(m.to_string()).or_insert(0) += 1;
}
}
}
let session_excerpt = SessionExcerpt {
id: input.session.id.clone(),
task: input.session.task.as_ref().map(|t| t.description.clone()),
decisions: input
.session
.decisions
.iter()
.rev()
.take(10)
.map(|d| d.summary.clone())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect(),
findings: input
.session
.findings
.iter()
.rev()
.take(20)
.map(|f| f.summary.clone())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect(),
next_steps: input.session.next_steps.iter().take(20).cloned().collect(),
};
let knowledge_excerpt = build_knowledge_excerpt(input.project_root.as_deref());
let mut curated = Vec::new();
for (p, text) in input.curated_refs.into_iter().take(MAX_CURATED_REFS) {
let md5 = md5_hex(text.as_bytes());
curated.push(CuratedRef {
path: p,
mode: "signatures".to_string(),
content_md5: md5,
content: text,
});
}
let mut ledger = HandoffLedgerV1 {
schema_version: SCHEMA_VERSION,
created_at: chrono::Local::now().to_rfc3339(),
content_md5: String::new(),
manifest_md5,
project_root: input.project_root,
agent_id: input.agent_id,
client_name: input.client_name,
workflow: input.workflow,
session_snapshot: input.session.build_compaction_snapshot(),
session: session_excerpt,
tool_calls: ToolCallsSummary {
total: input.tool_calls.len(),
by_tool,
by_ctx_read_mode: by_mode,
},
evidence_keys: evidence_keys.into_iter().collect(),
knowledge: knowledge_excerpt,
curated_refs: curated,
};
let md5 = ledger_content_md5(&ledger);
ledger.content_md5 = md5.clone();
let path = ledger_path(&ledger.created_at, &md5)?;
let json = serde_json::to_string_pretty(&ledger).map_err(|e| format!("serialize: {e}"))?;
crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok((ledger, path))
}
pub fn list_ledgers() -> Vec<PathBuf> {
let dir = handoffs_dir().ok();
let Some(dir) = dir else {
return Vec::new();
};
let Ok(rd) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut items: Vec<PathBuf> = rd
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
.collect();
items.sort();
items.reverse();
items
}
pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
let s = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
serde_json::from_str(&s).map_err(|e| format!("parse {}: {e}", path.display()))
}
pub fn clear_ledgers() -> Result<u32, String> {
let dir = handoffs_dir()?;
let mut removed = 0u32;
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if std::fs::remove_file(&p).is_ok() {
removed += 1;
}
}
}
Ok(removed)
}
fn build_knowledge_excerpt(project_root: Option<&str>) -> KnowledgeExcerpt {
let Some(root) = project_root else {
return KnowledgeExcerpt::default();
};
let Some(knowledge) = ProjectKnowledge::load(root) else {
return KnowledgeExcerpt::default();
};
let mut facts = Vec::new();
for f in knowledge.facts.iter().filter(|f| f.is_current()) {
facts.push(KnowledgeFactMini {
category: f.category.clone(),
key: f.key.clone(),
value: f.value.clone(),
confidence: f.confidence,
});
if facts.len() >= MAX_KNOWLEDGE_FACTS {
break;
}
}
KnowledgeExcerpt {
project_hash: Some(knowledge.project_hash.clone()),
facts,
}
}
fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
let dir = handoffs_dir()?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
let ts = created_at
.chars()
.filter(|c| c.is_ascii_digit())
.take(14)
.collect::<String>();
let name = format!("{ts}-{md5}.json");
Ok(dir.join(name))
}
fn handoffs_dir() -> Result<PathBuf, String> {
let dir = crate::core::data_dir::lean_ctx_data_dir()
.map_err(|e| e.to_string())?
.join("handoffs");
Ok(dir)
}
fn manifest_md5() -> String {
let v = crate::core::mcp_manifest::manifest_value();
let canon = canonicalize_json(&v);
md5_hex(canon.to_string().as_bytes())
}
fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
let mut tmp = ledger.clone();
tmp.content_md5.clear();
let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
let canon = canonicalize_json(&v);
md5_hex(canon.to_string().as_bytes())
}
fn md5_hex(bytes: &[u8]) -> String {
use md5::{Digest, Md5};
let mut hasher = Md5::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn canonicalize_json(v: &Value) -> Value {
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = serde_json::Map::new();
for k in keys {
if let Some(val) = map.get(k) {
out.insert(k.clone(), canonicalize_json(val));
}
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
other => other.clone(),
}
}