Skip to main content

lean_ctx/core/context_package/
content.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::core::knowledge::{ConsolidatedInsight, KnowledgeFact, ProjectPattern};
5
6use super::graph_model::ContextGraph;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct PackageContent {
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub knowledge: Option<KnowledgeLayer>,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub graph: Option<GraphLayer>,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub session: Option<SessionLayer>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub patterns: Option<PatternsLayer>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub gotchas: Option<GotchasLayer>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub context_graph: Option<ContextGraph>,
22    /// `kind=addon` payload (GH #724/#726): the embedded addon manifest.
23    /// Absent for every other kind — enforced by
24    /// [`super::verify::validate_kind_coherence`].
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub addon: Option<AddonContent>,
27    /// `kind=skills` payload (GH #724/#727): named, verified content blobs.
28    /// Absent for every other kind — enforced by
29    /// [`super::verify::validate_kind_coherence`].
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub documents: Option<DocumentsContent>,
32}
33
34/// Distribution view of an addon (unified distribution, GH #726): the
35/// authoring `lean-ctx-addon.toml` embedded **verbatim**. The TOML is the
36/// single source of truth — MCP wiring, capabilities, `[install]` bootstrap
37/// and the per-platform `[artifacts]` tables (GH #725) all live inside it,
38/// so nothing is duplicated at the pack layer that could drift.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct AddonContent {
41    /// Verbatim `lean-ctx-addon.toml` text (authoring contract
42    /// `docs/contracts/addon-manifest-v1.md`).
43    pub manifest_toml: String,
44}
45
46/// `kind=skills` payload (GH #727): a set of named, verified content blobs
47/// (markdown/scripts). **No execution semantics in lean-ctx** — skills are
48/// verified *content*; interpretation belongs to the consumer (an addon like
49/// lean-md, or the agent itself).
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
51pub struct DocumentsContent {
52    /// Sorted by `path` (byte order) — deterministic pack bytes (#498).
53    pub files: Vec<DocumentBlob>,
54}
55
56/// Body encoding marker for [`DocumentBlob::body`]. The only supported value;
57/// a field (not an enum) so future encodings fail with "unsupported encoding"
58/// on old readers instead of a serde parse error.
59pub const DOCUMENT_ENCODING_ZSTD_B64: &str = "zstd+base64";
60
61/// Per-file caps (plaintext bytes) — a skills pack is documentation and
62/// scripts, not a media archive.
63pub const MAX_DOCUMENT_FILES: usize = 256;
64pub const MAX_DOCUMENT_FILE_BYTES: usize = 1024 * 1024;
65pub const MAX_DOCUMENTS_TOTAL_BYTES: usize = 8 * 1024 * 1024;
66
67/// One named blob: `path` + SHA-256 of the **plaintext** + compressed body.
68/// The hash pins the decoded bytes, so tampering with the stored body (or a
69/// decompression bug) is detected before anything lands on disk.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct DocumentBlob {
72    /// Relative, `/`-separated path inside the pack (e.g. `skills/review.md`).
73    pub path: String,
74    /// SHA-256 (lowercase hex) of the plaintext bytes.
75    pub sha256: String,
76    /// Body encoding — currently always [`DOCUMENT_ENCODING_ZSTD_B64`].
77    pub encoding: String,
78    /// base64(zstd(plaintext)).
79    pub body: String,
80}
81
82impl DocumentBlob {
83    /// Build a blob from plaintext bytes (deterministic: fixed zstd level).
84    pub fn from_plaintext(path: &str, bytes: &[u8]) -> Result<Self, String> {
85        let compressed =
86            zstd::encode_all(bytes, 3).map_err(|e| format!("zstd compress {path}: {e}"))?;
87        Ok(Self {
88            path: path.to_string(),
89            sha256: sha256_hex_of(bytes),
90            encoding: DOCUMENT_ENCODING_ZSTD_B64.to_string(),
91            body: base64_encode(&compressed),
92        })
93    }
94
95    /// Decode and verify the body against its `sha256` pin. Any mismatch —
96    /// tampered body, wrong hash, corrupt compression — is an error; callers
97    /// never see unverified bytes.
98    pub fn decode_verified(&self) -> Result<Vec<u8>, String> {
99        if self.encoding != DOCUMENT_ENCODING_ZSTD_B64 {
100            return Err(format!(
101                "`{}`: unsupported encoding `{}` (newer lean-ctx required)",
102                self.path, self.encoding
103            ));
104        }
105        let compressed = base64_decode(&self.body)
106            .map_err(|e| format!("`{}`: body is not valid base64: {e}", self.path))?;
107        // Cap the decompressed size before allocating: a hostile blob must
108        // not zstd-bomb the installer.
109        let plain = zstd::bulk::decompress(&compressed, MAX_DOCUMENT_FILE_BYTES + 1)
110            .map_err(|e| format!("`{}`: zstd decompress failed: {e}", self.path))?;
111        if plain.len() > MAX_DOCUMENT_FILE_BYTES {
112            return Err(format!(
113                "`{}`: decoded size exceeds the {} byte cap",
114                self.path, MAX_DOCUMENT_FILE_BYTES
115            ));
116        }
117        let actual = sha256_hex_of(&plain);
118        if !actual.eq_ignore_ascii_case(&self.sha256) {
119            return Err(format!(
120                "`{}`: content hash mismatch — expected {}, got {actual} (tampered blob)",
121                self.path, self.sha256
122            ));
123        }
124        Ok(plain)
125    }
126}
127
128fn sha256_hex_of(bytes: &[u8]) -> String {
129    use sha2::{Digest, Sha256};
130    let mut h = Sha256::new();
131    h.update(bytes);
132    crate::core::agent_identity::hex_encode(&h.finalize())
133}
134
135fn base64_encode(bytes: &[u8]) -> String {
136    use base64::Engine;
137    base64::engine::general_purpose::STANDARD.encode(bytes)
138}
139
140fn base64_decode(text: &str) -> Result<Vec<u8>, String> {
141    use base64::Engine;
142    base64::engine::general_purpose::STANDARD
143        .decode(text.trim())
144        .map_err(|e| e.to_string())
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct KnowledgeLayer {
149    pub facts: Vec<KnowledgeFact>,
150    pub patterns: Vec<ProjectPattern>,
151    pub insights: Vec<ConsolidatedInsight>,
152    pub exported_at: DateTime<Utc>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct GraphLayer {
157    pub nodes: Vec<GraphNodeExport>,
158    pub edges: Vec<GraphEdgeExport>,
159    pub exported_at: DateTime<Utc>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct GraphNodeExport {
164    pub kind: String,
165    pub name: String,
166    pub file_path: String,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub line_start: Option<usize>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub line_end: Option<usize>,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub metadata: Option<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct GraphEdgeExport {
177    pub source_path: String,
178    pub source_name: String,
179    pub target_path: String,
180    pub target_name: String,
181    pub kind: String,
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub metadata: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SessionLayer {
188    pub task_description: Option<String>,
189    pub findings: Vec<SessionFinding>,
190    pub decisions: Vec<SessionDecision>,
191    pub next_steps: Vec<String>,
192    pub files_touched: Vec<String>,
193    pub exported_at: DateTime<Utc>,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct SessionFinding {
198    pub summary: String,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub file: Option<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub line: Option<u32>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct SessionDecision {
207    pub summary: String,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub rationale: Option<String>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct PatternsLayer {
214    pub patterns: Vec<ProjectPattern>,
215    pub exported_at: DateTime<Utc>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct GotchasLayer {
220    pub gotchas: Vec<GotchaExport>,
221    pub exported_at: DateTime<Utc>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct GotchaExport {
226    pub id: String,
227    pub category: String,
228    pub severity: String,
229    pub trigger: String,
230    pub resolution: String,
231    #[serde(default)]
232    pub file_patterns: Vec<String>,
233    pub confidence: f32,
234}
235
236impl PackageContent {
237    pub fn active_layer_count(&self) -> usize {
238        let mut n = 0;
239        if self.knowledge.is_some() {
240            n += 1;
241        }
242        if self.graph.is_some() {
243            n += 1;
244        }
245        if self.session.is_some() {
246            n += 1;
247        }
248        if self.patterns.is_some() {
249            n += 1;
250        }
251        if self.gotchas.is_some() {
252            n += 1;
253        }
254        if self.context_graph.is_some() {
255            n += 1;
256        }
257        if self.addon.is_some() {
258            n += 1;
259        }
260        if self.documents.is_some() {
261            n += 1;
262        }
263        n
264    }
265
266    pub fn is_empty(&self) -> bool {
267        self.active_layer_count() == 0
268    }
269
270    pub fn estimated_token_count(&self) -> usize {
271        let json = serde_json::to_string(self).unwrap_or_default();
272        json.len() / 4
273    }
274}