Skip to main content

engram/storage/
memory_blocks.rs

1//! Letta/MemGPT-inspired self-editing memory blocks.
2//!
3//! Memory blocks are named, versioned text slots that an AI agent can read and
4//! overwrite during a session.  Each write increments the version counter and
5//! appends a row to `block_edit_log` so the full rewrite history is preserved.
6//!
7//! # Design
8//! - One row per block name (PRIMARY KEY on `name`).
9//! - Overflow detection uses a rough 4 chars/token heuristic; if the content
10//!   exceeds `max_tokens * 4` bytes the excess is returned and the block is
11//!   truncated to the allowed length.
12//! - All timestamps follow the project-wide convention: RFC 3339 UTC strings.
13
14use rusqlite::{params, Connection, OptionalExtension};
15use serde::{Deserialize, Serialize};
16
17use crate::error::{EngramError, Result};
18
19// ─── DDL ─────────────────────────────────────────────────────────────────────
20
21/// SQL that creates the `memory_blocks` table.
22///
23/// Embed this in a migration when integrating with the main schema.
24pub const CREATE_MEMORY_BLOCKS_TABLE: &str = r#"
25CREATE TABLE IF NOT EXISTS memory_blocks (
26    name       TEXT    PRIMARY KEY,
27    content    TEXT    NOT NULL DEFAULT '',
28    version    INTEGER NOT NULL DEFAULT 1,
29    max_tokens INTEGER NOT NULL DEFAULT 4096,
30    created_at TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
31    updated_at TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
32);
33"#;
34
35/// SQL that creates the `block_edit_log` table.
36pub const CREATE_BLOCK_EDIT_LOG_TABLE: &str = r#"
37CREATE TABLE IF NOT EXISTS block_edit_log (
38    id          INTEGER PRIMARY KEY AUTOINCREMENT,
39    block_name  TEXT    NOT NULL,
40    old_content TEXT    NOT NULL,
41    new_content TEXT    NOT NULL,
42    edit_reason TEXT    NOT NULL DEFAULT '',
43    timestamp   TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
44    FOREIGN KEY (block_name) REFERENCES memory_blocks(name) ON DELETE CASCADE
45);
46"#;
47
48// ─── Types ───────────────────────────────────────────────────────────────────
49
50/// A named, versioned text slot.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct MemoryBlock {
53    pub name: String,
54    pub content: String,
55    pub version: i64,
56    pub max_tokens: usize,
57    pub created_at: String,
58    pub updated_at: String,
59}
60
61/// A single entry in the edit history for a block.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct BlockEditLog {
64    pub id: i64,
65    pub block_name: String,
66    pub old_content: String,
67    pub new_content: String,
68    pub edit_reason: String,
69    pub timestamp: String,
70}
71
72// ─── Helpers ─────────────────────────────────────────────────────────────────
73
74fn row_to_block(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryBlock> {
75    Ok(MemoryBlock {
76        name: row.get(0)?,
77        content: row.get(1)?,
78        version: row.get(2)?,
79        max_tokens: row.get::<_, i64>(3)? as usize,
80        created_at: row.get(4)?,
81        updated_at: row.get(5)?,
82    })
83}
84
85fn row_to_log(row: &rusqlite::Row<'_>) -> rusqlite::Result<BlockEditLog> {
86    Ok(BlockEditLog {
87        id: row.get(0)?,
88        block_name: row.get(1)?,
89        old_content: row.get(2)?,
90        new_content: row.get(3)?,
91        edit_reason: row.get(4)?,
92        timestamp: row.get(5)?,
93    })
94}
95
96// ─── Storage functions ───────────────────────────────────────────────────────
97
98/// Create a new memory block.
99///
100/// Returns an error if a block with the same `name` already exists.
101pub fn create_block(
102    conn: &Connection,
103    name: &str,
104    content: &str,
105    max_tokens: usize,
106) -> Result<MemoryBlock> {
107    if name.is_empty() {
108        return Err(EngramError::InvalidInput(
109            "block name must not be empty".into(),
110        ));
111    }
112
113    conn.execute(
114        r#"
115        INSERT INTO memory_blocks (name, content, version, max_tokens, created_at, updated_at)
116        VALUES (?, ?, 1, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
117        "#,
118        params![name, content, max_tokens as i64],
119    )?;
120
121    get_block_required(conn, name)
122}
123
124/// Retrieve a block by name, returning `None` when it does not exist.
125pub fn get_block(conn: &Connection, name: &str) -> Result<Option<MemoryBlock>> {
126    let result = conn
127        .query_row(
128            r#"
129            SELECT name, content, version, max_tokens, created_at, updated_at
130            FROM memory_blocks WHERE name = ?
131            "#,
132            params![name],
133            row_to_block,
134        )
135        .optional()?;
136    Ok(result)
137}
138
139/// Update a block's content, increment its version, and record the edit.
140///
141/// Returns an error when the block does not exist.
142pub fn update_block(
143    conn: &Connection,
144    name: &str,
145    new_content: &str,
146    reason: &str,
147) -> Result<MemoryBlock> {
148    let old = get_block(conn, name)?
149        .ok_or_else(|| EngramError::Storage(format!("memory block '{}' not found", name)))?;
150
151    conn.execute(
152        r#"
153        UPDATE memory_blocks
154        SET content    = ?,
155            version    = version + 1,
156            updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
157        WHERE name = ?
158        "#,
159        params![new_content, name],
160    )?;
161
162    conn.execute(
163        r#"
164        INSERT INTO block_edit_log (block_name, old_content, new_content, edit_reason, timestamp)
165        VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
166        "#,
167        params![name, old.content, new_content, reason],
168    )?;
169
170    get_block_required(conn, name)
171}
172
173/// Return all memory blocks ordered by name.
174pub fn list_blocks(conn: &Connection) -> Result<Vec<MemoryBlock>> {
175    let mut stmt = conn.prepare(
176        r#"
177        SELECT name, content, version, max_tokens, created_at, updated_at
178        FROM memory_blocks ORDER BY name
179        "#,
180    )?;
181    let blocks: rusqlite::Result<Vec<MemoryBlock>> = stmt.query_map([], row_to_block)?.collect();
182    Ok(blocks?)
183}
184
185/// Delete a block and its edit history.
186pub fn delete_block(conn: &Connection, name: &str) -> Result<()> {
187    let rows = conn.execute("DELETE FROM memory_blocks WHERE name = ?", params![name])?;
188    if rows == 0 {
189        return Err(EngramError::Storage(format!(
190            "memory block '{}' not found",
191            name
192        )));
193    }
194    Ok(())
195}
196
197/// Retrieve the edit history for a block, most-recent first.
198///
199/// `limit` caps the number of entries returned; pass `0` for all entries.
200pub fn get_block_history(conn: &Connection, name: &str, limit: usize) -> Result<Vec<BlockEditLog>> {
201    let sql = if limit > 0 {
202        format!(
203            r#"
204            SELECT id, block_name, old_content, new_content, edit_reason, timestamp
205            FROM block_edit_log WHERE block_name = ?
206            ORDER BY id DESC LIMIT {}
207            "#,
208            limit
209        )
210    } else {
211        r#"
212        SELECT id, block_name, old_content, new_content, edit_reason, timestamp
213        FROM block_edit_log WHERE block_name = ?
214        ORDER BY id DESC
215        "#
216        .to_string()
217    };
218
219    let mut stmt = conn.prepare(&sql)?;
220    let entries: rusqlite::Result<Vec<BlockEditLog>> =
221        stmt.query_map(params![name], row_to_log)?.collect();
222    Ok(entries?)
223}
224
225/// If the block's content exceeds its `max_tokens` budget (estimated at
226/// 4 chars/token), truncate the block to the allowed length, persist the
227/// truncated version with a log entry, and return the overflow text.
228///
229/// Returns `None` when the block is within budget (no write is performed).
230pub fn archive_overflow(conn: &Connection, name: &str) -> Result<Option<String>> {
231    let block = get_block(conn, name)?
232        .ok_or_else(|| EngramError::Storage(format!("memory block '{}' not found", name)))?;
233
234    let max_chars = block.max_tokens * 4;
235    if block.content.len() <= max_chars {
236        return Ok(None);
237    }
238
239    // Truncate at a char boundary.
240    let keep = &block.content[..max_chars];
241    let overflow = block.content[max_chars..].to_string();
242
243    update_block(conn, name, keep, "overflow archived")?;
244
245    Ok(Some(overflow))
246}
247
248// ─── Private helpers ─────────────────────────────────────────────────────────
249
250fn get_block_required(conn: &Connection, name: &str) -> Result<MemoryBlock> {
251    get_block(conn, name)?.ok_or_else(|| {
252        EngramError::Storage(format!("memory block '{}' unexpectedly missing", name))
253    })
254}
255
256// ─── Tests ───────────────────────────────────────────────────────────────────
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use rusqlite::Connection;
262
263    fn setup() -> Connection {
264        let conn = Connection::open_in_memory().unwrap();
265        conn.execute_batch(CREATE_MEMORY_BLOCKS_TABLE).unwrap();
266        conn.execute_batch(CREATE_BLOCK_EDIT_LOG_TABLE).unwrap();
267        conn
268    }
269
270    // 1. Create and get block
271    #[test]
272    fn test_create_and_get_block() {
273        let conn = setup();
274        let block = create_block(&conn, "persona", "I am a helpful assistant.", 512).unwrap();
275
276        assert_eq!(block.name, "persona");
277        assert_eq!(block.content, "I am a helpful assistant.");
278        assert_eq!(block.version, 1);
279        assert_eq!(block.max_tokens, 512);
280
281        let fetched = get_block(&conn, "persona").unwrap().unwrap();
282        assert_eq!(fetched.name, block.name);
283        assert_eq!(fetched.content, block.content);
284    }
285
286    // 2. Update increments version
287    #[test]
288    fn test_update_increments_version() {
289        let conn = setup();
290        create_block(&conn, "notes", "initial", 256).unwrap();
291
292        let v2 = update_block(&conn, "notes", "updated once", "first edit").unwrap();
293        assert_eq!(v2.version, 2);
294
295        let v3 = update_block(&conn, "notes", "updated twice", "second edit").unwrap();
296        assert_eq!(v3.version, 3);
297    }
298
299    // 3. Update logs to edit history
300    #[test]
301    fn test_update_logs_edit_history() {
302        let conn = setup();
303        create_block(&conn, "context", "old text", 256).unwrap();
304        update_block(&conn, "context", "new text", "test reason").unwrap();
305
306        let history = get_block_history(&conn, "context", 0).unwrap();
307        assert_eq!(history.len(), 1);
308        assert_eq!(history[0].old_content, "old text");
309        assert_eq!(history[0].new_content, "new text");
310        assert_eq!(history[0].edit_reason, "test reason");
311    }
312
313    // 4. List blocks returns all
314    #[test]
315    fn test_list_blocks_returns_all() {
316        let conn = setup();
317        create_block(&conn, "alpha", "a", 128).unwrap();
318        create_block(&conn, "beta", "b", 128).unwrap();
319        create_block(&conn, "gamma", "c", 128).unwrap();
320
321        let blocks = list_blocks(&conn).unwrap();
322        assert_eq!(blocks.len(), 3);
323        // Ordered by name
324        assert_eq!(blocks[0].name, "alpha");
325        assert_eq!(blocks[1].name, "beta");
326        assert_eq!(blocks[2].name, "gamma");
327    }
328
329    // 5. Delete block
330    #[test]
331    fn test_delete_block() {
332        let conn = setup();
333        create_block(&conn, "temp", "to be deleted", 64).unwrap();
334        delete_block(&conn, "temp").unwrap();
335
336        let result = get_block(&conn, "temp").unwrap();
337        assert!(result.is_none());
338    }
339
340    // 6. Get history with limit
341    #[test]
342    fn test_get_history_with_limit() {
343        let conn = setup();
344        create_block(&conn, "log", "v1", 256).unwrap();
345        update_block(&conn, "log", "v2", "edit 1").unwrap();
346        update_block(&conn, "log", "v3", "edit 2").unwrap();
347        update_block(&conn, "log", "v4", "edit 3").unwrap();
348
349        let limited = get_block_history(&conn, "log", 2).unwrap();
350        assert_eq!(limited.len(), 2);
351        // Most-recent first
352        assert_eq!(limited[0].new_content, "v4");
353        assert_eq!(limited[1].new_content, "v3");
354    }
355
356    // 7. Archive overflow truncates and returns excess
357    #[test]
358    fn test_archive_overflow_truncates_and_returns_excess() {
359        let conn = setup();
360        // max_tokens = 2  →  max_chars = 8
361        let long_content = "12345678overflow_part";
362        create_block(&conn, "small", long_content, 2).unwrap();
363
364        let overflow = archive_overflow(&conn, "small").unwrap();
365        assert!(overflow.is_some());
366        assert_eq!(overflow.unwrap(), "overflow_part");
367
368        let block = get_block(&conn, "small").unwrap().unwrap();
369        assert_eq!(block.content, "12345678");
370    }
371
372    // 8. Archive non-overflowing block returns None
373    #[test]
374    fn test_archive_non_overflowing_returns_none() {
375        let conn = setup();
376        create_block(&conn, "roomy", "short", 1024).unwrap();
377
378        let result = archive_overflow(&conn, "roomy").unwrap();
379        assert!(result.is_none());
380
381        // Block content and version must not have changed
382        let block = get_block(&conn, "roomy").unwrap().unwrap();
383        assert_eq!(block.content, "short");
384        assert_eq!(block.version, 1);
385    }
386
387    // 9. Get nonexistent returns None
388    #[test]
389    fn test_get_nonexistent_returns_none() {
390        let conn = setup();
391        let result = get_block(&conn, "does_not_exist").unwrap();
392        assert!(result.is_none());
393    }
394
395    // Bonus: delete nonexistent returns error
396    #[test]
397    fn test_delete_nonexistent_returns_error() {
398        let conn = setup();
399        let result = delete_block(&conn, "ghost");
400        assert!(result.is_err());
401    }
402
403    // Bonus: update nonexistent returns error
404    #[test]
405    fn test_update_nonexistent_returns_error() {
406        let conn = setup();
407        let result = update_block(&conn, "ghost", "content", "reason");
408        assert!(result.is_err());
409    }
410
411    // Bonus: create with empty name returns error
412    #[test]
413    fn test_create_empty_name_returns_error() {
414        let conn = setup();
415        let result = create_block(&conn, "", "content", 256);
416        assert!(result.is_err());
417    }
418}