1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::core::knowledge::ProjectKnowledge;
8use crate::core::session::SessionState;
9use crate::core::workflow::WorkflowRun;
10use crate::tools::ToolCallRecord;
11
12const SCHEMA_VERSION: u32 = 1;
13const MAX_KNOWLEDGE_FACTS: usize = 50;
14const MAX_CURATED_REFS: usize = 20;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct HandoffLedgerV1 {
18 pub schema_version: u32,
19 pub created_at: String,
20 pub content_md5: String,
21 pub manifest_md5: String,
22 pub project_root: Option<String>,
23 pub agent_id: Option<String>,
24 pub client_name: Option<String>,
25 pub workflow: Option<WorkflowRun>,
26 pub session_snapshot: String,
27 pub session: SessionExcerpt,
28 pub tool_calls: ToolCallsSummary,
29 pub evidence_keys: Vec<String>,
30 pub knowledge: KnowledgeExcerpt,
31 pub curated_refs: Vec<CuratedRef>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct SessionExcerpt {
36 pub id: String,
37 pub task: Option<String>,
38 pub decisions: Vec<String>,
39 pub findings: Vec<String>,
40 pub next_steps: Vec<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct ToolCallsSummary {
45 pub total: usize,
46 pub by_tool: BTreeMap<String, u64>,
47 pub by_ctx_read_mode: BTreeMap<String, u64>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct KnowledgeExcerpt {
52 pub project_hash: Option<String>,
53 pub facts: Vec<KnowledgeFactMini>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct KnowledgeFactMini {
58 pub category: String,
59 pub key: String,
60 pub value: String,
61 pub confidence: f32,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CuratedRef {
66 pub path: String,
67 pub mode: String,
68 pub content_md5: String,
69 pub content: String,
70}
71
72#[derive(Debug, Clone)]
73pub struct CreateLedgerInput {
74 pub agent_id: Option<String>,
75 pub client_name: Option<String>,
76 pub project_root: Option<String>,
77 pub session: SessionState,
78 pub tool_calls: Vec<ToolCallRecord>,
79 pub workflow: Option<WorkflowRun>,
80 pub curated_refs: Vec<(String, String)>, }
82
83pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
84 let manifest_md5 = manifest_md5();
85
86 let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
87 for ev in &input.session.evidence {
88 evidence_keys.insert(ev.key.clone());
89 }
90
91 let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
92 let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
93 for call in &input.tool_calls {
94 *by_tool.entry(call.tool.clone()).or_insert(0) += 1;
95 if call.tool == "ctx_read" {
96 if let Some(m) = call.mode.as_deref() {
97 *by_mode.entry(m.to_string()).or_insert(0) += 1;
98 }
99 }
100 }
101
102 let session_excerpt = SessionExcerpt {
103 id: input.session.id.clone(),
104 task: input.session.task.as_ref().map(|t| t.description.clone()),
105 decisions: input
106 .session
107 .decisions
108 .iter()
109 .rev()
110 .take(10)
111 .map(|d| d.summary.clone())
112 .collect::<Vec<_>>()
113 .into_iter()
114 .rev()
115 .collect(),
116 findings: input
117 .session
118 .findings
119 .iter()
120 .rev()
121 .take(20)
122 .map(|f| f.summary.clone())
123 .collect::<Vec<_>>()
124 .into_iter()
125 .rev()
126 .collect(),
127 next_steps: input.session.next_steps.iter().take(20).cloned().collect(),
128 };
129
130 let knowledge_excerpt = build_knowledge_excerpt(input.project_root.as_deref());
131
132 let mut curated = Vec::new();
133 for (p, text) in input.curated_refs.into_iter().take(MAX_CURATED_REFS) {
134 let md5 = md5_hex(text.as_bytes());
135 curated.push(CuratedRef {
136 path: p,
137 mode: "signatures".to_string(),
138 content_md5: md5,
139 content: text,
140 });
141 }
142
143 let mut ledger = HandoffLedgerV1 {
144 schema_version: SCHEMA_VERSION,
145 created_at: chrono::Local::now().to_rfc3339(),
146 content_md5: String::new(),
147 manifest_md5,
148 project_root: input.project_root,
149 agent_id: input.agent_id,
150 client_name: input.client_name,
151 workflow: input.workflow,
152 session_snapshot: input.session.build_compaction_snapshot(),
153 session: session_excerpt,
154 tool_calls: ToolCallsSummary {
155 total: input.tool_calls.len(),
156 by_tool,
157 by_ctx_read_mode: by_mode,
158 },
159 evidence_keys: evidence_keys.into_iter().collect(),
160 knowledge: knowledge_excerpt,
161 curated_refs: curated,
162 };
163
164 let md5 = ledger_content_md5(&ledger);
165 ledger.content_md5 = md5.clone();
166
167 let path = ledger_path(&ledger.created_at, &md5)?;
168 let json = serde_json::to_string_pretty(&ledger).map_err(|e| format!("serialize: {e}"))?;
169 crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
170 .map_err(|e| format!("write {}: {e}", path.display()))?;
171
172 Ok((ledger, path))
173}
174
175pub fn list_ledgers() -> Vec<PathBuf> {
176 let dir = handoffs_dir().ok();
177 let Some(dir) = dir else {
178 return Vec::new();
179 };
180 let Ok(rd) = std::fs::read_dir(&dir) else {
181 return Vec::new();
182 };
183 let mut items: Vec<PathBuf> = rd
184 .flatten()
185 .map(|e| e.path())
186 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
187 .collect();
188 items.sort();
189 items.reverse();
190 items
191}
192
193pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
194 let s = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
195 serde_json::from_str(&s).map_err(|e| format!("parse {}: {e}", path.display()))
196}
197
198pub fn clear_ledgers() -> Result<u32, String> {
199 let dir = handoffs_dir()?;
200 let mut removed = 0u32;
201 if let Ok(rd) = std::fs::read_dir(&dir) {
202 for e in rd.flatten() {
203 let p = e.path();
204 if p.extension().and_then(|e| e.to_str()) != Some("json") {
205 continue;
206 }
207 if std::fs::remove_file(&p).is_ok() {
208 removed += 1;
209 }
210 }
211 }
212 Ok(removed)
213}
214
215fn build_knowledge_excerpt(project_root: Option<&str>) -> KnowledgeExcerpt {
216 let Some(root) = project_root else {
217 return KnowledgeExcerpt::default();
218 };
219 let Some(knowledge) = ProjectKnowledge::load(root) else {
220 return KnowledgeExcerpt::default();
221 };
222
223 let mut facts = Vec::new();
224 for f in knowledge.facts.iter().filter(|f| f.is_current()) {
225 facts.push(KnowledgeFactMini {
226 category: f.category.clone(),
227 key: f.key.clone(),
228 value: f.value.clone(),
229 confidence: f.confidence,
230 });
231 if facts.len() >= MAX_KNOWLEDGE_FACTS {
232 break;
233 }
234 }
235
236 KnowledgeExcerpt {
237 project_hash: Some(knowledge.project_hash.clone()),
238 facts,
239 }
240}
241
242fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
243 let dir = handoffs_dir()?;
244 std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
245 let ts = created_at
246 .chars()
247 .filter(|c| c.is_ascii_digit())
248 .take(14)
249 .collect::<String>();
250 let name = format!("{ts}-{md5}.json");
251 Ok(dir.join(name))
252}
253
254fn handoffs_dir() -> Result<PathBuf, String> {
255 let dir = crate::core::data_dir::lean_ctx_data_dir()
256 .map_err(|e| e.to_string())?
257 .join("handoffs");
258 Ok(dir)
259}
260
261fn manifest_md5() -> String {
262 let v = crate::core::mcp_manifest::manifest_value();
263 let canon = canonicalize_json(&v);
264 md5_hex(canon.to_string().as_bytes())
265}
266
267fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
268 let mut tmp = ledger.clone();
269 tmp.content_md5.clear();
270 let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
271 let canon = canonicalize_json(&v);
272 md5_hex(canon.to_string().as_bytes())
273}
274
275fn md5_hex(bytes: &[u8]) -> String {
276 use md5::{Digest, Md5};
277 let mut hasher = Md5::new();
278 hasher.update(bytes);
279 format!("{:x}", hasher.finalize())
280}
281
282fn canonicalize_json(v: &Value) -> Value {
283 match v {
284 Value::Object(map) => {
285 let mut keys: Vec<&String> = map.keys().collect();
286 keys.sort();
287 let mut out = serde_json::Map::new();
288 for k in keys {
289 if let Some(val) = map.get(k) {
290 out.insert(k.clone(), canonicalize_json(val));
291 }
292 }
293 Value::Object(out)
294 }
295 Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
296 other => other.clone(),
297 }
298}