Skip to main content

lean_ctx/core/context_package/
export.rs

1//! Export a context package from the current session (#293).
2
3use std::path::{Path, PathBuf};
4
5use chrono::Utc;
6
7use super::bundle::{ContextPackage, FORMAT_VERSION, KnowledgeFact, PackageMetadata, SessionSlice};
8use crate::core::session::SessionState;
9use crate::core::session_summary;
10
11/// Export the current session as a context package (JSON file).
12///
13/// Returns the path where the package was written.
14pub fn save_package(
15    session: &SessionState,
16    project_root: &str,
17    agent_id: Option<&str>,
18    description: Option<&str>,
19    output_path: Option<&Path>,
20) -> Result<PathBuf, String> {
21    let pkg = build_package(session, project_root, agent_id, description);
22    let json = serde_json::to_string_pretty(&pkg).map_err(|e| e.to_string())?;
23    // A package is a portable derived store (findings, decisions, knowledge):
24    // admitted as a whole, and only written if it still parses (G5, E3).
25    let json = crate::core::context_admission::recovery::admit_for_storage(&json)
26        .filter(|admitted| serde_json::from_str::<ContextPackage>(admitted).is_ok())
27        .ok_or("package withheld by the context gateway")?;
28
29    let path = output_path.map_or_else(|| default_path(project_root, &session.id), PathBuf::from);
30
31    // The destination can come from the agent (jailed by the caller). The
32    // jail does not resolve a dangling symlink, so the write itself must not
33    // follow one: a symlinked component below the project, dangling or not,
34    // is refused instead of redirecting the package out of it.
35    crate::core::pathjail_write::write_requested_below(
36        Path::new(project_root),
37        &path,
38        &path,
39        json.as_bytes(),
40    )?;
41    Ok(path)
42}
43
44fn build_package(
45    session: &SessionState,
46    project_root: &str,
47    agent_id: Option<&str>,
48    description: Option<&str>,
49) -> ContextPackage {
50    let summaries = session_summary::list(project_root);
51    let knowledge = load_knowledge_facts(project_root);
52
53    ContextPackage {
54        format_version: FORMAT_VERSION,
55        created_at: Utc::now(),
56        project_root: project_root.to_string(),
57        session_id: session.id.clone(),
58        metadata: PackageMetadata {
59            agent_id: agent_id.map(str::to_string),
60            description: description.map(str::to_string),
61            tool_calls: session.stats.total_tool_calls,
62            tokens_saved: session.stats.total_tokens_saved,
63        },
64        session: SessionSlice {
65            task: session.task.clone(),
66            findings: session.findings.clone(),
67            decisions: session.decisions.clone(),
68            files: session.files_touched.clone(),
69            next_steps: session.next_steps.clone(),
70            test_results: session.test_results.clone(),
71        },
72        summaries,
73        knowledge,
74    }
75}
76
77fn load_knowledge_facts(project_root: &str) -> Vec<KnowledgeFact> {
78    let Some(pk) = crate::core::knowledge::ProjectKnowledge::load(project_root) else {
79        return Vec::new();
80    };
81    pk.facts
82        .iter()
83        .filter(|f| f.is_current() && f.confidence >= 0.5)
84        .take(100)
85        .map(|f| KnowledgeFact {
86            origin: f.origin.clone(),
87            category: f.category.clone(),
88            key: f.key.clone(),
89            value: f.value.clone(),
90            confidence: f.confidence,
91            created_at: f.created_at,
92        })
93        .collect()
94}
95
96fn default_path(project_root: &str, session_id: &str) -> PathBuf {
97    let hash = crate::core::project_hash::hash_project_root(project_root);
98    let dir = crate::core::data_dir::lean_ctx_data_dir()
99        .unwrap_or_else(|_| PathBuf::from(".lean-ctx"))
100        .join("packages")
101        .join(hash);
102    let short = session_id.split('-').next().unwrap_or(session_id);
103    dir.join(format!("{short}.ctx.json"))
104}