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
12pub const SCHEMA_VERSION: u32 = crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION;
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 #[serde(default)]
33 pub active_overlays: Vec<serde_json::Value>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct SessionExcerpt {
38 pub id: String,
39 pub task: Option<String>,
40 pub decisions: Vec<String>,
41 pub findings: Vec<String>,
42 pub next_steps: Vec<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46pub struct ToolCallsSummary {
47 pub total: usize,
48 pub by_tool: BTreeMap<String, u64>,
49 pub by_ctx_read_mode: BTreeMap<String, u64>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53pub struct KnowledgeExcerpt {
54 pub project_hash: Option<String>,
55 pub facts: Vec<KnowledgeFactMini>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct KnowledgeFactMini {
60 pub category: String,
61 pub key: String,
62 pub value: String,
63 pub confidence: f32,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CuratedRef {
68 pub path: String,
69 pub mode: String,
70 pub content_md5: String,
71 pub content: String,
72}
73
74#[derive(Debug, Clone)]
75pub struct CreateLedgerInput {
76 pub agent_id: Option<String>,
77 pub client_name: Option<String>,
78 pub project_root: Option<String>,
79 pub session: SessionState,
80 pub tool_calls: Vec<ToolCallRecord>,
81 pub workflow: Option<WorkflowRun>,
82 pub curated_refs: Vec<(String, String)>, }
84
85pub fn create_ledger(input: CreateLedgerInput) -> Result<(HandoffLedgerV1, PathBuf), String> {
86 let manifest_md5 = manifest_md5();
87
88 let mut evidence_keys: BTreeSet<String> = BTreeSet::new();
89 for ev in &input.session.evidence {
90 evidence_keys.insert(ev.key.clone());
91 }
92
93 let mut by_tool: BTreeMap<String, u64> = BTreeMap::new();
94 let mut by_mode: BTreeMap<String, u64> = BTreeMap::new();
95 for call in &input.tool_calls {
96 *by_tool.entry(call.tool.clone()).or_insert(0) += 1;
97 if call.tool == "ctx_read" {
98 if let Some(m) = call.mode.as_deref() {
99 *by_mode.entry(m.to_string()).or_insert(0) += 1;
100 }
101 }
102 }
103
104 let session_excerpt = SessionExcerpt {
105 id: input.session.id.clone(),
106 task: input.session.task.as_ref().map(|t| t.description.clone()),
107 decisions: input
108 .session
109 .decisions
110 .iter()
111 .rev()
112 .take(10)
113 .map(|d| d.summary.clone())
114 .collect::<Vec<_>>()
115 .into_iter()
116 .rev()
117 .collect(),
118 findings: input
119 .session
120 .findings
121 .iter()
122 .rev()
123 .take(20)
124 .map(|f| f.summary.clone())
125 .collect::<Vec<_>>()
126 .into_iter()
127 .rev()
128 .collect(),
129 next_steps: input.session.next_steps.iter().take(20).cloned().collect(),
130 };
131
132 let knowledge_excerpt = build_knowledge_excerpt(input.project_root.as_deref());
133
134 let mut curated = Vec::new();
135 for (p, text) in input.curated_refs.into_iter().take(MAX_CURATED_REFS) {
136 let md5 = md5_hex(text.as_bytes());
137 curated.push(CuratedRef {
138 path: p,
139 mode: "signatures".to_string(),
140 content_md5: md5,
141 content: text,
142 });
143 }
144
145 let mut ledger = HandoffLedgerV1 {
146 schema_version: SCHEMA_VERSION,
147 created_at: chrono::Local::now().to_rfc3339(),
148 content_md5: String::new(),
149 manifest_md5,
150 project_root: input.project_root,
151 agent_id: input.agent_id,
152 client_name: input.client_name,
153 workflow: input.workflow,
154 session_snapshot: input.session.build_compaction_snapshot(),
155 session: session_excerpt,
156 tool_calls: ToolCallsSummary {
157 total: input.tool_calls.len(),
158 by_tool,
159 by_ctx_read_mode: by_mode,
160 },
161 evidence_keys: evidence_keys.into_iter().collect(),
162 knowledge: knowledge_excerpt,
163 curated_refs: curated,
164 active_overlays: {
165 let overlay_store = crate::core::context_overlay::OverlayStore::load_project(
166 &std::env::current_dir().unwrap_or_default(),
167 );
168 overlay_store
169 .all()
170 .iter()
171 .filter_map(|o| serde_json::to_value(o).ok())
172 .collect()
173 },
174 };
175
176 let md5 = ledger_content_md5(&ledger);
177 ledger.content_md5.clone_from(&md5);
178
179 let path = ledger_path(&ledger.created_at, &md5)?;
180 let json = serde_json::to_string_pretty(&ledger).map_err(|e| format!("serialize: {e}"))?;
181 crate::config_io::write_atomic_with_backup(&path, &(json + "\n"))
182 .map_err(|e| format!("write {}: {e}", path.display()))?;
183
184 Ok((ledger, path))
185}
186
187pub fn list_ledgers() -> Vec<PathBuf> {
188 let dir = handoffs_dir().ok();
189 let Some(dir) = dir else {
190 return Vec::new();
191 };
192 let Ok(rd) = std::fs::read_dir(&dir) else {
193 return Vec::new();
194 };
195 let mut items: Vec<PathBuf> = rd
196 .flatten()
197 .map(|e| e.path())
198 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
199 .collect();
200 items.sort();
201 items.reverse();
202 items
203}
204
205pub fn load_ledger(path: &Path) -> Result<HandoffLedgerV1, String> {
206 let s = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
207 serde_json::from_str(&s).map_err(|e| format!("parse {}: {e}", path.display()))
208}
209
210pub fn compute_content_md5_for_ledger(ledger: &HandoffLedgerV1) -> String {
211 ledger_content_md5(ledger)
212}
213
214pub fn clear_ledgers() -> Result<u32, String> {
215 let dir = handoffs_dir()?;
216 let mut removed = 0u32;
217 if let Ok(rd) = std::fs::read_dir(&dir) {
218 for e in rd.flatten() {
219 let p = e.path();
220 if p.extension().and_then(|e| e.to_str()) != Some("json") {
221 continue;
222 }
223 if std::fs::remove_file(&p).is_ok() {
224 removed += 1;
225 }
226 }
227 }
228 Ok(removed)
229}
230
231fn build_knowledge_excerpt(project_root: Option<&str>) -> KnowledgeExcerpt {
232 let Some(root) = project_root else {
233 return KnowledgeExcerpt::default();
234 };
235 let Some(knowledge) = ProjectKnowledge::load(root) else {
236 return KnowledgeExcerpt::default();
237 };
238
239 let mut facts = Vec::new();
240 for f in knowledge.facts.iter().filter(|f| f.is_current()) {
241 facts.push(KnowledgeFactMini {
242 category: f.category.clone(),
243 key: f.key.clone(),
244 value: f.value.clone(),
245 confidence: f.confidence,
246 });
247 if facts.len() >= MAX_KNOWLEDGE_FACTS {
248 break;
249 }
250 }
251
252 KnowledgeExcerpt {
253 project_hash: Some(knowledge.project_hash.clone()),
254 facts,
255 }
256}
257
258fn ledger_path(created_at: &str, md5: &str) -> Result<PathBuf, String> {
259 let dir = handoffs_dir()?;
260 std::fs::create_dir_all(&dir).map_err(|e| format!("create_dir_all {}: {e}", dir.display()))?;
261 let ts = created_at
262 .chars()
263 .filter(char::is_ascii_digit)
264 .take(14)
265 .collect::<String>();
266 let name = format!("{ts}-{md5}.json");
267 Ok(dir.join(name))
268}
269
270fn handoffs_dir() -> Result<PathBuf, String> {
271 let dir = crate::core::data_dir::lean_ctx_data_dir()
272 .map_err(|e| e.clone())?
273 .join("handoffs");
274 Ok(dir)
275}
276
277fn manifest_md5() -> String {
278 let v = crate::core::mcp_manifest::manifest_value();
279 let canon = canonicalize_json(&v);
280 md5_hex(canon.to_string().as_bytes())
281}
282
283fn ledger_content_md5(ledger: &HandoffLedgerV1) -> String {
284 let mut tmp = ledger.clone();
285 tmp.content_md5.clear();
286 let v = serde_json::to_value(&tmp).unwrap_or(Value::Null);
287 let canon = canonicalize_json(&v);
288 md5_hex(canon.to_string().as_bytes())
289}
290
291fn md5_hex(bytes: &[u8]) -> String {
292 use md5::{Digest, Md5};
293 let mut hasher = Md5::new();
294 hasher.update(bytes);
295 format!("{:x}", hasher.finalize())
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct HandoffPackage {
300 pub ledger: HandoffLedgerV1,
301 pub intent: Option<IntentSnapshot>,
302 pub context_snapshot: Option<ContextSnapshot>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct IntentSnapshot {
307 pub task_type: String,
308 pub scope: String,
309 pub targets: Vec<String>,
310 pub keywords: Vec<String>,
311 pub language_hint: Option<String>,
312 pub urgency: f64,
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct ContextSnapshot {
317 pub window_size: usize,
318 pub tokens_used: usize,
319 pub tokens_saved: usize,
320 pub files_loaded: Vec<LoadedFileInfo>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct LoadedFileInfo {
325 pub path: String,
326 pub mode: String,
327 pub tokens: usize,
328}
329
330impl HandoffPackage {
331 pub fn build(
332 ledger: HandoffLedgerV1,
333 intent: Option<&super::intent_engine::StructuredIntent>,
334 context: Option<&crate::core::context_ledger::ContextLedger>,
335 ) -> Self {
336 let intent_snap = intent.map(|i| IntentSnapshot {
337 task_type: i.task_type.as_str().to_string(),
338 scope: match i.scope {
339 super::intent_engine::IntentScope::SingleFile => "single_file",
340 super::intent_engine::IntentScope::MultiFile => "multi_file",
341 super::intent_engine::IntentScope::CrossModule => "cross_module",
342 super::intent_engine::IntentScope::ProjectWide => "project_wide",
343 }
344 .to_string(),
345 targets: i.targets.clone(),
346 keywords: i.keywords.clone(),
347 language_hint: i.language_hint.clone(),
348 urgency: i.urgency,
349 });
350
351 let ctx_snap = context.map(|c| ContextSnapshot {
352 window_size: c.window_size,
353 tokens_used: c.total_tokens_sent,
354 tokens_saved: c.total_tokens_saved,
355 files_loaded: c
356 .entries
357 .iter()
358 .map(|e| LoadedFileInfo {
359 path: e.path.clone(),
360 mode: e.mode.clone(),
361 tokens: e.sent_tokens,
362 })
363 .collect(),
364 });
365
366 HandoffPackage {
367 ledger,
368 intent: intent_snap,
369 context_snapshot: ctx_snap,
370 }
371 }
372
373 pub fn format_compact(&self) -> String {
374 let mut out = String::new();
375
376 out.push_str("--- HANDOFF ---\n");
377 if let Some(ref intent) = self.intent {
378 out.push_str(&format!(
379 "TASK: {} (scope: {}, conf: {})\n",
380 intent.task_type,
381 intent.scope,
382 if intent.urgency > 0.5 {
383 "URGENT"
384 } else {
385 "normal"
386 }
387 ));
388 if !intent.targets.is_empty() {
389 out.push_str(&format!("TARGETS: {}\n", intent.targets.join(", ")));
390 }
391 if let Some(ref lang) = intent.language_hint {
392 out.push_str(&format!("LANG: {lang}\n"));
393 }
394 }
395
396 if let Some(ref ctx) = self.context_snapshot {
397 out.push_str(&format!(
398 "CTX: {}/{} tokens, {} files, saved {}\n",
399 ctx.tokens_used,
400 ctx.window_size,
401 ctx.files_loaded.len(),
402 ctx.tokens_saved,
403 ));
404 }
405
406 if !self.ledger.session.decisions.is_empty() {
407 out.push_str("DECISIONS:\n");
408 for d in &self.ledger.session.decisions {
409 out.push_str(&format!(" - {d}\n"));
410 }
411 }
412
413 if !self.ledger.session.findings.is_empty() {
414 out.push_str("FINDINGS:\n");
415 for f in self.ledger.session.findings.iter().take(5) {
416 out.push_str(&format!(" - {f}\n"));
417 }
418 }
419
420 if !self.ledger.session.next_steps.is_empty() {
421 out.push_str("NEXT:\n");
422 for s in &self.ledger.session.next_steps {
423 out.push_str(&format!(" - {s}\n"));
424 }
425 }
426
427 out.push_str("---\n");
428 out
429 }
430}
431
432fn canonicalize_json(v: &Value) -> Value {
433 match v {
434 Value::Object(map) => {
435 let mut keys: Vec<&String> = map.keys().collect();
436 keys.sort();
437 let mut out = serde_json::Map::new();
438 for k in keys {
439 if let Some(val) = map.get(k) {
440 out.insert(k.clone(), canonicalize_json(val));
441 }
442 }
443 Value::Object(out)
444 }
445 Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
446 other => other.clone(),
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use crate::core::data_dir::test_env_lock;
454 use tempfile::tempdir;
455
456 struct EnvVarGuard {
457 key: &'static str,
458 previous: Option<String>,
459 }
460
461 impl EnvVarGuard {
462 fn set(key: &'static str, value: &str) -> Self {
463 let previous = std::env::var(key).ok();
464 std::env::set_var(key, value);
465 Self { key, previous }
466 }
467 }
468
469 impl Drop for EnvVarGuard {
470 fn drop(&mut self) {
471 if let Some(ref previous) = self.previous {
472 std::env::set_var(self.key, previous);
473 } else {
474 std::env::remove_var(self.key);
475 }
476 }
477 }
478
479 fn sample_session() -> SessionState {
480 let mut session = SessionState::new();
481 session.set_task("stabilize handoff ledger tests", None);
482 session.add_decision("Keep ledger deterministic", None);
483 session.add_finding(
484 Some("rust/src/core/handoff_ledger.rs"),
485 Some(42),
486 "Edge case coverage extended",
487 );
488 session.record_manual_evidence("tool:ctx_read", Some("handoff_ledger.rs"));
489 session
490 }
491
492 fn sample_tool_calls() -> Vec<ToolCallRecord> {
493 vec![
494 ToolCallRecord {
495 tool: "ctx_read".to_string(),
496 original_tokens: 900,
497 saved_tokens: 700,
498 mode: Some("map".to_string()),
499 duration_ms: 12,
500 timestamp: chrono::Utc::now().to_rfc3339(),
501 },
502 ToolCallRecord {
503 tool: "ctx_shell".to_string(),
504 original_tokens: 120,
505 saved_tokens: 60,
506 mode: None,
507 duration_ms: 8,
508 timestamp: chrono::Utc::now().to_rfc3339(),
509 },
510 ]
511 }
512
513 fn make_minimal_ledger() -> HandoffLedgerV1 {
514 HandoffLedgerV1 {
515 schema_version: SCHEMA_VERSION,
516 created_at: "20260429T000000".to_string(),
517 content_md5: String::new(),
518 manifest_md5: "manifest".to_string(),
519 project_root: None,
520 agent_id: Some("agent-test".to_string()),
521 client_name: Some("cursor".to_string()),
522 workflow: None,
523 session_snapshot: "snapshot".to_string(),
524 session: SessionExcerpt::default(),
525 tool_calls: ToolCallsSummary::default(),
526 evidence_keys: Vec::new(),
527 knowledge: KnowledgeExcerpt::default(),
528 curated_refs: Vec::new(),
529 active_overlays: Vec::new(),
530 }
531 }
532
533 #[test]
534 fn create_load_list_clear_ledger_roundtrip() {
535 let _env_lock = test_env_lock();
536 let tmp = tempdir().expect("tempdir");
537 let data_dir = tmp.path().join("leanctx-data");
538 let _env = EnvVarGuard::set(
539 "LEAN_CTX_DATA_DIR",
540 data_dir.to_str().expect("data dir utf8"),
541 );
542
543 let input = CreateLedgerInput {
544 agent_id: Some("agent-1".to_string()),
545 client_name: Some("cursor".to_string()),
546 project_root: Some(tmp.path().to_string_lossy().to_string()),
547 session: sample_session(),
548 tool_calls: sample_tool_calls(),
549 workflow: None,
550 curated_refs: vec![("src/lib.rs".to_string(), "fn alpha() {}".to_string())],
551 };
552
553 let (ledger, path) = create_ledger(input).expect("create ledger");
554 assert!(path.exists(), "ledger file should be created");
555 assert_eq!(ledger.tool_calls.total, 2);
556 assert_eq!(ledger.tool_calls.by_tool.get("ctx_read"), Some(&1));
557 assert_eq!(ledger.tool_calls.by_ctx_read_mode.get("map"), Some(&1));
558 assert_eq!(ledger.curated_refs.len(), 1);
559 assert!(!ledger.content_md5.is_empty());
560
561 let listed = list_ledgers();
562 assert!(listed.iter().any(|p| p == &path));
563
564 let loaded = load_ledger(&path).expect("load ledger");
565 assert_eq!(loaded.content_md5, ledger.content_md5);
566 assert_eq!(loaded.session.decisions.len(), 1);
567 assert!(loaded.evidence_keys.iter().any(|k| k == "tool:ctx_read"));
568
569 let removed = clear_ledgers().expect("clear ledgers");
570 assert!(removed >= 1);
571 assert!(list_ledgers().is_empty());
572 }
573
574 #[test]
575 fn create_ledger_truncates_curated_refs_to_max() {
576 let _env_lock = test_env_lock();
577 let tmp = tempdir().expect("tempdir");
578 let data_dir = tmp.path().join("leanctx-data");
579 let _env = EnvVarGuard::set(
580 "LEAN_CTX_DATA_DIR",
581 data_dir.to_str().expect("data dir utf8"),
582 );
583
584 let curated_refs: Vec<(String, String)> = (0..(MAX_CURATED_REFS + 5))
585 .map(|idx| (format!("src/file_{idx}.rs"), format!("fn f_{idx}() {{}}")))
586 .collect();
587
588 let input = CreateLedgerInput {
589 agent_id: Some("agent-1".to_string()),
590 client_name: Some("cursor".to_string()),
591 project_root: Some(tmp.path().to_string_lossy().to_string()),
592 session: sample_session(),
593 tool_calls: sample_tool_calls(),
594 workflow: None,
595 curated_refs,
596 };
597
598 let (ledger, _) = create_ledger(input).expect("create ledger");
599 assert_eq!(ledger.curated_refs.len(), MAX_CURATED_REFS);
600 }
601
602 #[test]
603 fn canonicalization_and_content_md5_are_deterministic() {
604 let value = serde_json::json!({"b": 1, "a": {"d": 2, "c": 3}});
605 let canonical = canonicalize_json(&value);
606 assert_eq!(canonical.to_string(), r#"{"a":{"c":3,"d":2},"b":1}"#);
607
608 let mut ledger = make_minimal_ledger();
609 ledger.content_md5 = "old-value".to_string();
610 let md5_a = ledger_content_md5(&ledger);
611 ledger.content_md5 = "new-value".to_string();
612 let md5_b = ledger_content_md5(&ledger);
613 assert_eq!(md5_a, md5_b);
614 }
615}