plugmem-core
⚠️ Experimental. plugmem is mostly an AI-built experiment — written with the help of a small local model (Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf) and various Claude models, in roughly equal measure. Expect non-professional design choices, rough edges, broken behavior, or mistakes. Use it at your own risk.
plugmem-core is an embedded temporal-memory engine for LLM agents — a
library that runs inside the process. An agent talks to
it in four verbs — remember / recall / revise / forget — and it answers
with a ranked, token-budgeted context block ready to paste into a prompt.
It keeps a whole database in one snapshot file plus an append-only
journal; storage is flat byte arenas, so the memory image is the file
format (loading is a bounds-check plus adopt, replay is deterministic to
the byte, and the same file opens on native, wasm32 and wasm64 unchanged).
It is not a vector database. Recall fuses four sources by reciprocal-rank fusion with a recency boost (tags filter; they are not a source):
| Source | Algorithm | What it finds |
|---|---|---|
| Lexical | BM25 (Robertson idf) over a Unicode (UAX #29) tokenizer | exact terms / keyword overlap |
| Semantic | symmetric int8-quantized cosine — a flat two-phase scan below a threshold, an HNSW graph above | meaning / nearest neighbours |
| Graph | entity graph with typed edges, breadth-first from query anchors | relational knowledge |
| Temporal | range scans over a recorded_at-ordered index; bitemporal validity |
"what was true then", time windows |
On top of that: bitemporal
facts (revise/forget, "what was true then", revision chains,
physical erasure), and conflict surfacing — a new remember returns
the live facts it may duplicate or contradict, and the engine never merges
on its own; the caller decides.
This crate is the engine itself: no_std + alloc, zero I/O, no clock,
no threads. Bytes enter and leave through a five-method Storage trait,
timestamps arrive as parameters, and embeddings are computed by the caller
— which is what lets the same engine run natively, in wasm32v1-none, or
anywhere else Rust compiles.
Which crate do I need?
Most Rust programs want plugmem-host,
not this crate — it wraps this engine with files, locking, mmap and embedders,
so you never manage storage yourself. Reach for plugmem-core directly only
when you need no_std or your own persistence.
| You want | Use | Why |
|---|---|---|
| A memory in a Rust program — the common case | plugmem-host (std) |
Everything included: files, locking, read-only mmap, HTTP embedders (OpenAI/Ollama/LM Studio/vLLM/llama.cpp), integrity, concurrency. Re-exports this engine. |
A memory in Rust with no std or your own storage (browser, wasm host, custom persistence) |
plugmem-core (this crate) |
The engine only. You bring the Storage trait, the clock, file I/O and embedding — so you manage when the file opens and how memory loads. |
| Just the flat byte-pool containers (sorted page arenas, blob heap, chunk pool, interner) | plugmem-arena (no_std) |
The storage substrate, engine-agnostic. |
| A memory from a terminal or shell script | plugmem-cli (plugmem) |
One file, no server; plugmem repl keeps the engine open for host speed. |
| A memory for an LLM agent or a non-Rust program | plugmem-mcp |
Long-lived stdio JSON-RPC; language-independent. In Rust, embed the host lib instead. |
| A memory in JavaScript / TypeScript (Node) | plugmem-napi |
The engine as a native Node addon (napi-rs), in-process; on npm as plugmem. |
Who this is for
Agents and applications that need a personal memory: tens of thousands to a hundred thousand facts about a user, a project, a codebase — with temporal reasoning ("what was true then"), revision history, a relationship graph, and hybrid retrieval, all inside the process and inside a single file. It is not a horizontally scalable search cluster and does not try to be one; the capacity passport is deliberately sized to the 32-bit wasm address space (≤ 2 GiB, design center 100k facts, ceiling 1M). On 64-bit hosts — native or WebAssembly 3.0 memory64 — the same code and the same file format carry larger limits; see Targets and WebAssembly.
Quick start
use ;
let mut store = new; // file-backed storage lives in plugmem-host
let mut mem = new.unwrap;
let out = mem.remember.unwrap;
// out.similar lists live facts this one may duplicate or contradict —
// the engine never merges on its own; the caller decides.
let res = mem.recall.unwrap;
println!; // a compact, ranked block for the prompt
mem.snapshot.unwrap; // full image + journal reset
Usage — the four verbs, time travel, filters
Everything runs against a Storage; MemStorage is the in-memory one
(the file-backed storage with locking and durability lives in
plugmem-host). Timestamps are unix-millis you pass in
— the engine keeps no clock.
Revise, then ask "what was true then" (bitemporal). revise closes
the old fact's validity interval and records the successor; the old
version stays answerable through an as_of query.
use ;
let mut store = new;
let mut mem = new.unwrap;
let first = mem.remember.unwrap;
mem.revise.unwrap;
// As of time 1_500 the earlier fact was still valid; now, Berlin wins.
// (The entity anchor pulls the user's facts; there is no stemming, so a
// bare text query would need a word the fact actually contains.)
let then = mem.recall.unwrap;
assert!;
let now = mem.recall.unwrap;
assert!;
Forget, then reclaim the space. forget tombstones a fact
immediately; maintain physically purges the tombstones and compacts the
structures. The id stays burned — never reissued.
use ;
let mut store = new;
let mut mem = new.unwrap;
let f = mem.remember.unwrap;
mem.forget.unwrap;
assert!; // gone from every query at once
let report = mem.maintain.unwrap;
assert_eq!; // the bytes are reclaimed
Conflict surfacing on remember. A new remember returns the live
facts it may duplicate or contradict; the engine never merges on its own.
use ;
let mut store = new;
let mut mem = new.unwrap;
mem.remember.unwrap;
let out = mem.remember.unwrap;
for hit in &out.similar
Filtered recall — tags, entity, time range. Sources compose: text
ranking, a tag filter, an entity anchor and a recorded_at window in one
query.
use ;
let mut store = new;
let mut mem = new.unwrap;
mem.remember.unwrap;
let res = mem.recall.unwrap;
println!;
To add vector recall, set Config { dim: N, .. } and pass
RememberInput { vector: Some(&embedding), .. } (and a query vector);
the engine quantizes to int8 and, past a threshold, builds the HNSW graph
in maintain. Computing the embedding is the caller's job — which is what
plugmem-host automates over an HTTP embedding server.
Data model
A fact is one short statement with an optional subject entity, tags, an optional embedding, and two time axes (a simplified bitemporal model):
recorded_at— when the memory learned it (immutable);valid_from / valid_to— when it was/is true.
revise closes the old fact's validity interval and records the
successor; the old version is kept — "lived in Moscow (2023 → 2025)"
stays answerable through as_of queries. forget tombstones a fact
immediately; the next maintain removes it physically, and its id is
burned, never reissued. Entities form a graph through typed edges
(works_at, depends_on, …) with a provenance link back to the fact
that justified them.
| Verb | Effect |
|---|---|
remember |
new fact + indexes + similar-fact hints (Jaccard term overlap, vector cosine) |
recall |
hybrid ranked retrieval, zero allocations after warm-up |
revise |
close the predecessor, record the successor, keep the chain |
forget |
immediate tombstone; physical purge at maintain |
link |
upsert a typed edge between entities |
maintain |
the one O(base) verb: purge, compaction, index rebuilds, HNSW build |
snapshot |
full image + journal reset |
Retrieval
Four sources feed one ranked result:
- Lexical — BM25 with the Robertson idf over delta-encoded (LEB128) posting lists; the tokenizer does NFKC normalization, UAX #29 word segmentation, Latin diacritic folding and CJK bigrams. A stop-frequency guard drops query terms whose posting lists would dominate the cost.
- Vector — embeddings are stored as symmetric int8 quantizations of
the L2-normalized vector (f32 is never persisted). Below a configured
threshold, search is a two-phase flat scan: a Hamming prefilter over
1-bit sign signatures, then an exact quantized-cosine rescore of the
best candidates. Above the threshold,
maintainbuilds an HNSW graph (Malkov & Yashunin) with the neighbor-selection heuristic and early-stopped beam search; vectors added since the last build sit in a flat tail that is scanned exactly and merged. - Graph — bounded breadth-first expansion from entity anchors over the edge arenas, with hard budgets on entities, edges, candidates and examined posting entries (a hub entity cannot blow the query up).
- Temporal — range scans over a
recorded_at-ordered index.
Sources are fused with reciprocal rank
fusion (Cormack, Clarke
& Buettcher) — rank-based, so the sources need no score calibration —
plus an exponential recency boost. Selection is greedy under k and a
token budget, and the result includes both structured facts and a
rendered block ready to paste into a prompt.
Algorithms and optimizations
One place to see every technique in the engine and why it is there — each is a deliberate trade-off, not a default. Details are in the sections around this one; this is the map.
Memory (keep RAM ∝ number of facts, not size of content):
- Flat byte arenas — all state is
Vec<u8>/Vec<u32>, noBox/HashMapin the persistent data. Why: no pointer chasing, no per-node allocation, and the in-RAM image is the on-disk format, so load is adopt-not-parse. Cost: compaction is an explicitmaintainpass, not free deletion. - int8 vector quantization — embeddings stored as symmetric int8 of the
L2-normalized vector; f32 is never persisted. Why: ~4× smaller than f32 and
the dominant RAM consumer at scale. Cost: rescore is int8-exact, not f32-exact
(measured harmless — see
bench-history/). - Overlay open (mmap + owned tail) — a database opens zero-copy over an mmap; later appends land in a small owned tail, the borrowed base is never cloned. Why: opening a multi-GiB file touches only the pages actually read, and the OS can evict them. Cost: the borrow ties the handle to the mapping's lifetime.
- Disk-first maintain/recover — the two large pools (vectors, text) stream
through a host
Scratchfile instead of being rebuilt in RAM. Why: rebuild RAM becomes ∝ record count + graph, not content size, so a database larger than RAM can still be compacted. Cost: needs host temp-file I/O (unavailable on bare no_std, which falls back to the in-RAM path). - Streaming snapshot writer — the image is written section-by-section, never materialized as one buffer. Why: a checkpoint of a huge database does not spike memory to the full image size.
Speed:
- Two-phase vector search — a 1-bit sign-signature Hamming prefilter
(popcount, SIMD-friendly) narrows to
max(4k, 64)candidates, then an exact int8-cosine rescore ranks them. Why: the cheap prefilter skips the expensive rescore for almost everything. Cost: a coarse signature can drop a true neighbour before rescore (widen the querykto recover it — nearly free). - HNSW graph above a size threshold (Malkov & Yashunin) — sub-linear
approximate search with the neighbor-selection heuristic and early-stopped
beam. Why: linear scan stops paying off past ~tens of thousands of vectors.
Cost: a one-time build in
maintain(~ms/vector) and approximate recall. - Delta + LEB128 posting lists with a stop-frequency guard. Why: compact lists decode fast and a hub term cannot dominate query cost.
- Bounded everything in fusion — per-source candidate cap (
SOURCE_CAP), graph expansion budgets, greedy top-k. Why: one hub entity or one common term can never blow a query up; cost is a fixed ceiling regardless of data shape. - Zero allocations after warm-up —
recall/getreuse scratch buffers, enforced by a counting-allocator test. Why: predictable latency, no GC-like pauses.
Quality:
- Reciprocal rank fusion — merges sources by rank, not score, so no per-source calibration is needed. Recency boost and graph decay^depth tilt results toward fresh and closely-linked facts.
The two committed benchmarks that back these — benches/engine.rs (Criterion
speed) and examples/recall_quality.rs (recall vs an exact-cosine oracle) —
plus their recorded baselines in bench-history/, are what turn "should be
faster/accurate" into a number you can regress against.
Storage and durability
All state lives in flat byte structures (sorted page arenas, blob heaps,
chunked lists, an interner) from plugmem-arena. The consequence: the
memory image is the file format. A snapshot is the concatenation of
each structure's sections, 64-byte aligned, with
xxh3 checksums per section and
over the whole file; loading is bounds-checking the metadata and
adopting the bytes — no per-record parsing.
Between snapshots, every mutation appends one framed record to a
journal. Replay is deterministic to the byte: quantization is a pure
function, HNSW levels are a pure function of the fact id, and maintain
re-executes identically — so snapshot → crash → replay and the
uninterrupted engine produce the same file. A torn journal tail (crash
mid-append) is detected and dropped; any other inconsistency is a typed
error. Snapshots are canonical: save → load → save is byte-identical.
The loader treats every input as untrusted: arbitrary bytes can produce
any Error but never a panic or undefined behavior, and after a
successful load every stored id is range-checked, every chunk chain
walked, every invariant the hot path relies on re-established.
Integrity and recovery
Loading is trust/sparse by default (the SQLite model): it validates the metadata but does not read the container checksums or scan the two large byte pools, so a big database opens without faulting them in. The accessors tolerate bad bytes on their own — invalid text hides a fact, a bad vector slot is skipped — so a corrupt image never panics. Integrity is then on demand, in layers:
Memory::verify()— content consistency: every stored text is valid UTF-8 and the fact↔vector-slot bijection holds (the equivalent of SQLite'sintegrity_check).- a resumable byte-level container scrub (per-section and whole-file xxh3),
exposed by the host as
ReadOnlyDatabase::scrub()— the bitrot detector. Memory::faulty_facts()— the per-fact salvage predicate the host'srecover()uses to drop the corrupt records and write a clean copy.
Both the streaming snapshot writer (write_snapshot_to) and the disk-first
rebuild (snapshot_disk_first, over a host-provided Scratch) let the host
maintain and recover a database larger than RAM without ever materializing the
whole image — the engine provides the algorithm, the host the files (see
plugmem-host and specs/16 §9).
Each database carries a db_uuid (minted by the host at creation) so
external holders of fact ids can tell "same database" from "a different
one".
Performance
Deterministic work counters (cmp_ops, postings_decoded,
dist_evals, allocation counts — behind the counters feature) act as
CI gates: a complexity regression fails the same way on any machine.
recall and get perform zero allocator calls after warm-up,
enforced by a counting-allocator test.
Per-source recall latency (single thread, native). The chart is rendered
by plugmem-bench-charts from the
bench_ops example's output — the same plotters pipeline as the arena
charts:
The composite recall paths and the write side, which the chart does not break out:
| Operation (single thread, native) | Latency |
|---|---|
| tags + time-range recall @ 100k | ~230 µs |
| hybrid recall (text + hub entity anchor) @ 100k | ~470 µs |
remember (tokenize, index, quantize d384, similar-detect, journal) |
~72 µs mean |
one-time HNSW build inside maintain |
~1.6 ms/vector |
Reproduce: cargo bench -p plugmem-core (Criterion; a separate target,
never run under cargo test) for the full statistical suite, or
cargo run --release -p plugmem-core --example bench_ops for the chart's
#TSV rows. Corpora come from plugmem-testgen — seeded, so every run
measures the same workload.
Capacity — what weighs what
How much is resident depends on the open. Through an owned Storage
(the in-RAM MemStorage, or a wasm host that owns the bytes) the whole
database is held resident. Through the native host's memory-mapped overlay
it is not: the mapped pages are reclaimable, and even a rebuild
(maintain/recover) streams the two big pools through scratch, so peak
RAM tracks the record count, not the image size (see
plugmem-host and specs/16 §9).
The sizes below are the owned-resident case — the worst case; an overlay
open residents far less.
Either way, each structure tops out at the width of its own internal index.
Every byte cost here is fixed by the Slot definitions in model.rs and the
pool strides — not estimates:
| Structure | Holds | Per unit | Indexed by | Ceiling |
|---|---|---|---|---|
facts + fact_aux |
one fact's record | 48 + 16 = 64 B | u32 page × 4 KiB | 4.29 B facts (u32 id) |
temporal |
recorded_at index entry |
12 B / fact | u32 page × 4 KiB | 16 TiB pool |
entities + by_name |
one entity | 24 + 8 = 32 B | u32 page × 4 KiB | 4.29 B entities |
edges_out + edges_in |
one typed edge (both directions) | 16 + 16 = 32 B | u32 page × 4 KiB | 16 TiB pool |
texts (blob heap) |
all fact texts + entity names, concatenated | its text length | usize byte offset | 4 GiB on 32-bit; RAM-bound on 64-bit |
terms (interner) |
vocabulary: unique tokens, tags, relation names | deduped term length | usize byte offset | 4 GiB on 32-bit; RAM-bound on 64-bit |
tag_lists + postings |
tag/term/entity → fact lists | ~varint / entry | u32 chunk × 64 B | 256 GiB each |
vecs (vector pool) |
one int8-quantized embedding | 8 + 8·⌈dim/64⌉ + dim B |
u32 slot | 4.29 B vectors |
| HNSW graph | neighbor blocks | ≈ m0 × 4 B / vector |
u32 node id | 4.29 B nodes |
Per-vector stride, concretely: d384 → 440 B, d768 → 872 B, d1536 → 1736 B (f32 is never stored — only the int8 components, a 1-bit sign signature and a scale).
The binding limit is rarely the id space. Ids are u32 (4.29
billion), but two softer walls arrive first:
texts4 GiB on 32-bit — the sum of every fact's text plus entity names. At ~200 B/fact that is ~21 M facts of text; at ~120 B, ~36 M. The first hard wall for a text-heavy memory on wasm32; on a 64-bit host the pool offset is ausize, so this pool is RAM-bound, not wall-capped.- RAM on the owned-resident path — and with vectors this binds first: d768 embeddings are 872 B each, so 10 M vectors alone are ~8.7 GiB. A native overlay open sidesteps this (mmap pages are reclaimable); it is the ceiling for the owned/wasm path.
Worked sizes (native / wasm64; no vectors unless noted):
| Memory | Rough resident size | Fits |
|---|---|---|
| 100 k facts, ~120 B text (design center) | ~40 MB | anywhere, incl. wasm32 |
| 1 M facts + d384 vectors (wasm32 ceiling) | ~0.9 GB — arenas ~90 MB, text ~120 MB, vecs ~440 MB, index | wasm32 ≤ 2 GiB budget |
| 10 M facts + d768 vectors | ~13 GB — vecs ~8.7 GB dominate; text ~1.2 GB (< 4 GiB) | 64-bit host, comfortably |
So on 64-bit, vectors and text dominate RAM: you run out of memory far
sooner than the 4.29 B id space (the text pool is no longer a 4 GiB wall
there — it is usize-bound).
Address-space classes
| Target | usize |
Total resident image |
|---|---|---|
wasm32 (Wasm 2.0; wasm32v1-none, -wasip1) |
32-bit | ≤ 4 GiB total — every pool + code + stack share one linear memory. Realistic DB ~1–2 GiB; design center 100 k facts, ceiling 1 M. |
| wasm64 (Wasm 3.0 memory64) | 64-bit | RAM-bound; the byte-pool offset is a 64-bit usize, so text/vocabulary pools lift past 4 GiB |
| native 64-bit | 64-bit | RAM-bound; max_bytes raisable past 4 GiB (text/vocabulary pools too) |
| native 32-bit | 32-bit | like wasm32; a > 4 GiB-class DB is refused with ConfigMismatch (a typed error, not corruption) |
The per-pool byte-offset ceiling is the target's usize, not a fixed
4 GiB: on a 32-bit target (wasm32) that is 4 GiB — a natural fit for linear
memory; on a 64-bit host a single text or vocabulary pool is RAM-/max_bytes-
bound like every other pool. The snapshot format is identical across
pointer widths — dumps store per-blob lengths, not offsets, so a file written
anywhere reloads anywhere it fits (a > 4 GiB-pool file simply cannot be
addressed on a 32-bit host, and is refused with ConfigMismatch rather than
truncated). See WebAssembly 2.0 and 3.0.
Limits, stated plainly
- Single-threaded, single-writer. Concurrency belongs to the embedding
process (the
plugmem-hostcrate serializes access to a file). - Sizing and per-structure ceilings are laid out in
Capacity — what weighs what: ≤ 2 GiB of
state by default (the 32-bit wasm budget),
u32ids, texts ≤ 4 KiB, dimensions ≤ 4096. On 64-bit buildsmax_bytesmay be raised past 4 GiB — such a database then opens only on 64-bit hosts. - Vector search is quantized (int8) — exact f32 scores are never computed — and approximate above the HNSW threshold (recall@10 ≥ 0.9 against brute force is a test gate, not a proof).
- The tokenizer does no stemming or lemmatization.
maintainis O(database) and the first one past the HNSW threshold pays the graph build; call it on your schedule, not on a hot path.- The snapshot format is not yet frozen (pre-1.0): a new version may require re-importing, not migrating.
Features and targets
The crate is no_std + alloc unconditionally — there is no std feature
(it builds and is gated on wasm32v1-none in CI).
counters— the deterministic work counters; zero cost when off.serde—Serialize/Deserializeon the public data types (ids,Config, the model records,RememberOutcome,Stats,RecallResult,MaintainReport, …). Owned types round-trip; borrowed inputs (RememberInput,RecallQuery, …) andErrorare Serialize-only (they hold&str/&'static str). Off by default; forwards toplugmem-arena/serdeso the re-exported ids serialize too. Behavioral types (Memory,Tokenizer, the index structures) are not serializable by design.
WebAssembly 2.0 and 3.0
One source, one file format, two address-space classes:
- wasm32 (WebAssembly 2.0 class;
wasm32v1-none,wasm32-unknown-unknown) — the default and the portability baseline: runs in every engine and browser. The full contract test suite runs on a real 32-bit target:cargo test -p plugmem-core --target wasm32-wasip1under wasmtime. - wasm64 (WebAssembly 3.0
memory64) — lifts the 4 GiB
linear-memory ceiling. The crate builds for
wasm64-unknown-unknownunchanged (nightly-Zbuild-std=core,alloc; the target is tier 3). Engine support today: wasmtime and 3.0-era browsers run it; wasmer does not yet.
Snapshots are pointer-width independent by construction — every codec
writes fixed-width little-endian fields — and this is verified, not
assumed: the same scenario produces byte-identical snapshots on
native x86-64, wasm32 and wasm64 (and with +simd128 enabled). A
database is portable across all of them as long as its configured
limits fit the host's address space; "migrating" from a 32-bit to a
64-bit deployment is opening the same file. Determinism is preserved on
purpose: the engine stays inside the Wasm 3.0 deterministic profile
(integer distances, total-order float comparisons, no relaxed SIMD in
any state-affecting path).
Crates are published to crates.io under these names once the format freezes; until then, use the git repository.
License
MIT.