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
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)>, // (abs_path, signatures_text)
81}
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
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct HandoffPackage {
284    pub ledger: HandoffLedgerV1,
285    pub intent: Option<IntentSnapshot>,
286    pub context_snapshot: Option<ContextSnapshot>,
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct IntentSnapshot {
291    pub task_type: String,
292    pub scope: String,
293    pub targets: Vec<String>,
294    pub keywords: Vec<String>,
295    pub language_hint: Option<String>,
296    pub urgency: f64,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ContextSnapshot {
301    pub window_size: usize,
302    pub tokens_used: usize,
303    pub tokens_saved: usize,
304    pub files_loaded: Vec<LoadedFileInfo>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct LoadedFileInfo {
309    pub path: String,
310    pub mode: String,
311    pub tokens: usize,
312}
313
314impl HandoffPackage {
315    pub fn build(
316        ledger: HandoffLedgerV1,
317        intent: Option<&super::intent_engine::StructuredIntent>,
318        context: Option<&crate::core::context_ledger::ContextLedger>,
319    ) -> Self {
320        let intent_snap = intent.map(|i| IntentSnapshot {
321            task_type: i.task_type.as_str().to_string(),
322            scope: match i.scope {
323                super::intent_engine::IntentScope::SingleFile => "single_file",
324                super::intent_engine::IntentScope::MultiFile => "multi_file",
325                super::intent_engine::IntentScope::CrossModule => "cross_module",
326                super::intent_engine::IntentScope::ProjectWide => "project_wide",
327            }
328            .to_string(),
329            targets: i.targets.clone(),
330            keywords: i.keywords.clone(),
331            language_hint: i.language_hint.clone(),
332            urgency: i.urgency,
333        });
334
335        let ctx_snap = context.map(|c| ContextSnapshot {
336            window_size: c.window_size,
337            tokens_used: c.total_tokens_sent,
338            tokens_saved: c.total_tokens_saved,
339            files_loaded: c
340                .entries
341                .iter()
342                .map(|e| LoadedFileInfo {
343                    path: e.path.clone(),
344                    mode: e.mode.clone(),
345                    tokens: e.sent_tokens,
346                })
347                .collect(),
348        });
349
350        HandoffPackage {
351            ledger,
352            intent: intent_snap,
353            context_snapshot: ctx_snap,
354        }
355    }
356
357    pub fn format_compact(&self) -> String {
358        let mut out = String::new();
359
360        out.push_str("--- HANDOFF ---\n");
361        if let Some(ref intent) = self.intent {
362            out.push_str(&format!(
363                "TASK: {} (scope: {}, conf: {})\n",
364                intent.task_type,
365                intent.scope,
366                if intent.urgency > 0.5 {
367                    "URGENT"
368                } else {
369                    "normal"
370                }
371            ));
372            if !intent.targets.is_empty() {
373                out.push_str(&format!("TARGETS: {}\n", intent.targets.join(", ")));
374            }
375            if let Some(ref lang) = intent.language_hint {
376                out.push_str(&format!("LANG: {lang}\n"));
377            }
378        }
379
380        if let Some(ref ctx) = self.context_snapshot {
381            out.push_str(&format!(
382                "CTX: {}/{} tokens, {} files, saved {}\n",
383                ctx.tokens_used,
384                ctx.window_size,
385                ctx.files_loaded.len(),
386                ctx.tokens_saved,
387            ));
388        }
389
390        if !self.ledger.session.decisions.is_empty() {
391            out.push_str("DECISIONS:\n");
392            for d in &self.ledger.session.decisions {
393                out.push_str(&format!("  - {d}\n"));
394            }
395        }
396
397        if !self.ledger.session.findings.is_empty() {
398            out.push_str("FINDINGS:\n");
399            for f in self.ledger.session.findings.iter().take(5) {
400                out.push_str(&format!("  - {f}\n"));
401            }
402        }
403
404        if !self.ledger.session.next_steps.is_empty() {
405            out.push_str("NEXT:\n");
406            for s in &self.ledger.session.next_steps {
407                out.push_str(&format!("  - {s}\n"));
408            }
409        }
410
411        out.push_str("---\n");
412        out
413    }
414}
415
416fn canonicalize_json(v: &Value) -> Value {
417    match v {
418        Value::Object(map) => {
419            let mut keys: Vec<&String> = map.keys().collect();
420            keys.sort();
421            let mut out = serde_json::Map::new();
422            for k in keys {
423                if let Some(val) = map.get(k) {
424                    out.insert(k.clone(), canonicalize_json(val));
425                }
426            }
427            Value::Object(out)
428        }
429        Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()),
430        other => other.clone(),
431    }
432}