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 = 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}