use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::core::knowledge::{ConsolidatedInsight, KnowledgeFact, ProjectPattern};
use super::graph_model::ContextGraph;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub(crate) struct PackageContent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub knowledge: Option<KnowledgeLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<GraphLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<SessionLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub patterns: Option<PatternsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gotchas: Option<GotchasLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_graph: Option<ContextGraph>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub addon: Option<AddonContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documents: Option<DocumentsContent>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct AddonContent {
pub manifest_toml: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub(crate) struct DocumentsContent {
pub files: Vec<DocumentBlob>,
}
pub(crate) const DOCUMENT_ENCODING_ZSTD_B64: &str = "zstd+base64";
pub(crate) const MAX_DOCUMENT_FILES: usize = 256;
pub(crate) const MAX_DOCUMENT_FILE_BYTES: usize = 1024 * 1024;
pub(crate) const MAX_DOCUMENTS_TOTAL_BYTES: usize = 8 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct DocumentBlob {
pub path: String,
pub sha256: String,
pub encoding: String,
pub body: String,
}
impl DocumentBlob {
pub(crate) fn from_plaintext(path: &str, bytes: &[u8]) -> Result<Self, String> {
let compressed =
zstd::encode_all(bytes, 3).map_err(|e| format!("zstd compress {path}: {e}"))?;
Ok(Self {
path: path.to_string(),
sha256: sha256_hex_of(bytes),
encoding: DOCUMENT_ENCODING_ZSTD_B64.to_string(),
body: base64_encode(&compressed),
})
}
pub(crate) fn decode_verified(&self) -> Result<Vec<u8>, String> {
if self.encoding != DOCUMENT_ENCODING_ZSTD_B64 {
return Err(format!(
"`{}`: unsupported encoding `{}` (newer lean-ctx required)",
self.path, self.encoding
));
}
let compressed = base64_decode(&self.body)
.map_err(|e| format!("`{}`: body is not valid base64: {e}", self.path))?;
let plain = zstd::bulk::decompress(&compressed, MAX_DOCUMENT_FILE_BYTES + 1)
.map_err(|e| format!("`{}`: zstd decompress failed: {e}", self.path))?;
if plain.len() > MAX_DOCUMENT_FILE_BYTES {
return Err(format!(
"`{}`: decoded size exceeds the {} byte cap",
self.path, MAX_DOCUMENT_FILE_BYTES
));
}
let actual = sha256_hex_of(&plain);
if !actual.eq_ignore_ascii_case(&self.sha256) {
return Err(format!(
"`{}`: content hash mismatch — expected {}, got {actual} (tampered blob)",
self.path, self.sha256
));
}
Ok(plain)
}
}
fn sha256_hex_of(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
crate::core::agent_identity::hex_encode(&h.finalize())
}
fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn base64_decode(text: &str) -> Result<Vec<u8>, String> {
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(text.trim())
.map_err(|e| e.to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct KnowledgeLayer {
pub facts: Vec<KnowledgeFact>,
pub patterns: Vec<ProjectPattern>,
pub insights: Vec<ConsolidatedInsight>,
pub exported_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GraphLayer {
pub nodes: Vec<GraphNodeExport>,
pub edges: Vec<GraphEdgeExport>,
pub exported_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GraphNodeExport {
pub kind: String,
pub name: String,
pub file_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_start: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_end: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GraphEdgeExport {
pub source_path: String,
pub source_name: String,
pub target_path: String,
pub target_name: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionLayer {
pub task_description: Option<String>,
pub findings: Vec<SessionFinding>,
pub decisions: Vec<SessionDecision>,
pub next_steps: Vec<String>,
pub files_touched: Vec<String>,
pub exported_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionFinding {
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct SessionDecision {
pub summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PatternsLayer {
pub patterns: Vec<ProjectPattern>,
pub exported_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GotchasLayer {
pub gotchas: Vec<GotchaExport>,
pub exported_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct GotchaExport {
pub id: String,
pub category: String,
pub severity: String,
pub trigger: String,
pub resolution: String,
#[serde(default)]
pub file_patterns: Vec<String>,
pub confidence: f32,
}
impl PackageContent {
pub(crate) fn active_layer_count(&self) -> usize {
let mut n = 0;
if self.knowledge.is_some() {
n += 1;
}
if self.graph.is_some() {
n += 1;
}
if self.session.is_some() {
n += 1;
}
if self.patterns.is_some() {
n += 1;
}
if self.gotchas.is_some() {
n += 1;
}
if self.context_graph.is_some() {
n += 1;
}
if self.addon.is_some() {
n += 1;
}
if self.documents.is_some() {
n += 1;
}
n
}
pub(crate) fn is_empty(&self) -> bool {
self.active_layer_count() == 0
}
pub(crate) fn estimated_token_count(&self) -> usize {
let json = serde_json::to_string(self).unwrap_or_default();
json.len() / 4
}
}