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 = crate::core::hasher::hash_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    crate::core::hasher::hash_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    crate::core::hasher::hash_hex(canon.to_string().as_bytes())
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct HandoffPackage {
293    pub ledger: HandoffLedgerV1,
294    pub intent: Option<IntentSnapshot>,
295    pub context_snapshot: Option<ContextSnapshot>,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct IntentSnapshot {
300    pub task_type: String,
301    pub scope: String,
302    pub targets: Vec<String>,
303    pub keywords: Vec<String>,
304    pub language_hint: Option<String>,
305    pub urgency: f64,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct ContextSnapshot {
310    pub window_size: usize,
311    pub tokens_used: usize,
312    pub tokens_saved: usize,
313    pub files_loaded: Vec<LoadedFileInfo>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct LoadedFileInfo {
318    pub path: String,
319    pub mode: String,
320    pub tokens: usize,
321}
322
323impl HandoffPackage {
324    pub fn build(
325        ledger: HandoffLedgerV1,
326        intent: Option<&super::intent_engine::StructuredIntent>,
327        context: Option<&crate::core::context_ledger::ContextLedger>,
328    ) -> Self {
329        let intent_snap = intent.map(|i| IntentSnapshot {
330            task_type: i.task_type.as_str().to_string(),
331            scope: match i.scope {
332                super::intent_engine::IntentScope::SingleFile => "single_file",
333                super::intent_engine::IntentScope::MultiFile => "multi_file",
334                super::intent_engine::IntentScope::CrossModule => "cross_module",
335                super::intent_engine::IntentScope::ProjectWide => "project_wide",
336            }
337            .to_string(),
338            targets: i.targets.clone(),
339            keywords: i.keywords.clone(),
340            language_hint: i.language_hint.clone(),
341            urgency: i.urgency,
342        });
343
344        let ctx_snap = context.map(|c| ContextSnapshot {
345            window_size: c.window_size,
346            tokens_used: c.total_tokens_sent,
347            tokens_saved: c.total_tokens_saved,
348            files_loaded: c
349                .entries
350                .iter()
351                .map(|e| LoadedFileInfo {
352                    path: e.path.clone(),
353                    mode: e.mode.clone(),
354                    tokens: e.sent_tokens,
355                })
356                .collect(),
357        });
358
359        HandoffPackage {
360            ledger,
361            intent: intent_snap,
362            context_snapshot: ctx_snap,
363        }
364    }
365
366    pub fn format_compact(&self) -> String {
367        let mut out = String::new();
368
369        out.push_str("--- HANDOFF ---\n");
370        if let Some(ref intent) = self.intent {
371            out.push_str(&format!(
372                "TASK: {} (scope: {}, conf: {})\n",
373                intent.task_type,
374                intent.scope,
375                if intent.urgency > 0.5 {
376                    "URGENT"
377                } else {
378                    "normal"
379                }
380            ));
381            if !intent.targets.is_empty() {
382                out.push_str(&format!("TARGETS: {}\n", intent.targets.join(", ")));
383            }
384            if let Some(ref lang) = intent.language_hint {
385                out.push_str(&format!("LANG: {lang}\n"));
386            }
387        }
388
389        if let Some(ref ctx) = self.context_snapshot {
390            out.push_str(&format!(
391                "CTX: {}/{} tokens, {} files, saved {}\n",
392                ctx.tokens_used,
393                ctx.window_size,
394                ctx.files_loaded.len(),
395                ctx.tokens_saved,
396            ));
397        }
398
399        if !self.ledger.session.decisions.is_empty() {
400            out.push_str("DECISIONS:\n");
401            for d in &self.ledger.session.decisions {
402                out.push_str(&format!("  - {d}\n"));
403            }
404        }
405
406        if !self.ledger.session.findings.is_empty() {
407            out.push_str("FINDINGS:\n");
408            for f in self.ledger.session.findings.iter().take(5) {
409                out.push_str(&format!("  - {f}\n"));
410            }
411        }
412
413        if !self.ledger.session.next_steps.is_empty() {
414            out.push_str("NEXT:\n");
415            for s in &self.ledger.session.next_steps {
416                out.push_str(&format!("  - {s}\n"));
417            }
418        }
419
420        out.push_str("---\n");
421        out
422    }
423}
424
425fn canonicalize_json(v: &Value) -> Value {
426    match v {
427        Value::Object(map) => {
428            let mut keys: Vec<&String> = map.keys().collect();
429            keys.sort();
430            let mut out = serde_json::Map::new();
431            for k in keys {
432                if let Some(val) = map.get(k) {
433                    out.insert(k.clone(), canonicalize_json(val));
434                }
435            }
436            Value::Object(out)
437        }
438        Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
439        other => other.clone(),
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::core::data_dir::test_env_lock;
447    use tempfile::tempdir;
448
449    struct EnvVarGuard {
450        key: &'static str,
451        previous: Option<String>,
452    }
453
454    impl EnvVarGuard {
455        fn set(key: &'static str, value: &str) -> Self {
456            let previous = std::env::var(key).ok();
457            std::env::set_var(key, value);
458            Self { key, previous }
459        }
460    }
461
462    impl Drop for EnvVarGuard {
463        fn drop(&mut self) {
464            if let Some(ref previous) = self.previous {
465                std::env::set_var(self.key, previous);
466            } else {
467                std::env::remove_var(self.key);
468            }
469        }
470    }
471
472    fn sample_session() -> SessionState {
473        let mut session = SessionState::new();
474        session.set_task("stabilize handoff ledger tests", None);
475        session.add_decision("Keep ledger deterministic", None);
476        session.add_finding(
477            Some("rust/src/core/handoff_ledger.rs"),
478            Some(42),
479            "Edge case coverage extended",
480        );
481        session.record_manual_evidence("tool:ctx_read", Some("handoff_ledger.rs"));
482        session
483    }
484
485    fn sample_tool_calls() -> Vec<ToolCallRecord> {
486        vec![
487            ToolCallRecord {
488                tool: "ctx_read".to_string(),
489                original_tokens: 900,
490                saved_tokens: 700,
491                mode: Some("map".to_string()),
492                duration_ms: 12,
493                timestamp: chrono::Utc::now().to_rfc3339(),
494            },
495            ToolCallRecord {
496                tool: "ctx_shell".to_string(),
497                original_tokens: 120,
498                saved_tokens: 60,
499                mode: None,
500                duration_ms: 8,
501                timestamp: chrono::Utc::now().to_rfc3339(),
502            },
503        ]
504    }
505
506    fn make_minimal_ledger() -> HandoffLedgerV1 {
507        HandoffLedgerV1 {
508            schema_version: SCHEMA_VERSION,
509            created_at: "20260429T000000".to_string(),
510            content_md5: String::new(),
511            manifest_md5: "manifest".to_string(),
512            project_root: None,
513            agent_id: Some("agent-test".to_string()),
514            client_name: Some("cursor".to_string()),
515            workflow: None,
516            session_snapshot: "snapshot".to_string(),
517            session: SessionExcerpt::default(),
518            tool_calls: ToolCallsSummary::default(),
519            evidence_keys: Vec::new(),
520            knowledge: KnowledgeExcerpt::default(),
521            curated_refs: Vec::new(),
522            active_overlays: Vec::new(),
523        }
524    }
525
526    #[test]
527    fn create_load_list_clear_ledger_roundtrip() {
528        let _env_lock = test_env_lock();
529        let tmp = tempdir().expect("tempdir");
530        let data_dir = tmp.path().join("leanctx-data");
531        let _env = EnvVarGuard::set(
532            "LEAN_CTX_DATA_DIR",
533            data_dir.to_str().expect("data dir utf8"),
534        );
535
536        let input = CreateLedgerInput {
537            agent_id: Some("agent-1".to_string()),
538            client_name: Some("cursor".to_string()),
539            project_root: Some(tmp.path().to_string_lossy().to_string()),
540            session: sample_session(),
541            tool_calls: sample_tool_calls(),
542            workflow: None,
543            curated_refs: vec![("src/lib.rs".to_string(), "fn alpha() {}".to_string())],
544        };
545
546        let (ledger, path) = create_ledger(input).expect("create ledger");
547        assert!(path.exists(), "ledger file should be created");
548        assert_eq!(ledger.tool_calls.total, 2);
549        assert_eq!(ledger.tool_calls.by_tool.get("ctx_read"), Some(&1));
550        assert_eq!(ledger.tool_calls.by_ctx_read_mode.get("map"), Some(&1));
551        assert_eq!(ledger.curated_refs.len(), 1);
552        assert!(!ledger.content_md5.is_empty());
553
554        let listed = list_ledgers();
555        assert!(listed.iter().any(|p| p == &path));
556
557        let loaded = load_ledger(&path).expect("load ledger");
558        assert_eq!(loaded.content_md5, ledger.content_md5);
559        assert_eq!(loaded.session.decisions.len(), 1);
560        assert!(loaded.evidence_keys.iter().any(|k| k == "tool:ctx_read"));
561
562        let removed = clear_ledgers().expect("clear ledgers");
563        assert!(removed >= 1);
564        assert!(list_ledgers().is_empty());
565    }
566
567    #[test]
568    fn create_ledger_truncates_curated_refs_to_max() {
569        let _env_lock = test_env_lock();
570        let tmp = tempdir().expect("tempdir");
571        let data_dir = tmp.path().join("leanctx-data");
572        let _env = EnvVarGuard::set(
573            "LEAN_CTX_DATA_DIR",
574            data_dir.to_str().expect("data dir utf8"),
575        );
576
577        let curated_refs: Vec<(String, String)> = (0..(MAX_CURATED_REFS + 5))
578            .map(|idx| (format!("src/file_{idx}.rs"), format!("fn f_{idx}() {{}}")))
579            .collect();
580
581        let input = CreateLedgerInput {
582            agent_id: Some("agent-1".to_string()),
583            client_name: Some("cursor".to_string()),
584            project_root: Some(tmp.path().to_string_lossy().to_string()),
585            session: sample_session(),
586            tool_calls: sample_tool_calls(),
587            workflow: None,
588            curated_refs,
589        };
590
591        let (ledger, _) = create_ledger(input).expect("create ledger");
592        assert_eq!(ledger.curated_refs.len(), MAX_CURATED_REFS);
593    }
594
595    #[test]
596    fn canonicalization_and_content_md5_are_deterministic() {
597        let value = serde_json::json!({"b": 1, "a": {"d": 2, "c": 3}});
598        let canonical = canonicalize_json(&value);
599        assert_eq!(canonical.to_string(), r#"{"a":{"c":3,"d":2},"b":1}"#);
600
601        let mut ledger = make_minimal_ledger();
602        ledger.content_md5 = "old-value".to_string();
603        let md5_a = ledger_content_md5(&ledger);
604        ledger.content_md5 = "new-value".to_string();
605        let md5_b = ledger_content_md5(&ledger);
606        assert_eq!(md5_a, md5_b);
607    }
608}