Skip to main content

agentic_memory/v3/
block.rs

1//! Content-addressed, immutable blocks — the fundamental unit of V3 storage.
2
3use blake3::Hasher;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7/// A content-addressed, immutable block.
8/// Once written, never modified. Never deleted.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Block {
11    /// BLAKE3 hash of content (also serves as ID)
12    pub hash: BlockHash,
13
14    /// Hash of previous block (integrity chain)
15    pub prev_hash: BlockHash,
16
17    /// Sequence number (monotonic, gap-free)
18    pub sequence: u64,
19
20    /// When this block was created
21    pub timestamp: DateTime<Utc>,
22
23    /// Block type
24    pub block_type: BlockType,
25
26    /// The actual content
27    pub content: BlockContent,
28
29    /// Size in bytes (for budgeting)
30    pub size_bytes: u32,
31}
32
33/// 32-byte BLAKE3 hash
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub struct BlockHash(pub [u8; 32]);
36
37impl BlockHash {
38    pub fn compute(data: &[u8]) -> Self {
39        Self(*blake3::hash(data).as_bytes())
40    }
41
42    pub fn zero() -> Self {
43        Self([0u8; 32])
44    }
45
46    pub fn to_hex(&self) -> String {
47        hex::encode(self.0)
48    }
49
50    pub fn from_hex(s: &str) -> Option<Self> {
51        let bytes = hex::decode(s).ok()?;
52        if bytes.len() != 32 {
53            return None;
54        }
55        let mut arr = [0u8; 32];
56        arr.copy_from_slice(&bytes);
57        Some(Self(arr))
58    }
59}
60
61impl std::fmt::Display for BlockHash {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.to_hex())
64    }
65}
66
67/// Types of blocks we store
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum BlockType {
71    UserMessage,
72    AssistantMessage,
73    SystemMessage,
74    ToolCall,
75    ToolResult,
76    FileOperation,
77    Decision,
78    SessionBoundary,
79    Error,
80    Checkpoint,
81    Custom,
82}
83
84/// Block content variants
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum BlockContent {
88    /// Text content (messages)
89    Text {
90        text: String,
91        role: Option<String>,
92        tokens: Option<u32>,
93    },
94
95    /// Tool invocation
96    Tool {
97        tool_name: String,
98        input: serde_json::Value,
99        output: Option<serde_json::Value>,
100        duration_ms: Option<u64>,
101        success: bool,
102    },
103
104    /// File operation
105    File {
106        path: String,
107        operation: FileOperation,
108        content_hash: Option<BlockHash>,
109        lines: Option<u32>,
110        diff: Option<String>,
111    },
112
113    /// Decision record
114    Decision {
115        decision: String,
116        reasoning: Option<String>,
117        evidence_blocks: Vec<BlockHash>,
118        confidence: Option<f32>,
119    },
120
121    /// Session boundary
122    Boundary {
123        boundary_type: BoundaryType,
124        context_tokens_before: u32,
125        context_tokens_after: u32,
126        summary: String,
127        continuation_hint: Option<String>,
128    },
129
130    /// Error record
131    Error {
132        error_type: String,
133        message: String,
134        resolution: Option<String>,
135        resolved: bool,
136    },
137
138    /// Checkpoint (periodic state snapshot)
139    Checkpoint {
140        active_files: Vec<String>,
141        working_context: String,
142        pending_tasks: Vec<String>,
143    },
144
145    /// Raw bytes (for binary content)
146    Binary {
147        #[serde(with = "base64_serde")]
148        data: Vec<u8>,
149        mime_type: String,
150    },
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum FileOperation {
156    Create,
157    Read,
158    Update,
159    Delete,
160    Rename,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "snake_case")]
165pub enum BoundaryType {
166    SessionStart,
167    SessionEnd,
168    Compaction,
169    ContextPressure,
170    UserRequested,
171    Checkpoint,
172}
173
174impl Block {
175    /// Create a new block
176    pub fn new(
177        prev_hash: BlockHash,
178        sequence: u64,
179        block_type: BlockType,
180        content: BlockContent,
181    ) -> Self {
182        let timestamp = Utc::now();
183        let content_bytes = serde_json::to_vec(&content).unwrap_or_default();
184        let size_bytes = content_bytes.len() as u32;
185
186        // Hash includes: prev_hash + sequence + timestamp + content
187        let mut hasher = Hasher::new();
188        hasher.update(&prev_hash.0);
189        hasher.update(&sequence.to_le_bytes());
190        hasher.update(timestamp.to_rfc3339().as_bytes());
191        hasher.update(&content_bytes);
192        let hash = BlockHash(*hasher.finalize().as_bytes());
193
194        Self {
195            hash,
196            prev_hash,
197            sequence,
198            timestamp,
199            block_type,
200            content,
201            size_bytes,
202        }
203    }
204
205    /// Verify block integrity
206    pub fn verify(&self) -> bool {
207        let content_bytes = serde_json::to_vec(&self.content).unwrap_or_default();
208
209        let mut hasher = Hasher::new();
210        hasher.update(&self.prev_hash.0);
211        hasher.update(&self.sequence.to_le_bytes());
212        hasher.update(self.timestamp.to_rfc3339().as_bytes());
213        hasher.update(&content_bytes);
214        let computed = BlockHash(*hasher.finalize().as_bytes());
215
216        computed == self.hash
217    }
218
219    /// Get a short summary of block content (for display).
220    pub fn content_summary(&self) -> String {
221        let full = match &self.content {
222            BlockContent::Text { text, role, .. } => {
223                format!("[{}] {}", role.as_deref().unwrap_or("text"), text)
224            }
225            BlockContent::Tool {
226                tool_name, success, ..
227            } => {
228                format!(
229                    "tool:{} ({})",
230                    tool_name,
231                    if *success { "ok" } else { "err" }
232                )
233            }
234            BlockContent::File {
235                path, operation, ..
236            } => {
237                format!("{:?} {}", operation, path)
238            }
239            BlockContent::Decision {
240                decision,
241                confidence,
242                ..
243            } => {
244                format!(
245                    "Decision({:.0}%): {}",
246                    confidence.unwrap_or(0.0) * 100.0,
247                    decision
248                )
249            }
250            BlockContent::Boundary {
251                boundary_type,
252                summary,
253                ..
254            } => {
255                format!("{:?}: {}", boundary_type, summary)
256            }
257            BlockContent::Error {
258                error_type,
259                message,
260                resolved,
261                ..
262            } => {
263                format!(
264                    "{}:{} [{}]",
265                    error_type,
266                    message,
267                    if *resolved { "resolved" } else { "open" }
268                )
269            }
270            BlockContent::Checkpoint {
271                working_context, ..
272            } => {
273                format!("Checkpoint: {}", working_context)
274            }
275            BlockContent::Binary { mime_type, data } => {
276                format!("Binary({}, {} bytes)", mime_type, data.len())
277            }
278        };
279        // Truncate to 200 chars
280        if full.len() > 200 {
281            format!("{}...", &full[..200])
282        } else {
283            full
284        }
285    }
286
287    /// Extract text from block content (for indexing)
288    pub fn extract_text(&self) -> Option<String> {
289        match &self.content {
290            BlockContent::Text { text, .. } => Some(text.clone()),
291            BlockContent::Decision {
292                decision,
293                reasoning,
294                ..
295            } => Some(format!(
296                "{} {}",
297                decision,
298                reasoning.as_deref().unwrap_or("")
299            )),
300            BlockContent::Tool { tool_name, .. } => Some(tool_name.clone()),
301            BlockContent::File { path, .. } => Some(path.clone()),
302            BlockContent::Error { message, .. } => Some(message.clone()),
303            BlockContent::Boundary { summary, .. } => Some(summary.clone()),
304            BlockContent::Checkpoint {
305                working_context, ..
306            } => Some(working_context.clone()),
307            _ => None,
308        }
309    }
310}
311
312// Base64 serialization for binary data
313mod base64_serde {
314    use base64::{engine::general_purpose::STANDARD, Engine};
315    use serde::{Deserialize, Deserializer, Serializer};
316
317    pub fn serialize<S>(data: &[u8], serializer: S) -> Result<S::Ok, S::Error>
318    where
319        S: Serializer,
320    {
321        serializer.serialize_str(&STANDARD.encode(data))
322    }
323
324    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
325    where
326        D: Deserializer<'de>,
327    {
328        let s = String::deserialize(deserializer)?;
329        STANDARD.decode(&s).map_err(serde::de::Error::custom)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_block_creation_and_verify() {
339        let block = Block::new(
340            BlockHash::zero(),
341            0,
342            BlockType::UserMessage,
343            BlockContent::Text {
344                text: "Hello world".to_string(),
345                role: Some("user".to_string()),
346                tokens: Some(3),
347            },
348        );
349
350        assert!(block.verify());
351        assert_eq!(block.sequence, 0);
352        assert_eq!(block.prev_hash, BlockHash::zero());
353    }
354
355    #[test]
356    fn test_block_hash_hex_roundtrip() {
357        let hash = BlockHash::compute(b"test data");
358        let hex = hash.to_hex();
359        let recovered = BlockHash::from_hex(&hex).unwrap();
360        assert_eq!(hash, recovered);
361    }
362
363    #[test]
364    fn test_block_integrity_chain() {
365        let b0 = Block::new(
366            BlockHash::zero(),
367            0,
368            BlockType::UserMessage,
369            BlockContent::Text {
370                text: "First".to_string(),
371                role: None,
372                tokens: None,
373            },
374        );
375
376        let b1 = Block::new(
377            b0.hash,
378            1,
379            BlockType::AssistantMessage,
380            BlockContent::Text {
381                text: "Second".to_string(),
382                role: None,
383                tokens: None,
384            },
385        );
386
387        assert!(b0.verify());
388        assert!(b1.verify());
389        assert_eq!(b1.prev_hash, b0.hash);
390    }
391
392    #[test]
393    fn test_block_serialization() {
394        let block = Block::new(
395            BlockHash::zero(),
396            0,
397            BlockType::Decision,
398            BlockContent::Decision {
399                decision: "Use V3 architecture".to_string(),
400                reasoning: Some("Better persistence".to_string()),
401                evidence_blocks: vec![],
402                confidence: Some(0.95),
403            },
404        );
405
406        let json = serde_json::to_string(&block).unwrap();
407        let recovered: Block = serde_json::from_str(&json).unwrap();
408        assert_eq!(recovered.hash, block.hash);
409    }
410}