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)>, }
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}