How ai-memory Handles Local Spooling of Events for Native Commands
ai-memory eliminates network blocking during native command execution by persisting lifecycle-hook events to a private local filesystem spool, asynchronously draining them to the remote server only at session boundaries or when storage budgets are exceeded.
Native commands in the akitaonrails/ai-memory repository trigger lifecycle hooks such as pre-tool-use and post-tool-use that must be captured without delaying the user workflow. Instead of synchronously POST-ing to a remote endpoint, the Rust CLI implements a high-performance strategy for local spooling of events that writes payloads to disk and flushes them later. This architecture ensures that tool invocations remain instantaneous while guaranteeing eventual delivery of telemetry and memory updates.
The Spool Architecture: Filesystem as a Buffer
The spool implementation centers on crates/ai-memory-cli/src/commands/hook_spool.rs, which treats the local filesystem as a durable, zero-dependency message queue.
Spool Directory Structure and Permissions
The function spool_dir(data_dir) returns a private subdirectory <data_dir>/hook-spool. On Unix systems, the directory is created with mode 0700 and every spool file with mode 0600, ensuring only the owning user can inspect captured payloads.
Event Representation with SpoolEntry
Each queued event is serialized as a SpoolEntry struct that captures:
- The target URL for the webhook
- The JSON payload body
- A creation timestamp (milliseconds since epoch)
- The authentication mode (
Static,Oidc, orAnonymous) - A retry attempt counter
This metadata allows the drain logic to resolve authentication tokens lazily and handle retries without re-serializing the payload.
Enqueuing Events Without Blocking
The enqueue function provides the non-blocking write path that makes local spooling viable for real-time command hooks.
Atomic File Writes and Privacy Controls
To prevent partial writes from corrupting the spool, enqueue writes to a temporary file with a .tmp extension and then atomically renames it to a filename embedding the creation time, process ID, and a monotonic sequence: {created_ms}-{pid}-{seq}.json. This naming convention ensures total ordering while eliminating race conditions during enqueue operations.
Spool Pruning and Health Monitoring
Unbounded growth is prevented by prune_spool_file_count, which enforces a hard limit of MAX_SPOOL_FILES (10,000). When the count is exceeded, the oldest entries are evicted and a warning is emitted to stderr. Operators can inspect queue depth via spool_health, which efficiently computes pending count, oldest age, and total retry attempts by reading filenames only—never opening individual files.
Draining and Network Transmission
Draining converts filesystem entries into HTTP requests. The process is orchestrated by several drain variants including drain_exclusive and drain_until_quiescent.
Exclusive Locking with DrainLock
Concurrent drains are prevented by acquire_drain_lock, which creates a .drain.lock file and attempts an exclusive lock via fs2::FileExt::try_lock_exclusive. The lock is automatically released when the DrainLock object is dropped, ensuring that even panicked processes do not deadlock the spool.
Batch Processing and Retry Logic
The drain function sorts spool files oldest-first and constructs payloads via batch_payload. Requests are batched up to MAX_BATCH_ITEMS (256 events) or MAX_BATCH_BYTES (8 MiB). If the server does not support batching, the implementation falls back to per-event POST /hook.
Failed deliveries trigger bump_or_drop, which increments the attempts field. Entries exceeding MAX_ATTEMPTS (8) or MAX_AGE_MS (7 days) are permanently deleted to prevent poison-pill accumulation.
Authentication Resolution at Drain Time
Tokens are resolved lazily to support short-lived OIDC credentials. The entry_bearer function caches the OIDC token for the duration of a single drain pass, enabling Oidc auth mode entries to fetch fresh credentials from auth.json at flush time rather than at capture time.
Session Boundaries and Drain Triggers
Drains are triggered at well-defined lifecycle points: session-start, session-end, and cancellation handlers. The helper drain_exclusive_within_budget enforces time and size constraints, ensuring that a long-running session does not block indefinitely on network I/O while still guaranteeing forward progress on the spool.
use ai_memory_cli::commands::hook_spool::{
spool_dir, enqueue, entry_for, drain_exclusive, DrainLockWait,
};
/// Capture a hook event without blocking the native command
fn capture_event(data_dir: &std::path::Path) {
// Build entry with Anonymous auth (no static token, no OIDC)
let entry = entry_for(
"https://example.com/hook?event=tool-use".into(),
r#"{"tool":"git","args":["commit"]}"#.into(),
None,
false,
);
// Instantaneous local write; no network I/O
let spool = spool_dir(data_dir);
enqueue(&spool, &entry).expect("spool write failed");
}
/// Drain spool at session exit with budget constraints
#[tokio::main]
async fn drain_at_exit(data_dir: &std::path::Path) {
let spool = spool_dir(data_dir);
// Try exclusive lock; skip if another process is draining
if let Some(_result) = drain_exclusive(
&spool,
data_dir,
std::time::Duration::from_secs(30), // total budget
std::time::Duration::from_secs(5), // per-event timeout
DrainLockWait::Bounded(std::time::Duration::from_secs(30)),
)
.await
{
println!("Spool drained successfully");
} else {
println!("Another process is draining; skipping");
}
}
Summary
- ai-memory decouples event capture from network transmission by writing hook events to a private local spool in
crates/ai-memory-cli/src/commands/hook_spool.rs. - Atomic enqueuing via
enqueueuses temporary files and strict Unix permissions (mode0600) to guarantee durability and privacy without blocking the calling tool. - Bounded growth is enforced by
prune_spool_file_count(max 10,000 files) andbump_or_drop(max 8 attempts or 7 days age). - Exclusive draining through
acquire_drain_lockprevents race conditions, while batching (256 items or 8 MiB) optimizes network throughput. - Lazy authentication allows
Oidctokens to be resolved at drain time, supporting short-lived credentials for webhook delivery.
Frequently Asked Questions
What prevents the spool from growing indefinitely?
The prune_spool_file_count function caps the total file count at MAX_SPOOL_FILES (10,000) and evicts the oldest entries when exceeded. Additionally, bump_or_drop removes individual entries that exceed MAX_ATTEMPTS (8 retries) or MAX_AGE_MS (7 days), ensuring disk usage remains bounded even if the remote server is unavailable for extended periods.
How does ai-memory ensure that enqueuing never blocks on network I/O?
The enqueue function performs only local filesystem operations: it serializes a SpoolEntry to a temporary file and atomically renames it into the spool directory. Network transmission is deferred to the drain phase, which runs asynchronously at session boundaries or during periodic maintenance, keeping native command execution latency minimal.
Can multiple processes drain the spool simultaneously?
No. The acquire_drain_lock function creates an exclusive filesystem lock (.drain.lock) using fs2::FileExt::try_lock_exclusive. Only one process can hold this lock at a time; other callers receive None from drain_exclusive and skip the drain cycle, preventing duplicate deliveries and file-system race conditions.
How are authentication tokens handled for spooled events?
Authentication is resolved lazily during the drain phase rather than at capture time. The entry_bearer function checks the AuthMode enum (Static, Oidc, or Anonymous) and caches OIDC tokens for the duration of the drain pass. This design allows events captured hours ago to be delivered with fresh credentials fetched from auth.json at flush time.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →