lean_ctx/core/context_package/
content.rs1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::core::knowledge::{ConsolidatedInsight, KnowledgeFact, ProjectPattern};
7
8use super::graph_model::ContextGraph;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct PackageContent {
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub knowledge: Option<KnowledgeLayer>,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub graph: Option<GraphLayer>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub session: Option<SessionLayer>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub patterns: Option<PatternsLayer>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub gotchas: Option<GotchasLayer>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub context_graph: Option<ContextGraph>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub addon: Option<AddonContent>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub documents: Option<DocumentsContent>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub checkpoint: Option<CheckpointPackageContentV1>,
39}
40
41pub(crate) const CHECKPOINT_PACKAGE_SCHEMA_V1: &str = "leanctx.ctxpkg-checkpoint/v1";
42pub(crate) const CHECKPOINT_PACKAGE_SCHEMA_V2: &str = "leanctx.ctxpkg-checkpoint/v2";
44pub const CHECKPOINT_PACKAGE_SCHEMA_V3: &str = "leanctx.ctxpkg-checkpoint/v3";
46pub const CHECKPOINT_PACKAGE_SCHEMA_V4: &str = "leanctx.ctxpkg-checkpoint/v4";
48pub(crate) const MAX_CHECKPOINT_PACKAGE_BYTES: usize = 8 * 1024 * 1024;
49pub(crate) const MAX_CHECKPOINT_SOURCES: usize = 128;
50pub(crate) const MAX_CHECKPOINT_ENTRIES: usize = 256;
51pub(crate) const MAX_CHECKPOINT_REFS: usize = 4096;
52pub(crate) const MAX_CHECKPOINT_PACKAGE_PINS: usize = 128;
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct CheckpointPackageContentV1 {
60 pub schema_version: String,
61 pub checkpoint: serde_json::Value,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub migration_provenance: Option<serde_json::Value>,
64 pub non_portable_fields: Vec<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct AddonContent {
74 pub manifest_toml: String,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub modules: Vec<DocumentBlob>,
92}
93
94pub(crate) const MAX_ADDON_MODULES: usize = 8;
98pub(crate) const MAX_ADDON_MODULE_BYTES: usize = 8 * 1024 * 1024;
99pub(crate) const MAX_ADDON_TOTAL_BYTES: usize = 16 * 1024 * 1024;
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
106pub struct DocumentsContent {
107 pub files: Vec<DocumentBlob>,
109}
110
111pub(crate) const DOCUMENT_ENCODING_ZSTD_B64: &str = "zstd+base64";
115
116pub(crate) const MAX_DOCUMENT_FILES: usize = 256;
119pub(crate) const MAX_DOCUMENT_FILE_BYTES: usize = 1024 * 1024;
120pub(crate) const MAX_DOCUMENTS_TOTAL_BYTES: usize = 8 * 1024 * 1024;
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct DocumentBlob {
127 pub path: String,
129 pub sha256: String,
131 pub encoding: String,
133 pub body: String,
135}
136
137impl DocumentBlob {
138 pub fn from_plaintext(path: &str, bytes: &[u8]) -> Result<Self, String> {
140 let compressed =
141 zstd::encode_all(bytes, 3).map_err(|e| format!("zstd compress {path}: {e}"))?;
142 Ok(Self {
143 path: path.to_string(),
144 sha256: sha256_hex_of(bytes),
145 encoding: DOCUMENT_ENCODING_ZSTD_B64.to_string(),
146 body: base64_encode(&compressed),
147 })
148 }
149
150 pub fn decode_verified(&self) -> Result<Vec<u8>, String> {
154 if self.encoding != DOCUMENT_ENCODING_ZSTD_B64 {
155 return Err(format!(
156 "`{}`: unsupported encoding `{}` (newer lean-ctx required)",
157 self.path, self.encoding
158 ));
159 }
160 let compressed = base64_decode(&self.body)
161 .map_err(|e| format!("`{}`: body is not valid base64: {e}", self.path))?;
162 let plain = zstd::bulk::decompress(&compressed, MAX_DOCUMENT_FILE_BYTES + 1)
165 .map_err(|e| format!("`{}`: zstd decompress failed: {e}", self.path))?;
166 if plain.len() > MAX_DOCUMENT_FILE_BYTES {
167 return Err(format!(
168 "`{}`: decoded size exceeds the {} byte cap",
169 self.path, MAX_DOCUMENT_FILE_BYTES
170 ));
171 }
172 let actual = sha256_hex_of(&plain);
173 if !actual.eq_ignore_ascii_case(&self.sha256) {
174 return Err(format!(
175 "`{}`: content hash mismatch — expected {}, got {actual} (tampered blob)",
176 self.path, self.sha256
177 ));
178 }
179 Ok(plain)
180 }
181}
182
183fn sha256_hex_of(bytes: &[u8]) -> String {
184 use sha2::{Digest, Sha256};
185 let mut h = Sha256::new();
186 h.update(bytes);
187 crate::core::agent_identity::hex_encode(&h.finalize())
188}
189
190fn base64_encode(bytes: &[u8]) -> String {
191 use base64::Engine;
192 base64::engine::general_purpose::STANDARD.encode(bytes)
193}
194
195fn base64_decode(text: &str) -> Result<Vec<u8>, String> {
196 use base64::Engine;
197 base64::engine::general_purpose::STANDARD
198 .decode(text.trim())
199 .map_err(|e| e.to_string())
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct KnowledgeLayer {
204 pub facts: Vec<KnowledgeFact>,
205 pub patterns: Vec<ProjectPattern>,
206 pub insights: Vec<ConsolidatedInsight>,
207 pub exported_at: DateTime<Utc>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct GraphLayer {
212 pub nodes: Vec<GraphNodeExport>,
213 pub edges: Vec<GraphEdgeExport>,
214 pub exported_at: DateTime<Utc>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct GraphNodeExport {
219 pub kind: String,
220 pub name: String,
221 pub file_path: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub line_start: Option<usize>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub line_end: Option<usize>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub metadata: Option<String>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GraphEdgeExport {
232 pub source_path: String,
233 pub source_name: String,
234 pub target_path: String,
235 pub target_name: String,
236 pub kind: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub metadata: Option<String>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct SessionLayer {
243 pub task_description: Option<String>,
244 pub findings: Vec<SessionFinding>,
245 pub decisions: Vec<SessionDecision>,
246 pub next_steps: Vec<String>,
247 pub files_touched: Vec<String>,
248 pub exported_at: DateTime<Utc>,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct SessionFinding {
253 pub summary: String,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub file: Option<String>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub line: Option<u32>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct SessionDecision {
262 pub summary: String,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub rationale: Option<String>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct PatternsLayer {
269 pub patterns: Vec<ProjectPattern>,
270 pub exported_at: DateTime<Utc>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct GotchasLayer {
275 pub gotchas: Vec<GotchaExport>,
276 pub exported_at: DateTime<Utc>,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct GotchaExport {
281 pub id: String,
282 pub category: String,
283 pub severity: String,
284 pub trigger: String,
285 pub resolution: String,
286 #[serde(default)]
287 pub file_patterns: Vec<String>,
288 pub confidence: f32,
289}
290
291impl PackageContent {
292 pub(crate) fn active_layer_count(&self) -> usize {
293 let mut n = 0;
294 if self.knowledge.is_some() {
295 n += 1;
296 }
297 if self.graph.is_some() {
298 n += 1;
299 }
300 if self.session.is_some() {
301 n += 1;
302 }
303 if self.patterns.is_some() {
304 n += 1;
305 }
306 if self.gotchas.is_some() {
307 n += 1;
308 }
309 if self.context_graph.is_some() {
310 n += 1;
311 }
312 if self.addon.is_some() {
313 n += 1;
314 }
315 if self.documents.is_some() {
316 n += 1;
317 }
318 if self.checkpoint.is_some() {
319 n += 1;
320 }
321 n
322 }
323
324 pub(crate) fn is_empty(&self) -> bool {
325 self.active_layer_count() == 0
326 }
327
328 pub fn estimated_token_count(&self) -> usize {
329 let json = serde_json::to_string(self).unwrap_or_default();
330 json.len() / 4
331 }
332}