lean_ctx/core/context_package/
content.rs1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub addon: Option<AddonContent>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub documents: Option<DocumentsContent>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct AddonContent {
41 pub manifest_toml: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
51pub struct DocumentsContent {
52 pub files: Vec<DocumentBlob>,
54}
55
56pub const DOCUMENT_ENCODING_ZSTD_B64: &str = "zstd+base64";
60
61pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct DocumentBlob {
72 pub path: String,
74 pub sha256: String,
76 pub encoding: String,
78 pub body: String,
80}
81
82impl DocumentBlob {
83 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 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 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}