Skip to main content

lean_ctx/core/
handoff_ledger.rs

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)>, // (abs_path, signatures_text)
83}
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}