-
Notifications
You must be signed in to change notification settings - Fork 2
Storage Architecture
Document Version: 1.0 Last Updated: 2026-01-31 Status: Authoritative Reference
The agent-memory storage layer provides durable, append-only persistence for conversation events and their derived summaries. Built on RocksDB with a separate Tantivy index for full-text search, the architecture prioritizes:
- Append-only semantics - Events are immutable once written
- Time-based ordering - Efficient range queries by timestamp
- Transactional consistency - Atomic batch writes with outbox pattern
- Crash recovery - Checkpoints and idempotent operations
- Separation of concerns - Column families isolate different data types
The storage layer treats events as immutable facts. Once an event is written, it is never modified or deleted during normal operation.
Benefits:
- Simplicity: No complex update logic or conflict resolution
- Auditability: Complete history preserved for debugging and compliance
- Performance: Sequential writes optimize for SSDs and log-structured merge trees
- Recovery: No partial states to reconcile after crashes
Trade-offs:
- Storage growth requires periodic compaction
- Corrections require new events (not mutations)
- Deleted content remains in storage until compacted
Event Timeline (immutable):
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ E-001 │───▶│ E-002 │───▶│ E-003 │───▶│ E-004 │
│ User │ │ Agent │ │ Tool │ │ Agent │
│ 10:00 │ │ 10:01 │ │ 10:02 │ │ 10:03 │
└────────┘ └────────┘ └────────┘ └────────┘
│
▼
Cannot modify E-001; only append new events
Immutability enables:
- Lock-free reads: No coordination needed between readers
- Safe iteration: Range scans see consistent snapshot
- Simple replication: Just ship new events
- Predictable I/O: Write-once pattern
| Requirement | RocksDB Capability |
|---|---|
| Embedded database | Single-process, no network dependency |
| Column families | Logical separation without multiple databases |
| Universal compaction | Optimal for append-only workloads |
| Compression | Built-in Zstd support reduces disk usage |
| Durability | Write-ahead log guarantees persistence |
| Range scans | Efficient prefix iteration for time queries |
| Rust bindings | Mature rust-rocksdb crate |
Alternatives Considered:
- SQLite: Less efficient for time-series append patterns
- sled: Less mature, uncertain maintenance status
- LMDB: Single-writer limitation problematic for concurrent ingestion
Column families provide logical isolation within a single RocksDB instance. Each CF has its own LSM tree, memtable, and compaction settings.
┌─────────────────────────────────────────────────────────────────────────┐
│ RocksDB Instance │
│ (~/.local/share/agent-memory/db/) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ events │ │ toc_nodes │ │ toc_latest │ │ grips │ │
│ │ │ │ │ │ │ │ │ │
│ │ Append-only │ │ Versioned │ │ Pointer │ │ Provenance │ │
│ │ events │ │ nodes │ │ table │ │ anchors │ │
│ │ │ │ │ │ │ │ │ │
│ │ Universal │ │ Default │ │ Default │ │ Default │ │
│ │ Compaction │ │ Compaction │ │ Compaction │ │ Compaction │ │
│ │ + Zstd │ │ │ │ │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ outbox │ │ checkpoints │ │
│ │ │ │ │ │
│ │ Async work queue │ │ Job recovery state │ │
│ │ │ │ │ │
│ │ FIFO Compaction │ │ Default Compaction │ │
│ └─────────────────────────────┘ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
| Column Family | Purpose | Key Pattern | Value Type | Compaction |
|---|---|---|---|---|
events |
Raw conversation events | evt:{ts:013}:{ulid} |
Event JSON | Universal + Zstd |
toc_nodes |
Versioned TOC hierarchy | toc:{node_id}:v{ver:06} |
TocNode JSON | Default |
toc_latest |
Latest version pointers | latest:{node_id} |
u32 (big-endian) | Default |
grips |
Provenance anchors |
{grip_id} or node:{id}:{grip}
|
Grip JSON | Default |
outbox |
Async processing queue | outbox:{seq:020} |
OutboxEntry JSON | FIFO |
checkpoints |
Job recovery state | checkpoint:{job_name} |
Checkpoint bytes | Default |
events CF:
- Write: Sequential append during ingestion
- Read: Time-range scans for context retrieval
- Pattern: Write-heavy, range-read-heavy
toc_nodes CF:
- Write: Append new version on summary update
- Read: Point lookup by versioned key
- Pattern: Read-heavy after initial summarization
toc_latest CF:
- Write: Update pointer when new version written
- Read: Point lookup to find current version
- Pattern: Balanced read/write
grips CF:
- Write: Batch insert during summarization
- Read: Lookup by grip_id or scan by node
- Pattern: Read-heavy for provenance checks
outbox CF:
- Write: Append with every event ingestion
- Read: Sequential scan from head
- Delete: After successful processing
- Pattern: Queue (FIFO)
checkpoints CF:
- Write: Periodic job progress saves
- Read: On startup for recovery
- Pattern: Low volume, high durability
The key format enables efficient time-based queries while maintaining uniqueness and ordering.
Key Format: {prefix}:{timestamp_ms:013}:{ulid}
┌────────────────────────────────────────────────────────────────┐
│ Event Key Example │
├────────────────────────────────────────────────────────────────┤
│ evt:1706540400000:01HPXYZ123456789ABCDEFGH │
│ │ │ │ │
│ │ │ └── ULID (26 chars, Crockford Base32) │
│ │ │ - Encodes timestamp + random │
│ │ │ - Globally unique │
│ │ │ - Lexicographically sortable │
│ │ │ │
│ │ └── Timestamp (13 digits, zero-padded) │
│ │ - Milliseconds since Unix epoch │
│ │ - 1706540400000 = 2024-01-29T15:00:00Z │
│ │ - Zero-padding ensures lexicographic = chronological │
│ │ │
│ └── Prefix │
│ - "evt" for events │
│ - Enables CF-wide prefix scans │
└────────────────────────────────────────────────────────────────┘
Lexicographic ordering equals chronological ordering:
Earlier event: evt:0001706540400000:01HPXYZ...
Later event: evt:0001706540401000:01HPXYZ...
▲
│
String comparison works for time ordering
Range scans are prefix iterations:
// Get all events between two timestamps
fn get_events_in_range(start_ms: i64, end_ms: i64) {
let start_key = format!("evt:{:013}:", start_ms);
let end_key = format!("evt:{:013}:", end_ms);
// RocksDB iterates forward from start_key
// Stop when key >= end_key
}| Entity | Key Format | Example |
|---|---|---|
| Event | evt:{ts:013}:{ulid} |
evt:1706540400000:01HPXYZ123456789ABCDEFGH |
| TOC Node (versioned) | toc:{node_id}:v{ver:06} |
toc:day:2024-01-29:v000003 |
| TOC Latest | latest:{node_id} |
latest:toc:day:2024-01-29 |
| Grip | {grip_id} |
grip:1706540400000:test123 |
| Grip Index | node:{node_id}:{grip_id} |
node:toc:day:2024-01-29:grip:123 |
| Outbox | outbox:{seq:020} |
outbox:00000000000000000042 |
| Checkpoint | checkpoint:{job_name} |
checkpoint:segmenter |
The ULID format encodes timestamp in its first 48 bits:
ULID: 01HPXYZ123456789ABCDEFGH
└──────┴──────────────┘
Time Randomness
(48b) (80b)
// Extract timestamp from ULID
let ulid: Ulid = event_id.parse()?;
let timestamp_ms = ulid.timestamp_ms() as i64;
This allows reconstruction of the full key from just the event_id.
Events are serialized as JSON for storage:
// From crates/memory-types/src/event.rs
#[derive(Serialize, Deserialize)]
pub struct Event {
pub event_id: String, // ULID
pub session_id: String, // Session identifier
pub timestamp: DateTime<Utc>, // Source timestamp
pub event_type: EventType, // UserMessage, AssistantMessage, etc.
pub role: EventRole, // User, Assistant, System, Tool
pub text: String, // Content
pub metadata: HashMap<String, String>, // Optional key-value pairs
}Serialized example:
{
"event_id": "01HPXYZ123456789ABCDEFGH",
"session_id": "session-abc123",
"timestamp": 1706540400000,
"event_type": "user_message",
"role": "user",
"text": "How do I configure RocksDB?",
"metadata": {}
}Query: Get events from 10:00 to 10:30 on 2024-01-29
Start timestamp: 1706526000000 (10:00:00 UTC)
End timestamp: 1706527800000 (10:30:00 UTC)
RocksDB Iteration:
┌────────────────────────────────────────────────────────────┐
│ events CF │
├────────────────────────────────────────────────────────────┤
│ evt:1706525900000:... │ 09:58:20 │ ← Before range, skip │
│ evt:1706525999000:... │ 09:59:59 │ ← Before range, skip │
│─────────────────────────────────────────────────────────────│
│ evt:1706526000000:... │ 10:00:00 │ ← Start iteration here │
│ evt:1706526030000:... │ 10:00:30 │ ← Include │
│ evt:1706526120000:... │ 10:02:00 │ ← Include │
│ evt:1706527700000:... │ 10:28:20 │ ← Include │
│─────────────────────────────────────────────────────────────│
│ evt:1706527800000:... │ 10:30:00 │ ← Stop here (exclusive)│
│ evt:1706527900000:... │ 10:31:40 │ ← After range, ignore │
└────────────────────────────────────────────────────────────┘
Every event write includes an outbox entry for transactional consistency:
// From crates/memory-storage/src/db.rs
pub fn put_event(
&self,
event_id: &str,
event_bytes: &[u8],
outbox_bytes: &[u8],
) -> Result<(EventKey, bool), StorageError> {
// Check for idempotent write
if self.db.get_cf(&events_cf, event_key.to_bytes())?.is_some() {
return Ok((event_key, false)); // Already exists
}
// Atomic write: event + outbox entry
let mut batch = WriteBatch::default();
batch.put_cf(&events_cf, event_key.to_bytes(), event_bytes);
batch.put_cf(&outbox_cf, outbox_key.to_bytes(), outbox_bytes);
self.db.write(batch)?; // Atomic commit
Ok((event_key, true))
}Atomicity guarantees:
- Both event and outbox entry written together
- If crash before write completes, neither is visible
- WAL ensures durability after write returns
TOC nodes are versioned to preserve history and enable safe updates:
TOC Node Versioning Flow:
Initial creation:
┌─────────────────────────────────────────────────────────┐
│ toc_latest │ toc_nodes │
├───────────────────────────┼─────────────────────────────┤
│ latest:toc:day:2024-01-29 │ toc:day:2024-01-29:v000001 │
│ = 1 │ = { v1 content } │
└───────────────────────────┴─────────────────────────────┘
After summary update:
┌─────────────────────────────────────────────────────────┐
│ toc_latest │ toc_nodes │
├───────────────────────────┼─────────────────────────────┤
│ latest:toc:day:2024-01-29 │ toc:day:2024-01-29:v000001 │
│ = 2 │ = { v1 content } │
│ ▲ │ toc:day:2024-01-29:v000002 │
│ │ updated │ = { v2 content } │
└─────────│─────────────────┴─────────────────────────────┘
│
Latest pointer updated atomically with new version
The two-column-family pattern separates versioned data from version pointers:
// Write new version atomically with pointer update
pub fn put_toc_node(&self, node: &TocNode) -> Result<(), StorageError> {
// Get current version
let latest_key = format!("latest:{}", node.node_id);
let current_version = self.get_latest_version(&latest_key)?;
let new_version = current_version + 1;
let versioned_key = format!("toc:{}:v{:06}", node.node_id, new_version);
// Atomic write: new version + updated pointer
let mut batch = WriteBatch::default();
batch.put_cf(&nodes_cf, versioned_key, &node_bytes);
batch.put_cf(&latest_cf, &latest_key, &new_version.to_be_bytes());
self.db.write(batch)?;
}Benefits:
- Version history preserved for debugging
- Atomic pointer update prevents inconsistent reads
- Easy rollback by updating pointer
TOC nodes store child references for hierarchy traversal:
pub struct TocNode {
pub node_id: String,
pub level: TocLevel, // Year, Month, Week, Day, Segment
pub child_node_ids: Vec<String>, // References to children
// ...
}Hierarchy navigation:
Year Node (toc:year:2024)
└── child_node_ids: ["toc:month:2024-01", "toc:month:2024-02", ...]
│
▼
Month Node (toc:month:2024-01)
└── child_node_ids: ["toc:week:2024-w01", "toc:week:2024-w02", ...]
│
▼
Week Node (toc:week:2024-w04)
└── child_node_ids: ["toc:day:2024-01-22", "toc:day:2024-01-23", ...]
│
▼
Day Node (toc:day:2024-01-29)
└── child_node_ids: ["toc:segment:2024-01-29:abc", ...]
│
▼
Segment Node (toc:segment:2024-01-29:abc123)
└── child_node_ids: [] // Leaf node
Grips anchor summaries to source events for provenance:
Grip ID Format: grip:{timestamp_ms:013}:{random_suffix}
Example: grip:1706540400000:test123
┌──────────────────────────────────────────────────────────────┐
│ Grip │
├──────────────────────────────────────────────────────────────┤
│ grip_id: "grip:1706540400000:test123" │
│ excerpt: "User asked about JWT authentication" │
│ event_id_start: "01HPXYZ..." ← First event in range │
│ event_id_end: "01HPXYZ..." ← Last event in range │
│ timestamp: 2024-01-29T15:00:00Z │
│ source: "segment_summarizer" │
│ toc_node_id: Some("toc:day:2024-01-29") │
└──────────────────────────────────────────────────────────────┘
When grips are linked to TOC nodes, an index entry enables efficient lookup:
grips CF Data Layout:
Primary grip entry (by grip_id):
┌───────────────────────────────────┬───────────────────────┐
│ Key │ Value │
├───────────────────────────────────┼───────────────────────┤
│ grip:1706540400000:test123 │ { full grip JSON } │
└───────────────────────────────────┴───────────────────────┘
Index entry (by node):
┌───────────────────────────────────┬───────────────────────┐
│ Key │ Value │
├───────────────────────────────────┼───────────────────────┤
│ node:toc:day:2024-01-29:grip:123 │ (empty) │
└───────────────────────────────────┴───────────────────────┘
Lookup by node (prefix scan):
pub fn get_grips_for_node(&self, node_id: &str) -> Result<Vec<Grip>, StorageError> {
let prefix = format!("node:{}:", node_id);
// Iterate index entries
for (key, _) in self.prefix_scan(&grips_cf, &prefix) {
let grip_id = extract_grip_id_from_key(&key);
let grip = self.get_grip(&grip_id)?;
grips.push(grip);
}
}Provenance Query Flow:
1. TOC bullet references grip_ids
TocBullet { text: "Discussed authentication", grip_ids: ["grip:123"] }
2. Lookup grip by ID
get_grip("grip:1706540400000:test123") → Grip
3. Grip contains event range
event_id_start → event_id_end
4. Retrieve source events
get_events_in_range(start_ms, end_ms) → Vec<Event>
Result: Original conversation that supports the summary
The outbox pattern ensures index updates are never lost:
Event Ingestion with Outbox:
┌─────────────────────────────────────────────────────────────────┐
│ Atomic Write Batch │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. Event written to events CF │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ evt:1706540400000:01HPXYZ... → { event JSON } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ 2. Outbox entry written to outbox CF │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ outbox:00000000000000000042 → { event_id, action } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Both writes committed atomically via WriteBatch │
│ │
└─────────────────────────────────────────────────────────────────┘
│
▼
Background Worker (async, separate thread)
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Worker reads outbox entry │
│ 4. Processes action (update TOC, index for search) │
│ 5. Deletes outbox entry after successful processing │
└─────────────────────────────────────────────────────────────────┘
The outbox guarantees at-least-once delivery semantics:
Failure Scenarios and Recovery:
Scenario A: Crash before processing
┌────────────┐ ┌────────────┐
│ Write │────▶│ CRASH │
│ Event + │ │ │
│ Outbox │ └────────────┘
└────────────┘ │
▼
On Restart:
┌────────────────┐
│ Outbox entry │
│ still present │───▶ Reprocessed
└────────────────┘
Scenario B: Crash during processing
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Read │────▶│ Process │────▶│ CRASH │
│ Outbox │ │ (partial) │ │ │
└────────────┘ └────────────┘ └────────────┘
│
▼
On Restart:
┌────────────────┐
│ Outbox entry │
│ not deleted │───▶ Reprocessed
└────────────────┘
Key: Outbox entry only deleted AFTER successful processing
Processing must be idempotent
// Checkpoint structure
pub struct Checkpoint {
job_name: String,
last_processed_sequence: u64,
last_processed_timestamp: i64,
saved_at: DateTime<Utc>,
}Recovery flow:
Daemon Startup:
1. Open RocksDB
└── Recovers WAL automatically
2. Load checkpoint for each job
┌─────────────────────────────────────┐
│ get_checkpoint("segmenter") │
│ → last_processed_sequence: 12345 │
└─────────────────────────────────────┘
3. Scan outbox from checkpoint
┌─────────────────────────────────────┐
│ Iterate outbox where seq > 12345 │
│ Process each entry │
│ Update checkpoint periodically │
└─────────────────────────────────────┘
4. Resume normal processing
The Tantivy full-text search index operates separately from RocksDB, providing BM25-scored keyword search.
Storage Layout:
~/.local/share/agent-memory/
├── db/ ← RocksDB instance
│ ├── CURRENT
│ ├── MANIFEST-*
│ ├── OPTIONS-*
│ ├── *.sst ← SST files (per CF)
│ └── ...
│
└── bm25-index/ ← Tantivy index (separate)
├── meta.json ← Index metadata
├── .tantivy-meta.lock ← Writer lock
└── *.managed.idx ← Segment files
Why separate directories:
- Independent lifecycle management
- Can rebuild search index without touching RocksDB
- Different compaction/merge strategies
- Tantivy uses memory-mapped files (MmapDirectory)
// From .planning/phases/11-bm25-teleport-tantivy/11-RESEARCH.md
pub fn build_teleport_schema() -> Schema {
let mut builder = Schema::builder();
// Document type: "toc_node" or "grip"
builder.add_text_field("doc_type", STRING | STORED);
// Primary key: node_id or grip_id
builder.add_text_field("doc_id", STRING | STORED);
// TOC level for filtering
builder.add_text_field("level", STRING);
// Searchable text content
builder.add_text_field("text", TEXT);
// Keywords for search
builder.add_text_field("keywords", TEXT | STORED);
// Timestamp for recency
builder.add_text_field("timestamp_ms", STRING | STORED);
builder.build()
}Data Flow: RocksDB ──▶ Tantivy
1. Event ingested to RocksDB
┌────────────────────────────────────────────┐
│ events CF: full event JSON │
│ outbox CF: IndexEvent action │
└────────────────────────────────────────────┘
│
▼
2. Background worker processes outbox
┌────────────────────────────────────────────┐
│ Read event from RocksDB │
│ Extract searchable fields │
└────────────────────────────────────────────┘
│
▼
3. Index document in Tantivy
┌────────────────────────────────────────────┐
│ tantivy::IndexWriter::add_document() │
│ Periodic commit (every minute) │
└────────────────────────────────────────────┘
│
▼
4. Search queries use Tantivy
┌────────────────────────────────────────────┐
│ Query: "authentication JWT" │
│ Result: [doc_id: "toc:day:2024-01-29"] │
└────────────────────────────────────────────┘
│
▼
5. Fetch full content from RocksDB
┌────────────────────────────────────────────┐
│ get_toc_node("toc:day:2024-01-29") │
│ Return complete TocNode │
└────────────────────────────────────────────┘
Key principle: Tantivy stores only IDs and searchable text. Full content fetched from RocksDB.
RocksDB compaction runs automatically, but can be triggered manually:
// From crates/memory-storage/src/db.rs
pub fn compact(&self) -> Result<(), StorageError> {
// Full database compaction
self.db.compact_range::<&[u8], &[u8]>(None, None);
// Per-CF compaction
for cf_name in ALL_CF_NAMES {
if let Some(cf) = self.db.cf_handle(cf_name) {
self.db.compact_range_cf::<&[u8], &[u8]>(&cf, None, None);
}
}
Ok(())
}When to trigger manual compaction:
- After bulk imports
- Before backup/snapshot
- When disk space is constrained
- Scheduled maintenance window
Universal Compaction (events CF):
Good for append-only workloads:
┌────────────────────────────────────────────────────────────┐
│ L0: [SST-new] [SST-new] [SST-new] │
│ │ │
│ ▼ Size ratio trigger │
│ L1: [ Merged SST files ] │
│ │ │
│ ▼ Size ratio trigger │
│ L2: [ Larger merged SST ] │
└────────────────────────────────────────────────────────────┘
Benefits:
- Lower write amplification
- Better for time-series data
- Simpler merge policy
Default Compaction (other CFs):
Leveled for point lookups:
┌────────────────────────────────────────────────────────────┐
│ L0: Unsorted SST files │
│ L1: Sorted, non-overlapping │
│ L2: 10x size of L1, sorted │
│ L3: 10x size of L2, sorted │
└────────────────────────────────────────────────────────────┘
Benefits:
- Better read amplification
- Predictable point lookup latency
- Good for key-value patterns
Configuration in db.rs:
pub fn open(path: &Path) -> Result<Self, StorageError> {
let mut db_opts = Options::default();
// Universal compaction for append-only
db_opts.set_compaction_style(rocksdb::DBCompactionStyle::Universal);
// Background job parallelism
db_opts.set_max_background_jobs(4);
// ...
}Column family tuning:
// events CF: Zstd compression for space efficiency
fn events_options() -> Options {
let mut opts = Options::default();
opts.set_compression_type(rocksdb::DBCompressionType::Zstd);
opts
}
// outbox CF: FIFO for queue behavior
fn outbox_options() -> Options {
let mut opts = Options::default();
opts.set_compaction_style(rocksdb::DBCompactionStyle::Fifo);
opts.set_fifo_compaction_options(&rocksdb::FifoCompactOptions::default());
opts
}Operational recommendations:
| Setting | Recommended Value | Rationale |
|---|---|---|
max_background_jobs |
4 | Balance between throughput and CPU |
| Compression | Zstd for events | 3-5x compression, good speed |
| WAL size | Default (64MB) | Sufficient for batch writes |
| Block cache | 128MB | Cache hot blocks in memory |
| Write buffer | 64MB | Buffer before flush |
// Get database statistics
pub fn get_stats(&self) -> Result<StorageStats, StorageError> {
StorageStats {
event_count: count_cf_entries(&events_cf),
toc_node_count: count_cf_entries(&toc_nodes_cf),
grip_count: count_cf_entries(&grips_cf),
outbox_count: count_cf_entries(&outbox_cf),
disk_usage_bytes: get_disk_usage(),
}
}| Metric | Description | Alert Threshold |
|---|---|---|
outbox_count |
Pending work items | > 1000 (processing lag) |
disk_usage_bytes |
Total storage size | > 80% of disk |
event_count |
Total events stored | Informational |
compaction_pending_bytes |
Bytes waiting for compaction | > 1GB |
# Option 1: RocksDB checkpoint (online, consistent)
# Creates a hard-link based snapshot
rocksdb_checkpoint /source/db /backup/db
# Option 2: File copy (offline)
systemctl stop memory-daemon
cp -r ~/.local/share/agent-memory/db /backup/
systemctl start memory-daemon
# Tantivy index can be rebuilt from RocksDB
# Use rebuild command after restoring RocksDB
memory-admin rebuild-indexRecovery Procedure:
1. Stop daemon
systemctl stop memory-daemon
2. Restore RocksDB from backup
rm -rf ~/.local/share/agent-memory/db
cp -r /backup/db ~/.local/share/agent-memory/db
3. Remove Tantivy index (will be rebuilt)
rm -rf ~/.local/share/agent-memory/bm25-index
4. Start daemon
systemctl start memory-daemon
5. Trigger index rebuild
memory-admin rebuild-index
6. Verify
memory-admin stats
The agent-memory storage architecture combines RocksDB for durable persistence with Tantivy for full-text search:
- RocksDB provides append-only event storage with column family isolation
- Time-prefixed keys enable efficient range scans for context retrieval
- Outbox pattern ensures reliable async processing without message loss
- TOC versioning preserves history while enabling safe updates
- Grips anchor summaries to source events for provenance
- Tantivy delivers BM25-scored keyword search independently of primary storage
- Checkpoints enable crash recovery with at-least-once semantics
This design prioritizes durability, queryability, and operational simplicity while maintaining the flexibility to evolve the search layer independently.