Introduction
Agent Memory Engine
🌙 Lunaris
Sub-25 ms recall at 100,000 documents per scope — measured — with provable atomicity and a graph that's opt-in.
Lunaris is a production-grade agent-memory engine written in pure Rust,
with first-class Python (pip install lunaris) and TypeScript
(npm i @pilotspace/lunaris) SDKs generated from the same source of truth.
You feed it raw observations — chat turns, documents, tool outputs — as
Episodes. It chunks, embeds, and (optionally) extracts entities, relations,
and facts using a small local LLM, then stores everything in a bi-temporal
MVCC store backed by Moon, a high-performance Redis-compatible
substrate (and, as of 0.7.0, the only backend). Agents query it through a
composable retrieval DSL that fuses semantic search, graph traversal, and
BM25 keyword lookup, with an optional cross-encoder rerank pass on top.
async fn demo() -> Result<(), lunaris::LunarisError> {
async fn demo() -> Result<(), Box<dyn std::error::Error>> {
use lunaris::{EpisodeBuilder, Lunaris, Query, Scope};
let lunaris = Lunaris::open("moon://127.0.0.1:6380").await?;
let scope = Scope::new("acme.agent-1")?;
let scoped = lunaris.scoped(scope);
let lsn = scoped.ingest(EpisodeBuilder::new("user-msg", "Alice loves chocolate.")).await?;
let hits = scoped.recall(Query::text("what does Alice like?")).await?;
Ok(())
}
Ok(())
}
Want a hybrid plan (vector + BM25, fused, reranked)? Compose it with the retrieval DSL:
use lunaris::{Lunaris, Scope};
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{Lunaris, Scope};
async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let lunaris = Lunaris::open("moon://127.0.0.1:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
use lunaris::{Keyword, Query, Vector};
let hits = scoped
.dsl()
.with_root(Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(5))
.execute(Query::text("what does Alice like?"))
.await?;
Ok(())
}
Ok(())
}
Use it from your agent (MCP)
Don’t want to write SDK code? Lunaris ships an MCP server so coding agents — Claude Code, Codex, or any MCP client — get persistent, scope-isolated memory over stdio. Install the binary and register it:
# no Rust toolchain needed — both download a prebuilt binary on first run
claude mcp add --transport stdio lunaris \
-e LUNARIS_MCP_STORAGE=moon://127.0.0.1:6381 \
-- npx -y @pilotspace/lunaris-mcp
# or: claude mcp add --transport stdio lunaris \
-e LUNARIS_MCP_STORAGE=moon://127.0.0.1:6381 \
-- uvx lunaris-mcp
Building from source instead? lunaris-mcp is not on crates.io (it
depends on a publish = false crate), so use the git form:
cargo install --git https://github.com/pilotspace/lunaris lunaris-mcp.
The agent then calls eleven memory.* tools — seven durable-memory tools
(ingest, recall, forget, list_scopes, record_decision,
record_edit, status) plus four working-memory scratchpad tools
(scratchpad_write, scratchpad_read, scratchpad_grep,
scratchpad_consolidate):
memory.ingest source="src:notes" content="The ingest pipeline writes one atomic_write per episode."
memory.recall query="ingest atomicity" k=3
Scope is derived per-repo from the git remote. LUNARIS_MCP_STORAGE must name
a Moon — there is no default store as of 0.7.0, and the server refuses to boot
without one. memory.recall then runs hybrid vector + BM25 recall. See
MCP Server for the full guide.
The three moats
Three properties define what Lunaris is. Every commit is reviewed against them; any feature that weakens any of the three is rejected.
| Moat | What it means | Where enforced |
|---|---|---|
| Sub-25 ms p50 recall | No LLM on the recall hot path. Measured p50 19.2–22.4 ms / p99 23.4–24.4 ms at 100k documents per scope, graph OFF, rerank OFF. The opt-in cross-encoder rerank is a quality stage, not a latency-class stage — it measures p50 1301.3 ms at top_in=60 and voids this contract when enabled. | scripts/bench/perf/recall_latency.sh all — a manual, local ~10-minute live-Moon gate. Not CI-enforced: perf-gates.yml is opt-in behind a perf-bench label, is not a required check, and is red on main (capacity.md) |
Single atomic_write per ingest | All-or-nothing commit across vector, KV, BM25, queue. Fan-out architectures (Mem0, Zep) can’t make this guarantee. | tests/ingest_pipeline.rs::single_atomic_write_call + CI grep gate |
| Bi-temporal MVCC + HLC | BiTemporal { valid, sys } on every primitive, on every backend — supersede closes intervals instead of destroying rows. As-of reads are search-side and graph-side (FT.SEARCH AS_OF, GRAPH.QUERY VALID_AT); historical KV reads have no version chain on Moon and are refused explicitly — see Core concepts. | Required field on Episode, Chunk, Entity, Fact, Relation, Community |
If everything else fails, that performance + correctness contract must hold — it’s what differentiates Lunaris from Mem0, Zep, and Cognee. See Why Lunaris for the honest “use a different tool when…” criteria.
How this book is organized
- Getting Started — install, a 10-minute quickstart, and the core concepts (episodes, scope, bi-temporal MVCC, the atomic write).
- Guides — one chapter per capability: ingest, the retrieval DSL, forget, the opt-in graph pipeline, consolidation & verification, multi-agent scoping.
- Cookbook — the built-in recipe types (chat agents, document corpora, Slack/email archives, code-repo memory, timelines) as copy-pasteable how-tos.
- Reference — the exhaustive
configuration reference (every feature flag and
LUNARIS_*env var), the generated API docs, and the error taxonomy. - Operations — running the HTTP server, choosing a backend, durability & recovery.
- SDKs — Python and TypeScript surface notes.
- Integrations (MCP) — the MCP server and its Claude Code / Codex integration guides.
- Migrating From — Mem0 / Zep / Cognee mapping tables.
- Protocol — the MemoryProtocol 0.1 HTTP/SSE wire spec and its conformance suite.
Source of truth. Where a claim in this book disagrees with the Rust source, the source wins. Many pages carry
path:linecross-references back into the crates; the generated API reference is built fromcargo docon every release.
Why Lunaris
Read this before evaluating Lunaris. It is the one-page elevator pitch plus the honest “use a different tool when…” criteria — the page a maintainer points at when someone asks “how does this compare to X?” The migration chapters go deeper; this page is the pitch and the exclusions.
The one-line claim
Sub-25 ms recall at 100,000 documents per scope, with provable atomicity and an opt-in graph. Embedded Rust core. Python + TypeScript bindings generated from the same source of truth. Apache 2.0. That’s it.
The latency half is measured: p50 19.2–22.4 ms / p99 23.4–24.4 ms
engine-side at 100k docs/scope on single-shard Moon v0.8.5 (Apple M4 Pro,
graph OFF, rerank OFF, k=30) —
capacity.md.
We do not claim “millions”: the 1k → 100k trend (0.7 ms → ~20 ms p50)
says a million-fact scope would not meet 25 ms p50 on that hardware, and no
run at that size exists.
You feed it raw observations — chat turns, documents, tool outputs — as
Episodes. It chunks, embeds, and (optionally) extracts entities,
relations, and facts using a small local LLM, then stores everything in a
bi-temporal MVCC store backed by Moon (a high-performance
Redis-compatible substrate) — and, since 0.7.0, only Moon: the Postgres and
SQLite backends were removed. Agents query it
through a composable retrieval DSL that fuses semantic search, graph
traversal, and BM25 keyword lookup, with an optional cross-encoder rerank
pass on top.
The three moats
Three properties define what Lunaris is. Every commit is reviewed against them; any feature that weakens any of the three is rejected.
| Moat | What it means | Where enforced |
|---|---|---|
| Sub-25 ms p50 recall | No LLM on the recall hot path. Measured p50 19.2–22.4 ms / p99 23.4–24.4 ms at 100k documents per scope, graph OFF, rerank OFF. The opt-in cross-encoder rerank is a quality stage, not a latency-class stage — it measures p50 1301.3 ms at top_in=60 and voids this contract when enabled. | scripts/bench/perf/recall_latency.sh all — a manual, local ~10-minute live-Moon gate. Not CI-enforced: perf-gates.yml is opt-in behind a perf-bench label, is not a required check, and is red on main (capacity.md) |
Single atomic_write per ingest | All-or-nothing commit across vector, KV, BM25, audit, and queue. Fan-out architectures (Mem0, Zep) can’t make this guarantee. | crates/lunaris-ingest/tests/ingest_pipeline.rs::single_atomic_write_call + CI grep gate |
| Bi-temporal MVCC + HLC | BiTemporal { valid, sys } on every primitive; forget and supersession close intervals instead of destroying rows. “What did the agent know at time T” is a query on the search and graph lanes (FT.SEARCH AS_OF, GRAPH.QUERY VALID_AT) — a historical KV read has no version chain on Moon and read_as_of refuses past the 1-hour live window rather than answering with today’s data. | Required field on Episode, Chunk, Entity, Fact, Relation, Community (crates/lunaris-core/src/bitemporal.rs); the refusal is pinned by crates/lunaris-conformance/tests/run_as_of_moon_gap.rs |
If everything else fails, that performance + correctness contract must hold — it’s what differentiates Lunaris from Mem0, Zep, and Cognee.
A few more differentiators, with proof:
| Differentiator | What it gets you | Proof source |
|---|---|---|
| Composable retrieval DSL | Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(5) is one typed expression. Hybrid search isn’t a feature flag; it’s an operator combinator. | crates/lunaris-retrieve/src/builder.rs |
| Type-enforced multi-tenancy | Scope::new(s)? validates against [A-Za-z0-9_\-.]{1,128}; the wire can’t smuggle a different scope past ScopedLunaris. On Moon the scope is baked into the key, the FT index name and the graph name, so a cross-scope read has nothing to address. (Postgres RLS was the second boundary through 0.6.2; that backend was removed in 0.7.0.) | crates/lunaris-core/src/scope.rs (RFC 0001) |
| Opt-in graph | Graph::anchored(entity_ids, hops) is an operator. Off by default — your dev box doesn’t load a graph extractor until you call lunaris.graph_pipeline().enable(). | crates/lunaris-retrieve/src/operators/graph.rs |
| Remote-only verifier, no accidental cost | The verifier resolves from LUNARIS_VERIFY_PROVIDER (anthropic/openai/gemini/minimax/openai-compat) or a caller-supplied impl. With no provider configured the effective verifier is NoopVerifier (a tracing::warn! says so) — opt in deliberately, no local model to stage. | crates/lunaris-verify/src/lib.rs |
| One substrate, not three | Moon holds the vector index, the graph, the keyword index, and the queue. No vector DB + graph DB + relational DB to operate. | Choosing a Backend |
When Lunaris is the answer
Pick Lunaris when you can say yes to most of these:
- My agent’s recall p50 is on the hot path of user experience — 300 ms feels slow.
- I want a single substrate (Moon) instead of running a vector DB + graph DB + relational DB.
- I need bi-temporal queries: “what did the agent believe at time T?” (bi-temporal writes always; as-of reads on the search and graph lanes only — a historical KV read is refused on Moon, so if you need to hydrate a row as it was, Lunaris v0 is not the tool)
- I need multi-tenant isolation that the type system enforces, not just
a
user_idstring the caller could swap. - I want a composable retrieval DSL where vector + keyword + graph fuse in one typed expression, not three API calls glued together.
- My stack is Rust, or Python with a Rust binary in the build chain is acceptable, or TypeScript with a NAPI binding is acceptable.
- Apache 2.0 + open source matters; I want to read the substrate code, not depend on a hosted service.
- I need a hosted SaaS with zero infra ownership. - Lunaris-cloud in progress (shared and managed memories in graph)
- My recall latency budget is 500+ ms anyway. The 25 ms contract isn’t free to operate; if you don’t need it, pick the hosted option.
How Lunaris compares to Mem0 / Zep / Cognee
| Lunaris | Mem0 | Zep | Cognee | |
|---|---|---|---|---|
| Core language | Rust (Py + TS bindings) | Python | Python / Go | Python |
| Recall latency contract | sub-25 ms p50, measured at 100k docs/scope (manual bench — not CI-enforced) | best-effort | best-effort | best-effort |
| Atomic ingest | single atomic_write, all-or-nothing | fan-out writes | fan-out writes | task pipeline |
| Bi-temporal | yes at the storage layer (valid + sys); as-of reads on the search + graph lanes, not on KV hydrate | no | yes | partial |
| Substrate count | 1 (Moon; Postgres and SQLite were removed in 0.7.0) | vector DB + store | vector DB + graph DB | configurable, multi |
| Hybrid retrieval | typed DSL: vector.and(keyword).fuse_rrf().top() | flag | flag | pipeline step |
| Graph | opt-in operator, off by default | Mem0g (Platform-only) | always-on | pipeline-driven |
| Multi-tenancy | Scope newtype + per-scope Moon keyspace, FT index and graph | user_id string | session_id | namespace |
| Hosted option | not yet (roadmap) | yes | yes (Zep Cloud) | self-host |
| License | Apache 2.0 | Apache 2.0 | Apache 2.0 | Apache 2.0 |
Already running one of these? The migration chapters walk through ingest, recall, time-travel, and forget — code-side, with honest “stay on $incumbent if…” criteria:
- Migrating from Mem0 — Mem0 has no bi-temporal layer, so the migration is the bi-temporal upgrade.
- Migrating from Zep — Zep already has bi-temporal facts, so the conversation is latency + substrate simplification (1 service vs 2).
- Migrating from Cognee — Cognee is pipeline-oriented; Lunaris is operator-oriented. The question is “where does your domain logic live — ingest time or recall time?”
What’s still on the runway
v0.2.x is the OSS-publish milestone, not the end state:
- v0.3 self-hosted — Docker / Helm, SLOs, design partners. The
hosted-substrate experience for teams that want Lunaris but don’t want
to operate Moon themselves. Also lands the typed
Scope+EpisodeBuildersurface in the Python / TS SDKs and per-scopeScopedLunaris::forget. - Ecosystem (shipped) — LangGraph / CrewAI / Letta adapters via the
lunaris-integrationspackage (pip install lunaris-integrations[langgraph],[crewai],[letta]— published from the nextv*tag; until then install it from a checkout, seeintegrations/README.md). The “drop Lunaris into your existing agent framework” experience: LangGraph and CrewAI are drop-in store / storage classes; Letta ships as a client-backed connector shim + recipe (its archival store is server-side). The MCP server remains the universal shim for frameworks without a dedicated adapter.
Where to go next
- Try it now → Installation, then the 10-Minute Quickstart.
- Understand the model → Core Concepts.
- Evaluate against your corpus → read the moat table above + the
migration chapter for your current tool, then run
examples/quickstart-py/against your real data.
Architecture at a Glance
Three pictures explain Lunaris. If you only have ninety seconds, read the captions; the full architecture page carries the crate-level detail and the proof links.
1. The layers
Lunaris is a pure-Rust engine with thin SDK shells. Your agent talks to the surface layer (Python, TypeScript, HTTP, or MCP). The engine turns observations into structured memory and queries into fused result sets. Everything below the engine is a port — a Rust trait — with one implementation behind it:
- Moon — our Redis-compatible substrate, and since 0.7.0 the only backend. The Postgres portability proof and the SQLite onboarding backend were removed; the port stays a trait, so a third-party substrate is still an open extension point.
You never write Moon commands or SQL. You write one typed expression (see The Retrieval DSL for the full operator set):
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{Keyword, Lunaris, Query, Scope, Vector};
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
let last_tuesday = lunaris_core::Hlc::from_parts(1_736_467_200_000, 0, 0);
// Hybrid recall: vector + BM25 fused server-side, with a time-travel cut.
let hits = scoped
.dsl()
.with_root(Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(5))
.as_of(last_tuesday)
.execute(Query::text("what did we decide about the pricing page?"))
.await?;
Ok(())
}
2. The data path
Ingest flows left to right: observation → chunking → local embedding (no API key, no network — models run in-process on CPU) → optional entity/fact extraction → one atomic commit. That last box is the heart of the system: vectors, keywords, graph edges, audit trail, and the consolidation queue land in a single transaction. Either your agent’s memory is complete, or the write didn’t happen. There is no “the vector store has it but the graph doesn’t” state — the failure mode every fan-out memory stack eventually hits.
Recall flows right to left: your query fans out across semantic, keyword, and graph lanes inside the substrate, gets fused by reciprocal-rank fusion, optionally reranked by a cross-encoder, and returns in p50 19.2–22.4 ms · p95 22.3–24.1 ms · p99 23.4–24.4 ms against the target corpus of 100,000 documents per scope — engine-side (query embedding excluded), graph OFF, rerank OFF, k=30, single-shard Moon v0.8.5 on an Apple M4 Pro, 500 timed queries after 50 warmup, run-to-run p50 drift ± 3 ms (envelope + method, raw samples). The published contract is p50 < 25 ms, so the headroom is ≤ 25 %. Since the 2026-06-10 concurrent-hydration fan-out the tail is flat even at k=30 — p50 6.0 ms / p99 6.2 ms (A/B methodology). The contract was re-validated on Moon v0.3.0 with the 4-bit GGUF granite embedder — retrieval-only p50 3.1 ms / p99 3.6 ms on a 3k-doc SQuAD corpus (v0.3.0 rerun, which also sizes the Navigate operator’s recall edge on graph-linked corpora: plain 0.00 → nav 1.00 recall@5 for +0.05 ms).
3. Why Moon makes this possible
The conventional agent-memory stack is three databases and a broker: a vector DB for similarity, a graph DB for relationships, a relational DB for records, and a queue for background work. Four systems, four failure domains, four consistency boundaries — and no transaction that spans them.
Moon collapses the stack into one process. The payoff isn’t abstract — it shows up feature by feature. Each Lunaris capability you actually use maps onto something Moon does natively, so the engine never reimplements a vector index, a BM25 scorer, a graph engine, or a transaction log on top of a dumb key-value store:
The same story as a lookup table — the command family behind each row:
| Capability | Moon command family | What Lunaris does with it |
|---|---|---|
| Transactions | TXN.BEGIN / TXN.COMMIT | The single atomic ingest commit |
| Vector KNN | FT.SEARCH | Semantic recall, auto-indexed on write |
| BM25 keywords | FT.SEARCH (same index) | Exact-term recall, no second system |
| Hybrid fusion | native RRF | Vector + keyword fused server-side |
| Time travel | AS_OF / VALID_AT clauses | “What did the agent believe at time T?” |
| Property graph | GRAPH.QUERY (Cypher) | Opt-in relationship traversal, per-tenant graphs |
| Bulk forget | FT.INVALIDATE_RANGE | GDPR-grade deletion over time ranges |
| Queues | native queue + pub/sub | Consolidation without a broker |
One substrate, one transaction boundary, one operational surface. That is the design bet — and the measured sub-25 ms recall is what the bet pays out.
How that lands against other tools
Most agent-memory engines do the same job; what differs is the guarantees. Because Lunaris pushes vectors, keywords, the graph, the queue, and a transaction boundary into a single substrate, several rows below are a ✓ for Lunaris where the fan-out tools can only manage a partial or an ✗:
Every cell above is taken straight from the full comparison table in Why Lunaris, which carries the nuance each mark compresses — plus the honest “use a different tool when…” criteria. The short version: Lunaris trades a hosted-SaaS option and Python-native simplicity for a latency contract and an atomicity guarantee the fan-out tools structurally can’t make.
Honest footnote: plain key-value point reads on Moon are not natively temporal (the engine applies the temporal cut itself), and index schemas are fixed at creation. The architecture page lists every limit next to every advantage.
Where next
- 10-Minute Quickstart — first recall against a local Moon
- Core Concepts — episodes, primitives, scopes, bi-temporal time
- The Retrieval DSL — composing vector / keyword / graph queries
- The Storage Backend — Moon setup and its honest limits
Installation
Add Lunaris to a Rust, Python, or TypeScript project, stand up the
storage backend it needs, and (optionally) run the HTTP server. For
the exhaustive list of feature flags and LUNARIS_* environment
variables, see the Configuration Reference.
Prerequisites
Lunaris needs a running Moon and an embedder. As of 0.7.0 there is one storage scheme:
| URL | Backend | Needs |
|---|---|---|
moon://host:port | Moon | a running Moon started with --shards 1 |
Every retired spelling — memory://, sqlite:///path, postgres://… —
is rejected by Lunaris::open with an UnsupportedScheme error carrying
the migration link. The Postgres and SQLite backends were deleted in
0.7.0; if you have data in one, migrate it before you bump your
lunaris pin — see
0.6 → 0.7.
Storage: Moon
docker run -d --name lunaris-moon -p 6380:6379 \
ghcr.io/pilotspace/moon:0.8.5 \
--shards 1 --protected-mode no --appendonly yes
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = lunaris::Lunaris::open("moon://127.0.0.1:6380").await?;
Ok(())
}
--shards 1 is not optional: a Lunaris ingest is one MULTI/EXEC
transaction, and a sharded Moon rejects cross-shard writes. The image
defaults to --shards 0 (auto), so the flag has to be passed explicitly.
--appendonly yes is what makes the store survive a restart.
Moon provides native FT.SEARCH (vector + BM25), GRAPH.QUERY, a message
queue, and native RRF fusion — the fuse_rrf operator collapses a
(Vector + Keyword) pair on the same index into one round trip.
Full production setup — persistence, memory limits, backups, health
probes — is in
Running an external Moon. See
Choosing a Backend for the
embedding-dimension story (the Moon adapter sizes its vector index to the
embedder — default 768-d, set wider via connect_with_dim).
Embedder: in-process llama.cpp, no external service required
v0.6 llama.cpp-only cutover. The candle inference stack is deleted. The only local embed/rerank runtime is
lunaris-llamacpp— in-process llama.cpp FFI, static-linked, no external server. Seedocs/decisions/2026-07-10-llamacpp-only-cutover.md(the cutover ADR) anddocs/migration/0.5-to-0.6-llamacpp-only.md(the migration guide).
The default embedder is granite-embedding-311m-multilingual-r2 (768-d),
loaded from a Q4_K_M GGUF (~240 MiB) via in-process llama.cpp — no
Ollama, no external service required. The default reranker is
bge-reranker-v2-m3 (Q5_K_M GGUF, ~446 MiB), lazy-loaded on first recall.
Both GGUFs are expected at ~/.lunaris/models/; the MCP server stages them
lazily on first recall, other deployments download them out-of-band. An
air-gapped Ollama HTTP embedder remains available as an operator escape hatch
behind --features embed-remote (resolves after the llama.cpp step).
Override the artifact paths with LUNARIS_EMBEDDER_GGUF=<path> /
LUNARIS_RERANKER_GGUF=<path>. There is no auto-download in the umbrella
crate — a missing GGUF logs a WARN and falls back to NoopEmbedder /
NoopReranker. Operators running an air-gapped Ollama for embedding (not the
supported path) can set LUNARIS_EMBEDDER_OLLAMA_URL=<endpoint> alongside
--features embed-remote.
Building the llamacpp feature (default) needs cmake + a C++ toolchain.
For a pure-Rust, no-inference build (Tier-0, small devices):
default-features = false → NoopEmbedder/NoopReranker.
See the Configuration Reference for the full feature matrix.
Rust
The umbrella crate is published as lunaris-memory (the bare lunaris
name on crates.io is owned by an unrelated project); the library itself is
still named lunaris, so you import it as use lunaris::…. Rename it in
Cargo.toml:
[dependencies]
lunaris = { package = "lunaris-memory", version = "0.6" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
cargo add lunaris-memory --rename lunaris
cargo add tokio --features macros,rt-multi-thread
Feature flags on the lunaris umbrella crate (full table in the
Configuration Reference):
Note.
llamacppis the only local embed/rerank runtime and is on by default — building it needs cmake + a C++ toolchain. The flags below also gate the extractor/verifier remote-provider backends and the escape-hatch paths.
| Feature | Default | Effect |
|---|---|---|
llamacpp | ✅ | In-process llama.cpp embedder (granite-r2 Q4_K_M GGUF) + reranker (bge-reranker-v2-m3 Q5_K_M GGUF) |
metal / cuda / vulkan | GPU offload — forwards to llama-cpp-2’s backends | |
embed-remote | Ollama HTTP embedder escape hatch (operator-only, not the supported path); resolves after the llamacpp step | |
ollama | OllamaExtractor / Ollama HTTP verifier backend selector (NOT the embedder) | |
cloud-api | Cloud-API extractor / verifier backends (pulls reqwest) |
default = ["llamacpp"]. Extractor and verifier are remote-only —
LUNARIS_EXTRACT_PROVIDER / LUNARIS_VERIFY_PROVIDER
(anthropic|openai|gemini|minimax|openai-compat, the last
covering Ollama / llama-server / vLLM / LM Studio via
LUNARIS_OPENAI_COMPAT_BASE_URL) or a caller-supplied with_extractor /
with_verifier impl; unset resolves to NoopExtractor/NoopVerifier. For a
pure-Rust, no-C++-toolchain build (Tier-0): default-features = false.
Smoke test — needs the Moon from Prerequisites running:
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = lunaris::Lunaris::open("moon://127.0.0.1:6380").await?;
println!("{lunaris:?}");
Ok(())
}
There is no zero-dependency scheme to start from since 0.7.0 — open
either reaches a Moon or returns an error naming what to start.
Python — pip install lunaris
pip install lunaris # or: uv add lunaris
PyO3 0.26 baseline; Python 3.11+. The wheel bundles the compiled
extension — no Rust toolchain needed at install time. The Python surface
mirrors the Rust handle: await lunaris.open(url) →
await handle.ingest(episode) → await handle.recall(). See the
Python SDK chapter for the full surface.
Developing against this repo before the PyPI release? Build the binding in place with
maturin develop --releasefromcrates/lunaris-py/, thenimport lunarisresolves to the local build.lunaris-pyis acdyliband is excluded fromcargo test --workspace.
TypeScript — npm i @pilotspace/lunaris
npm i @pilotspace/lunaris
napi-rs 3.x, Node 20+ ABI. Prebuilt .node binaries ship in the
package. The surface mirrors the Rust handle: await lunaris.open(url)
→ await handle.ingest(episode) → await handle.recall(). See the
TypeScript SDK chapter.
Developing against this repo before the npm release? Run
npm run build(=napi build) fromcrates/lunaris-ts/, thennpm install ../../crates/lunaris-tsfrom your project.lunaris-tsis acdyliband is excluded fromcargo test --workspace.
Running the HTTP server
Non-Rust runtimes talk to Lunaris through lunaris-server — an axum
service implementing MemoryProtocol 0.1 (/v1/{ingest,recall,forget,snapshot},
HTTP + SSE). It needs a storage URL and a bearer-token map:
# One-time: a bearer-token map. Every /v1/* request needs a token from it.
cat > /tmp/lunaris-tokens.json <<'EOF'
{
"dev-token-xxx": { "tenant": "acme", "scopes": ["acme.agent-1", "acme.agent-2"] }
}
EOF
cargo run -p lunaris-server -- \
--storage moon://127.0.0.1:6380 \
--bind 0.0.0.0:8080 \
--tokens-file /tmp/lunaris-tokens.json
Every CLI flag has a matching LUNARIS_* env var (the CLI flag wins).
The key knobs:
| Flag | Env | Default |
|---|---|---|
--bind | LUNARIS_BIND | 0.0.0.0:8080 |
--storage | LUNARIS_STORAGE | (required) |
--tokens-file | LUNARIS_TOKENS_FILE | (required) |
--rate-per-second | LUNARIS_RATE_PER_SECOND | 60 |
--rate-burst | LUNARIS_RATE_BURST | 120 |
--shutdown-grace-secs | LUNARIS_SHUTDOWN_GRACE_SECS | 30 |
The tenant claim from the bearer token is the only source of truth
for the partition scope — route handlers ignore any scope / tenant
field on the request body. Probe surfaces /healthz and /metrics are
unauthenticated. Full route list, DTOs, and the SSE contract:
Running the HTTP Server and the
MemoryProtocol 0.1 spec.
Removed in 0.7.0.
lunaris-server migrateandlunaris-server bootstrap-dbwere Postgres-only (embedded migration set, RLS app-role provisioning) and went with the backend, along withLUNARIS_ADMIN_URL. Moon needs no schema migration and no role bootstrap — start it, point--storageat it. Indexes are created on first connect.
Next
- 10-Minute Quickstart — ingest and recall your first episode, Rust / Python / TypeScript side by side.
- Core Concepts — episodes, scope, bi-temporal MVCC, the atomic write.
- Configuration Reference — every
feature flag and
LUNARIS_*variable.
10-Minute Quickstart
From a fresh checkout to your first ingest + recall against a local Moon
in under ten minutes — Rust is canonical, with Python and TypeScript
mirrors side by side. Moon is the only backend as of 0.7.0; the Postgres
and SQLite paths this chapter used to open with were deleted (see
0.6 → 0.7).
The default embedder is in-process llama.cpp granite-r2 (GGUF) — no Ollama
needed for embedding. The shipped examples/quickstart-rs crate opts into
the ollama build to enable the Ollama extractor + verifier path —
see step 2.
API note. This chapter uses the retrieval surface that exists in the v0.2.x source:
ScopedLunaris::recall(Query::text(...))returnsVec<Hit>directly, andScopedLunaris::dsl()(or the bareLunaris::recall()) returns aRetrievalBuilderyou drive with.with_root(...)+.execute(Query::text(...)). A fluent shorthand likerecall().vector("chunks", 30).top(5).execute()is a planned ergonomic wrapper that is not yet on the builder —RetrievalBuilderhas no.vector(...)method today. When in doubt, the Rust source wins.
0. Get the code
git clone https://github.com/pilotspace/lunaris && cd lunaris
1. Bring up Moon
The examples/quickstart-*/ directories share one compose file, which runs
the published ghcr.io/pilotspace/moon image on localhost:6380:
cd examples/quickstart-rs
docker compose up -d
docker compose ps # wait until lunaris-quickstart-moon is "healthy"
There is no schema step — Moon needs no migration, and Lunaris creates its
indexes on first connect. Note the compose file passes --shards 1: a
Lunaris ingest is one MULTI/EXEC transaction and a sharded Moon rejects it.
2. Point Lunaris at it
export LUNARIS_STORE_URL="moon://127.0.0.1:6380"
The quickstart binary has no default URL — an unset LUNARIS_STORE_URL
is an error, not a fallback to an in-process store.
The default embedder is granite-embedding-311m-multilingual-r2 (768-d),
loaded from a Q4_K_M GGUF in-process via llama.cpp (staged at
~/.lunaris/models/) — no Ollama needed for embedding.
The shipped examples/quickstart-rs crate pins features = ["ollama"]
in its Cargo.toml to enable the Ollama extractor + verifier path
(the smallest external-dep build that exercises a real extraction + verification
flow). The embedder and reranker remain in-process llama.cpp regardless. So
for this walkthrough, start Ollama and pull the extractor model:
ollama serve &
ollama pull gemma3:4b
Prefer a cloud extractor instead of Ollama? Set
LUNARIS_EXTRACT_PROVIDER=minimax (or anthropic/openai/gemini) and the
matching API key — extraction and verification are remote-only in v0.6, so
there is no all-in-process alternative to Ollama for those two stages.
3–6. Open a handle, ingest, recall, forget
What follows is the canonical Rust flow; the Python and TypeScript mirrors come right after.
Rust
use std::env;
use anyhow::{Context, Result};
use lunaris::{EpisodeBuilder, ForgetTarget, Lunaris, Query, Scope, ScopeSpec, Vector};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
// 3. Open a handle. `moon://host:port` is the only scheme `open`
// accepts since 0.7.0; anything else is an UnsupportedScheme error.
let url = env::var("LUNARIS_STORE_URL")
.context("set LUNARIS_STORE_URL — see examples/quickstart-rs/README.md")?;
let lunaris = Lunaris::open(&url).await.context("Lunaris::open")?;
// The Scope newtype is the multi-agent partition key (RFC 0001).
// Scope::new validates the string against [A-Za-z0-9_\-.]{1,128}.
let scope = Scope::new("quickstart").context("Scope::new")?;
let scoped = lunaris.scoped(scope);
// 4. Ingest one episode. ScopedLunaris::ingest takes an
// EpisodeBuilder (scope-less payload); the wrapper stamps the
// bound scope on. Exactly one atomic_write per call (INGEST-04).
let lsn = scoped
.ingest(EpisodeBuilder::new(
"quickstart:demo",
"# Hello from Lunaris\n\nAlice loves chocolate.",
))
.await
.context("ingest")?;
println!("ingested at lsn={lsn:?} under scope `quickstart`");
// 5a. Recall — the one-shot form. ScopedLunaris::recall(query) runs the
// default plan (Vector over `chunks`, no fusion/rerank) and returns
// Vec<Hit> directly. Least ceremony for a plain semantic lookup.
let hits = scoped
.recall(Query::text("who loves chocolate"))
.await
.context("recall")?;
println!("recalled {} hit(s)", hits.len());
for h in &hits {
println!(" hit id={:?} score={:.3}", h.id, h.score);
}
// 5b. Recall — the composable form. ScopedLunaris::dsl() returns a
// RetrievalBuilder pre-seeded with this scope; .with_root sets the
// operator tree, .execute runs the plan once and returns Vec<Hit>.
// Reach for this when you want hybrid fusion, graph/tree, as_of,
// or rerank. (Here: cap the same default Vector plan at top-5.)
let hits = scoped
.dsl()
.with_root(Vector::new("chunks", 30).top(5))
.execute(Query::text("who loves chocolate"))
.await
.context("recall (dsl)")?;
println!("recalled {} hit(s) via the DSL", hits.len());
// 6. Forget — soft delete (MVCC: stamps bt.sys_to; prior as_of
// reads still see it). A dry-run preview never writes.
//
// NOTE (v0.2.x): Lunaris::forget is hard-coded to Scope::dev()
// today, so a forget issued under a real scope like `quickstart`
// silently matches zero rows. The dry-run preview below is safe
// to run regardless; per-scope ScopedLunaris::forget lands in
// v0.3. See CHANGELOG.md "v0.2.0 — Known issues".
let preview = lunaris
.forget(ForgetTarget::Scope(ScopeSpec::BySource("quickstart:".into())).dry_run())
.await
.context("forget dry-run")?;
println!("forget preview: preview={} rows_would_write={}", preview.preview, preview.rows_written);
Ok(())
}
Run it:
cargo run --release
Expected output (LSN values vary):
ingested at lsn=Lsn { wall_ms: 1713789012345, counter: 0 } under scope `quickstart`
recalled 1 hit(s)
hit id=... score=...
forget preview: preview=true rows_would_write=...
Hybrid recall, one line more. Add BM25 keyword search and fuse it with reciprocal-rank fusion — when both legs sit on the same Moon index
fuse_rrfcollapses them into a single round trip, otherwise it fuses the leg results client-side. Same API either way:use lunaris::{Keyword, Lunaris, Query, Scope, Vector}; async fn demo() -> Result<(), lunaris::LunarisError> { let lunaris = Lunaris::open("moon://localhost:6380").await?; let scoped = lunaris.scoped(Scope::new("quickstart")?); use lunaris::{Keyword, Vector}; let hits = scoped .dsl() .with_root( Vector::new("chunks", 30) .and(Keyword::bm25("chunks", 30)) .fuse_rrf(60) .top(5), ) .execute(Query::text("who loves chocolate")) .await?; Ok(()) }Add
.rerank(lunaris.reranker())before.top(5)for the cross-encoder pass. The full operator catalogue is in The Retrieval DSL.
Python (pip install lunaris)
The typed Scope + EpisodeBuilder Python surface lands in v0.3; today
the wire shape is a dict that mirrors lunaris_core::primitives::Episode
(the scope field is required).
import asyncio, os
import lunaris
import ulid # pip install python-ulid
def build_episode(scope: str, content: str) -> dict:
return {
"id": str(ulid.ULID()),
"scope": scope,
"source": "quickstart:demo",
"content": content,
"t_ref": None,
"bt": {
"valid": [{"wall_ms": 0, "counter": 0, "node_id": 0}, None],
"sys": [{"wall_ms": 0, "counter": 0, "node_id": 0}, None],
},
"metadata": {},
}
async def main() -> None:
url = os.environ["LUNARIS_STORE_URL"]
handle = await lunaris.open(url) # 3. open
lsn = await handle.ingest(build_episode("quickstart", # 4. ingest
"Alice loves chocolate."))
print(f"ingested at lsn={lsn} under scope `quickstart`")
# 5. recall — the DSL is reachable from handle.recall(); the typed
# Scope binding and the recall walkthrough land alongside the v0.3
# SDK story. See examples/quickstart-py/README.md.
asyncio.run(main())
Run: python quickstart.py (or maturin develop --release from
crates/lunaris-py/ first if you’re on a repo checkout). See the
Python SDK chapter.
TypeScript (npm i @pilotspace/lunaris)
Same story — dict-shaped episode today, typed surface in v0.3.
import * as lunaris from "@pilotspace/lunaris";
function buildEpisode(scope: string, content: string): object {
const ts = Date.now();
const id = `01${ts.toString(32).toUpperCase().padStart(10, "0")}`
.padEnd(26, "0").slice(0, 26);
return {
id,
scope,
source: "quickstart:demo",
content,
t_ref: null,
bt: {
valid: [{ wall_ms: 0, counter: 0, node_id: 0 }, null],
sys: [{ wall_ms: 0, counter: 0, node_id: 0 }, null],
},
metadata: {},
};
}
const handle = await lunaris.open(process.env.LUNARIS_STORE_URL!); // 3. open
const lsn = await handle.ingest(buildEpisode("quickstart", // 4. ingest
"Alice loves chocolate."));
console.log(`ingested at lsn=${lsn} under scope \`quickstart\``);
// 5. recall — handle.recall() exposes the DSL; the typed-Scope binding
// and the recall walkthrough land with the v0.3 SDK story. See
// examples/quickstart-ts/README.md.
Run: npx tsx quickstart.mts (or npm run build from
crates/lunaris-ts/ first on a repo checkout). See the
TypeScript SDK chapter.
Tear-down
docker compose down -v # -v wipes the pg data volume
What you just did
| Step | Rust | What it is |
|---|---|---|
| Open | Lunaris::open(url) | moon://host:port — the only scheme (0.7.0) |
| Scope | Scope::new("quickstart")? | the validated multi-agent partition key (RFC 0001) |
| Bind | lunaris.scoped(scope) | all ops on the returned ScopedLunaris are partitioned |
| Ingest | scoped.ingest(EpisodeBuilder::new(src, body)) | one atomic_write: chunk + embed + commit |
| Recall | scoped.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text(q)) | one read pass, returns Vec<Hit> |
| Forget | lunaris.forget(target.dry_run()) | MVCC soft delete + audit event (scoped variant in v0.3) |
Next
- Core Concepts — the Episode → ingest → storage →
recall mental model, bi-temporal MVCC, the
Scopekeyspace, the singleatomic_writeinvariant. - The Retrieval DSL — every operator and fusion / rerank / fallback combinator.
- Ingesting Observations — chunking, embedding, the graph pipeline.
Core Concepts
The mental model behind Lunaris: you write Episodes, ingest turns
them into stored primitives, recall composes a read plan over them — and
every layer is bi-temporal, scope-partitioned, and committed in one
atomic write. Read this once and the rest of the book is configuration
detail.
The flow: Episode → ingest → storage → recall
Episode
|
v
+-------------+ (optional) +----------------+
| ingest | -- graph pipeline ON --> | extract |
| (chunk, | | entities + |
| embed, | | relations + |
| atomic | | facts |
| write) | <---- validator --------+----------------+
+------+------+
| ^
v |
+-------------+ +--------------+
| storage | <-- MVCC supersede -------> | forget |
| (KV/Vector/ | (soft/hard) | (GDPR/audit) |
| Graph/ | +--------------+
| Queue) |
+------+------+
|
recall v rerank
builder -+-> [vector] ----\ queue
+-> [bm25 ] ---- fuse_rrf --> rerank --> Hit[]
+-> [graph] ----/ |
v
+-------+-------+
| __lunaris_ |
| verify__ | <-- verifier worker (default OFF)
| __lunaris_ |
| consolidate__| <-- consolidator worker (default OFF)
| __lunaris_ |
| audit__ | <-- audit sink
+---------------+
ingestchunks the episode body (markdown-aware, ~500-token target with ~100-token overlap), embeds each chunk in batches, and commits everything — episode KV row, per-chunk KV rows, per-chunk vector upserts, plus graph/fact rows if the graph pipeline is on — in a singleStoragePort::atomic_writecall. Then it publishes one message to__lunaris_consolidate__(best-effort; the data is already durable).forgetis the inverse: it also commits in oneatomic_write(soft delete stamps the MVCCsysend; hard delete is aKvDeletefan-out behind a two-step confirmation token), and emits one audit event.recallis read-only: aRetrievalBuildercomposesVector,Keyword,Graph, andTree(RAPTOR hierarchical) operators (plus fusion / rerank / fallback wrappers) into a plan, executes it once, and returnsVec<Hit>. The one-shotScopedLunaris::recall(query)is the same builder with its default root left in place. No LLM is on this path — that’s the sub-25 ms moat.
The primitives
Six primitive types, each carrying a BiTemporal stamp and a Scope
(crates/lunaris-core/src/primitives.rs):
| Primitive | What it is | Produced by |
|---|---|---|
Episode | A raw observation: chat turn, document, tool output. { id, scope, source, content, t_ref?, bt, metadata }. The only thing you write directly. | you, via EpisodeBuilder |
Chunk | A retrieval-sized slice of an episode: { id, scope, episode_id, text, tokens, offset, heading_path, overlap_tail, embedding?, bt }. The unit vector search ranks. | ingest (markdown chunker) |
Entity | A named thing — { id, scope, name, aliases, entity_type, embedding?, bt, confidence }. | the extractor (graph pipeline) |
Relation | A typed edge between two entities. | the extractor |
Fact | An extracted assertion, contradiction-checked by the validator. | the extractor + validator |
Community | A Leiden-detected cluster of entities, surfaced by the consolidator. | the consolidator |
Only Episode is written by your code; everything below it is derived.
Recall queries one of four whitelisted index names — chunks,
entities, facts, communities — e.g. Vector::new("chunks", 30).
Bi-temporal MVCC + the HLC
Every primitive carries a BiTemporal (crates/lunaris-core/src/bitemporal.rs):
use lunaris_core::Hlc;
async fn demo() -> Result<(), lunaris::LunarisError> {
pub struct BiTemporal {
pub valid: (Hlc, Option<Hlc>), // when the fact is true in the world
pub sys: (Hlc, Option<Hlc>), // when the system observed/recorded it
}
Ok(())
}
Both are half-open [from, to) intervals; to = None means “still
current”. valid is world time — “Alice loved chocolate from Jan to
March”. sys is system time — “we recorded that on Feb 3rd, and
superseded it on April 1st”. The two axes are independent, which is what
lets you ask “what did the agent believe at time T?” as a query
(.as_of(ts) on the retrieval builder, or read_as_of on the storage
port) rather than reconstructing it from a log.
Updates are MVCC supersede, never in-place mutation: a soft delete
or an arbitration outcome stamps the old row’s sys end and writes a
new row — old as_of reads still resolve correctly. (Backends derive
persisted bitemporal from the payload bytes, so a typed-only mutation
would be silently lost; the supersede writers patch both.)
Which half is universal. The bi-temporal write model above holds on every backend. As-of reads do not:
StoragePort::read_as_ofcan answer a historical pin only where the backend keeps a KV version chain (supports_historical_kv_reads() == true), and the two backends that did were deleted in 0.7.0. Moon stores Lunaris rows as plain hashes with no version chain, so since v0.6.2 it refuses a historical pin withStorageError::NotSupported(HTTP501 not_supported) rather than answering with present-time data. Latest-state reads — what every recall, hydrate andforgetactually issues — work everywhere, and Moon’s search/graph lanes remain temporal viaFT.SEARCH AS_OFandGRAPH.QUERY VALID_AT. Closing the KV gap upstream is tracked against Moon’sTemporalKvIndex.
The clock is a Hybrid Logical Clock (crates/lunaris-core/src/hlc.rs):
Hlc { wall_ms: u64, counter: u32, node_id: u16 }, totally ordered by
(wall_ms, counter, node_id). When the wall clock doesn’t advance
between two ticks, the counter increments — so no two stamps ever
collide, even under burst writes. node_id is 0 in single-node v0.
Scope: the multi-agent partition key (RFC 0001)
Every Lunaris operation is partitioned by a Scope — a validated
newtype (crates/lunaris-core/src/scope.rs), not a user_id string the
caller could swap:
use lunaris::{EpisodeBuilder, Scope};
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scope = Scope::new("acme.agent-1")?; // validates against [A-Za-z0-9_\-.]{1,128}
let scoped = lunaris.scoped(scope); // ScopedLunaris — every op partitioned
scoped.ingest(EpisodeBuilder::new("user-msg", "Alice loves chocolate.")).await?;
Ok(())
}
Scope::new rejects the empty string, anything over 128 bytes, and any
character outside [A-Za-z0-9_\-.]. : is not in the alphabet — by
design, so the KV key format can’t byte-alias across scopes (see below).
Scope does not derive Deserialize transparently — the hand-rolled
impl re-runs the validating constructor, so the wire can’t smuggle an
invalid or unintended scope past the type. (Scope::dev() exists but is
#[doc(hidden)] — a migration crutch for code paths that haven’t
threaded a real scope through yet.)
On the HTTP surface, the tenant claim resolved for the bearer token
is the only source of truth for the partition scope — route handlers
ignore any scope field on the request body. On Moon, the scope is
baked into the key, the FT index name and the graph name
(lunaris:{scope}:{kind}:{ulid}, lunaris_{scope}_{kind}_idx), so a
cross-scope read has nothing to address rather than being filtered after
the fact. Same ULID under two different scopes = two distinct rows, no
leak.
Through 0.6.2 a second boundary existed: Postgres row-level security (
USING+WITH CHECK, under aNOSUPERUSER NOBYPASSRLSrole). That backend was removed in 0.7.0, so today the isolation is structural in the Moon keyspace rather than row-filtered in a database. Details: Multi-Agent & Scope.
The keyspace
KV keys are minted exclusively by lunaris_core::keyspace —
episode_key, chunk_key, entity_key, relation_key, fact_key,
community_key — in the canonical format:
lunaris:{scope}:{kind}:{ulid}
e.g. lunaris:acme.agent-1:chunk:01J.... Backend crates re-export these
helpers; minting a Lunaris KV key from a local helper is a bug. Because
: is forbidden in Scope, the {scope} segment can never absorb the
:{kind} delimiter — scope isolation is closed at the type level, not
by operator discipline.
The single atomic_write invariant (INGEST-04)
This is the correctness moat. One ingest call = exactly one
StoragePort::atomic_write — never one commit per chunk. The pipeline
builds the full Vec<WriteOp> (episode KV + per-chunk KV + per-chunk
vector upsert, plus graph/fact rows when the graph pipeline is on) and
hands it to the backend in a single call. Either all of it lands or none
of it does — across the vector index, the KV store, the BM25 index, the
audit log, and the queue. Fan-out architectures (write to the vector DB,
then the store, then the graph) can’t make that guarantee; Lunaris can
because it owns the substrate.
It’s grep-pinned and CI gates on it on every push: after stripping comment
lines, exactly one real storage.atomic_write call site must remain in
crates/lunaris-ingest/src/pipeline.rs
(grep -v '^\s*//' crates/lunaris-ingest/src/pipeline.rs | grep -c 'storage\.atomic_write'
== 1) — see Ingesting Observations
for the exact gate and the graph-on counterpart. Any new ingest fan-out extends
the single WriteOp vector — it does not add a second atomic_write. forget
holds the same property: one atomic_write per call (zero for a
dry-run preview).
Opt-in pipelines: graph, verify, consolidate
Three background pipelines, all default OFF — your dev box doesn’t download a Gemma extractor until you ask:
| Pipeline | What it does | Enable |
|---|---|---|
| Graph | Extract entities / relations / facts from each ingested episode; populate the graph index so Graph::anchored(...) recall works. | lunaris.graph_pipeline().enable() or LUNARIS_GRAPH_ENABLED=1 |
| Verify | Slow-path arbitration: consume __lunaris_verify__ items, resolve contradictions, MVCC-supersede the loser (RFC 0006). Pluggable model: Gemma-3-27B / -270M (laptop floor) / Ollama / cloud. | lunaris.verify_pipeline().enable() or LUNARIS_VERIFY_ENABLED=1 |
| Consolidate | ACT-R base-level activation (Anderson 1996) + Leiden community detection; consume one __lunaris_consolidate__ message per ingest commit, promote/archive memories. | lunaris.consolidator_pipeline().enable() or LUNARIS_CONSOLIDATE_ENABLED=1 |
Each handle is obtained from the Lunaris value after open. The
consolidator handle additionally exposes .enable_for_scope(prefix)
(a source-prefix filter) for per-scope rollout; the graph and verify
handles are .enable() / .disable() only in v0.2.x. Enabling a
pipeline with no real backend wired just runs the shipped Noop* impl
— no crash, but no useful work either. Details:
The Graph Pipeline and
Consolidation & Verification.
The two backends
Lunaris::open(url) dispatches on the URL scheme:
| Scheme | Backend | Notes |
|---|---|---|
moon://host:port | Moon (Redis-compatible substrate) | Native FT.SEARCH (vector + BM25), GRAPH.QUERY, message queue, native RRF fusion (one round trip for vector.and(keyword).fuse_rrf()). The Moon adapter sizes its vector index to the configured embedder (default 768-d; a wider embedder works on Moon too via Lunaris::open / connect_with_dim). |
postgres:// / postgresql:// / memory:// / sqlite:///path | — | Removed in 0.7.0. StorageError::UnsupportedScheme, with the message naming the migration guide. |
| anything else | — | StorageError::UnsupportedScheme |
Moon is the only backend. Start it with --shards 1 — an ingest is one
MULTI/EXEC transaction and a sharded Moon rejects it. Embedding-dimension
details and the STORE-07 as-of gap:
The Storage Backend.
Next
- The Retrieval DSL — every operator and combinator.
- Ingesting Observations — the chunker, the embedder driver, the graph branch.
- Durability & Recovery — how the bi-temporal store survives a crash.
- Configuration Reference — every
feature flag and
LUNARIS_*variable.
Where this chapter disagrees with the Rust source, the source wins — the
path:linereferences above point back into the crates.
Data Structures: A Visual Tour
Lunaris transforms raw observations into a rich set of structured primitives before storing them in a bi-temporal MVCC store. This page walks through each stage visually — from the Episode envelope that wraps every input, through chunking and embedding, all the way to the indexed storage layer that makes sub-25ms recall possible.
Step 1 — Observation → Episode
Every agent turn, tool result, or document arrives as a raw Observation and is immediately wrapped in an Episode envelope. The Episode captures identity (id, scope, source), the content string, a reference timestamp (t_ref), free-form metadata, and a BiTemporal record (bt) that tracks both valid-time and system-time intervals.
Step 2 — Chunking + DocTree
The episode content is split into overlapping Chunks using a BPE token counter (target 500 tokens, overlap 100). Each Chunk carries its heading breadcrumb (heading_path), the overlap bridge to the previous chunk (overlap_tail), and a parent_id that will point to its RAPTOR parent node. Heading records also build a DocTree capturing the document’s structural outline.
Step 3 — Embedding + RAPTOR Tree
Each Chunk receives a 768-dimensional embedding from the granite-embedding-311m model in batches of 32. The RAPTOR tree then connects Chunks upward: Community nodes at each level carry a bottom-up extractive summary and a summary_embedding, forming a hierarchy that lets recall query at any granularity.
Step 4 — Graph Primitives
When graph extraction is enabled — or when structured ingest supplies pre-parsed data — Entities, Relations, and Facts are derived. Entity IDs are deterministic blake3 hashes so the same real-world entity always maps to the same id across ingests. Facts carry an activation field that the ACT-R consolidator uses for recency weighting.
Step 5 — Atomic Persist
All write operations — DocTree KvPuts, Episode KvPut, per-Chunk KvPut + VectorUpsert into the “chunks” FT index, per-Community KvPut + VectorUpsert into the “communities” FT index, and optional GraphEdge writes — are collected into a single Vec<WriteOp> and submitted via one storage.atomic_write call. The INGEST-04 invariant guarantees exactly one atomic_write per ingest.
Step 6 — Keyspace
Every primitive is stored under the canonical key format lunaris:{scope}:{kind}:{ulid}, where scope is a validated string (alphabet [A-Za-z0-9_-.]{1,128}, : explicitly rejected), kind is one of episode, chunk, entity, relation, fact, or community, and the ulid provides a sortable 128-bit unique identifier.
Step 7 — Bi-temporal MVCC
Every primitive carries a BiTemporal record with two interval fields: valid (when the fact was true in the world) and sys (when it was recorded in Lunaris). Each write appends a new version row; old versions are never deleted.
read_as_of(T) returns the version visible at logical time T — on a backend that keeps a KV version chain. No 0.7.0 backend does. Moon’s Lunaris rows are plain hashes, so a historical read_as_of returns an explicit StorageError::NotSupported (HTTP 501 not_supported) instead of quietly handing back today’s row, and the Postgres/SQLite backends that answered it (supports_historical_kv_reads() == true) were deleted in 0.7.0. Latest-state reads are unaffected, and the search and graph lanes stay temporal through FT.SEARCH AS_OF / GRAPH.QUERY VALID_AT.
Step 8 — Indexes
Three indexes sit over the store. A vector FT index on “chunks” and a separate one on “communities” enable 768-d cosine similarity search. A BM25 keyword index enables term-frequency scoring. Graph edges capture Relations for graph traversal. Moon’s native FT.* commands serve all three modes — Lunaris does not bundle a separate HNSW or BM25 library. At query time, results from all lanes are fused using RRF (k=60).
Ingesting Observations
Reach for this chapter when you need to put data into Lunaris — durably, with
exactly one storage transaction per call. Every write begins with
ScopedLunaris::ingest. One call commits one atomic_write on the backend,
whether the graph pipeline is on or off.
The shape
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{EpisodeBuilder, Lunaris, Scope};
let lunaris = Lunaris::open("moon://127.0.0.1:6380").await?;
let scope = Scope::new("acme.agent-1")?; // partition key — no colons
let scoped = lunaris.scoped(scope);
let lsn = scoped
.ingest(EpisodeBuilder::new("notes.md", "# Notes\nThe quick brown fox."))
.await?;
Ok(())
}
EpisodeBuilder (crates/lunaris/src/episode_builder.rs) is a scope-less
payload builder — source, content, and the optional t_ref / metadata
/ id. The scope is stamped exactly once, by ScopedLunaris::ingest, via the
pub(crate) EpisodeBuilder::into_episode. Callers cannot reach around the
ScopedLunaris wrapper to inject an arbitrary scope — that is the type-level
guard behind multi-agent isolation.
| Builder method | Effect |
|---|---|
EpisodeBuilder::new(source, content) | Required. source is the namespace-qualified origin ("helios:fs/report.md", "chat:session-42/turn-7"); content is the raw text that gets chunked + embedded. |
.id(ulid) | Override the auto-generated ULID — for idempotent replay / migration tooling. Default: fresh Ulid::new(). |
.t_ref(chrono::DateTime<Utc>) | Set the valid-time anchor. Default: the engine’s HlcClock wall time at ingest. |
.metadata(map) | Merge serde_json key/value pairs onto the episode. |
The returned value is an Lsn — a replay cursor, not a primary key. It
tells the snapshot endpoint (GET /v1/snapshot/{lsn}) where to resume.
De-dupe on Episode::id, never on Lsn.
What ingest does
ScopedLunaris::ingest stamps the scope and delegates to Lunaris::ingest,
which reads the graph-pipeline toggle once at the top and picks a branch
(crates/lunaris/src/ingest.rs:64-152). Either branch runs:
- Chunk —
lunaris_ingest::chunk_markdown(&content, 500, 100): a markdown-aware chunker, ~500-token target, 100-token overlap, heading path preserved on every chunk (crates/lunaris-ingest/src/chunker.rs). - Embed —
embedder.embed_batch(&[..])in batches ofINGEST_EMBED_BATCH_SIZE = 32. On a batch error, it degrades to per-chunk single-input embeds; a per-chunk failure surfaces immediately asLunarisError::Storage(Backend(_))(crates/lunaris-ingest/src/pipeline.rs:120-178). The default embedder is granite-embedding-311m-multilingual-r2 (768-d, Q4_K_M GGUF), running in-process via llama.cpp — no external service required; the GGUF is staged at~/.lunaris/models/(the MCP server stages it lazily on first recall; other deployments download it out-of-band). See Configuration → Embedder. - Assemble one
Vec<WriteOp>— oneKvPutfor the episode, plus per chunk aKvPut(chunk JSON) and aVectorUpsert(768-d embedding +{episode_id, heading_path, offset, text, source}metadata). Thetextfield is what lets Moon’s BM25 index score chunk content. - One
atomic_write—storage.atomic_write(&scope, &ops).await. All chunks for an episode land or none do; that is the Phase 1 atomicity contract.
After commit (and only after — the data is already durable), ingest
fire-and-forgets one __lunaris_consolidate__ envelope carrying
{episode_id, lsn, source}. A publish failure logs and continues — it never
fails the ingest. See Consolidation & Verification.
The INGEST-04 invariant
Exactly one atomic_write per ingest call. ingest does not commit per
chunk — it builds the full Vec<WriteOp> and hands it to the backend once.
The invariant is enforced separately per branch:
- Graph OFF — the single call site is
crates/lunaris-ingest/src/pipeline.rs:116. CI runs a grep gate on every push:grep -v '^\s*//' crates/lunaris-ingest/src/pipeline.rs | grep -c 'storage\.atomic_write'must equal1(comments mentioningatomic_writeare stripped first). - Graph ON — the single call site is in
ingest_episode_graph_on(crates/lunaris/src/ingest.rs, theONE atomic_write call (INGEST-04 …)comment). The extended fan-out (entities, relations, facts) extends the sameWriteOpvector — it does not introduce a secondatomic_write.
Any new ingest fan-out must extend the existing vector. A second
atomic_write is a bug.
With the graph pipeline on
When lunaris.graph_pipeline().enable() has been called (or
LUNARIS_GRAPH_ENABLED=1 was set at open time), Lunaris::ingest routes to
the graph-on branch:
- Each chunk is run through the
Extractor→validator::validate→ValidatedExtraction. (ANoopExtractor— installed automatically when noLUNARIS_EXTRACT_PROVIDERis configured — short-circuits this:applies() == falseskips the extract call entirely, so noGraphNodes are written.) - The single
WriteOpvector grows to include, per extracted entity, aGraphNode+ aVectorUpsertinto theentitiesindex; per relation, aGraphEdge; per fact, aKvPut+ aVectorUpsertinto thefactsindex. - After commit, every
NeedsReviewitem also publishes one__lunaris_verify__message (consumed only when the verifier pipeline is enabled).
A toggle change during an in-flight ingest takes effect on the next call — never mid-call.
Gotchas
- Bare
Lunaris::ingest(Episode)still exists but the scoped path is the one to use. The HTTP server already routes everyPOST /v1/ingestthroughScopedLunaris::ingestkeyed on the JWTtenantclaim. - GGUF staging. The default embedder (llama.cpp granite-r2) expects its
GGUF at
~/.lunaris/models/granite-embedding-311m-multilingual-r2.Q4_K_M.gguf— there is no auto-download in the umbrella crate; a missing GGUF logs aWARNand falls back toNoopEmbedder. The MCP server stages GGUFs lazily on first recall. PointLUNARIS_EMBEDDER_GGUFat an existing local copy, or build with--features embed-remoteand setLUNARIS_EMBEDDER_OLLAMA_URLto use the Ollama HTTP escape hatch (resolves after the llama.cpp step). - Embedding dimension. The Moon adapter creates its vector index at the
configured embedder’s dimension (default 768-d;
Lunaris::openpassesembedder.dim(), or useMoonStorage::connect_with_dimdirectly), so a 1536-d embedder works out of the box. Footgun: Moon’sFT.CREATEwon’t resize an existing index — drop it first if you switch embedder width. See Choosing a Backend. - Higher-level wrappers exist. If you don’t want to hand-build episodes,
the Cookbook recipes (
DocumentKnowledgeBase,ChatAgentMemory, …) forward toingestwith opinionatedsourceprefixes.
See also
- The Retrieval DSL — reading the data back.
- Forgetting — taking it out again.
- Configuration Reference — every feature
flag and
LUNARIS_*env var.
The Retrieval DSL
DSL = Domain-Specific Language — here, a small composable query API (operators you chain into a plan), not a separate language you write or parse. Prefer
ScopedLunaris::recall(query)for a one-shot default query; reach for the DSL builder below when you need to compose operators.
Reach for this chapter for every read beyond a single-key fetch. The DSL
composes a small set of operators — Vector, Keyword, Graph, Tree
(RAPTOR hierarchical), plus fusion / rerank / modifier wrappers — into a
plan, then executes it in one pass and returns Vec<Hit>. It is declarative (“feel like Keras, not like a
query language”, blueprint §8) and tower::Service-shaped at the edge so
rate-limit / retry / timeout / tracing middleware drops in for free.
All names below are re-exported at the lunaris:: top level — never reach
into lunaris_retrieve::.
Two ways to query
There are two query forms over the same engine. Both run on the same storage / embedder / scope; the only difference is how much of the plan you spell out.
Form A — scoped.recall(query) (one-shot). A single .await that returns
Vec<Hit> directly. It runs the default plan: a Vector search over the
chunks index — no keyword fusion, no graph, no rerank. Reach for it when a
plain semantic lookup is all you need.
use lunaris::{Hit, Vector};
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{Lunaris, Query, Scope};
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
// One call, Vec<Hit> back. Default plan = Vector over `chunks`.
let hits = scoped.recall(Query::text("who loves chocolate")).await?;
Ok(())
}
Form B — scoped.dsl()…execute(query) (composable). Returns a
RetrievalBuilder you customise (.with_root(...), .filter(...), .as_of(...),
.rerank(...)) before the single .execute(query).await. Reach for it the
moment you want hybrid fusion, the graph or tree operators, time-travel, or
reranking.
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
use lunaris::{Keyword, Query, Vector};
// `scoped` is the handle from Form A above.
let hits = scoped
.dsl()
.with_root(Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(5))
.execute(Query::text("who loves chocolate"))
.await?;
Ok(())
}
They are the same machinery: recall(query) is exactly dsl() with the
default root left in place, then .execute(query) — verify it in
crates/lunaris/src/handle.rs (recall at the ScopedLunaris impl delegates to
engine.recall().with_scope(scope).execute(query); dsl() returns the same
pre-seeded builder). So there is nothing recall() can do that dsl() cannot —
recall() is the convenience name for the common default.
| You want… | Use | Returns |
|---|---|---|
| A plain semantic lookup, least ceremony | scoped.recall(query) | Vec<Hit> |
| Hybrid (vector + BM25) fusion | scoped.dsl().with_root(Vector….and(Keyword…).fuse_rrf(k)) | builder → Vec<Hit> |
Graph expansion / RAPTOR Tree descent | scoped.dsl().with_root(Graph… / Tree…) | builder → Vec<Hit> |
Time-travel (as_of), filters, rerank | scoped.dsl().as_of(…)/.filter(…)/.rerank(…) | builder → Vec<Hit> |
See it run. Querying Three Ways runs
all three forms — direct recall, DSL fusion, and the Tree operator — over one
ingested document.
Mind the three
recallnames.ScopedLunaris::recall(query)(above) returnsVec<Hit>and is the canonical one-shot.ScopedLunaris::dsl()returns the builder. The bareLunaris::recall()(no scope) is a legacy path that returns aRetrievalBuilder(notVec<Hit>) seeded withScope::dev()and warns on every call — see the note under Seeding a builder.
Seeding a builder
ScopedLunaris::dsl() returns a RetrievalBuilder pre-seeded with the
handle’s storage / embedder / keyword Arcs and the bound scope, so only
hits from that scope’s partition come back. Its default root operator is
Vector::new("chunks", 30); you replace it with .with_root(...).
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{Keyword, Lunaris, Query, Scope, Vector};
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
let hits = scoped
.dsl()
.with_root(Vector::new("chunks", 30).top(5))
.execute(Query::text("brown fox"))
.await?;
Ok(())
}
Lunaris::recall()exists too, but it seedsScope::dev()and emits atracing::warn!on every call (crates/lunaris/src/recall.rs:78-80). It is the v0.1 backwards-compatible path; new code usesengine.scoped(scope).dsl()(orengine.scoped(scope).recall(query)for the one-shot form).
RetrievalBuilder is synchronous — with_root, filter / filter_str,
as_of, rerank, degraded_fallback all run before any IO. .execute(query)
is the only .await; it wires the operator tree into a QueryContext, runs
it, and hydrates the results. This keeps the builder Send without
future-boxing.
The operators
Vector::new(index, k)
Top-k chunks by vector (cosine) similarity. index is one of the four
whitelisted names: chunks | entities | facts | communities. The chunker
fills chunks; the extractor fills entities and facts. RAPTOR’s
ingest-time tree now fills communities with embedded summary nodes — query
them directly here, or via the Tree operator (below) for hierarchical
descent. (The consolidator’s Leiden run also
contributes community nodes.) (crates/lunaris-retrieve/src/operators/vector.rs)
Keyword::bm25(index, k)
Top-k chunks by BM25 keyword score (min-max normalized).
(crates/lunaris-retrieve/src/operators/keyword.rs)
Graph::anchored(entity_ids, hops)
Breadth-first traversal out from known entities — “everything we know about
Alice”. entity_ids are pre-resolved EntityIds:
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{EntityId, Graph};
let alice = EntityId::from_name_and_type("Alice", "Person"); // deterministic content hash
let g = Graph::anchored(vec![(alice, 1.0)], 2);
Ok(())
}
hops is clamped to [1, MAX_GRAPH_HOPS = 5]; DEFAULT_GRAPH_HOPS = 2.
Empty entity_ids short-circuits to an empty result without touching storage.
Per-hit score is 1.0 / (1 + bfs_rank). .with_k(n) caps the candidate set
(default DEFAULT_GRAPH_K = 30); .with_graph(name) overrides the graph key
(default lunaris_graph). Requires the graph pipeline and an extractor — see
The Graph Pipeline. (crates/lunaris-retrieve/src/operators/graph.rs)
Tree::new(index, k) — RAPTOR hierarchical retrieval
Climbs the RAPTOR community tree instead of searching flat chunks. It
vector-searches the communities index for the k nearest summary nodes,
then descends Community.members breadth-first to collect the leaf chunks
beneath them. Because a summary node semantically aggregates its chunks, this
surfaces whole-document and multi-hop answers whose constituent chunks fall
outside a flat search’s top-k budget.
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
use lunaris::{Query, Tree};
// operator form — pass to .with_root() or compose with .and()/.fuse_rrf()
scoped.dsl()
.with_root(Tree::new("communities", 5))
.execute(Query::text("What are the main themes across both reports?"))
.await?;
// builder shortcut — .tree(index, k, depth) replaces the root in one call
scoped.dsl()
.tree("communities", 5, 1)
.execute(Query::text("What are the main themes across both reports?"))
.await?;
Ok(())
}
k— number of top community summary nodes to seed from (clamped toMAX_K).depth— BFS descent levels.1(default,DEFAULT_TREE_DEPTH) collects the seed communities’ direct members;2also expands sub-communities one level deeper, and so on. Clamped to[1, MAX_TREE_DEPTH]whereMAX_TREE_DEPTH = 4. Only the first level issues a vector search — deeper levels read community KV rows only, so cost scales with depth × fan-out, not index size.- Composes like any operator:
.and()/.or()/.then()/.fuse_rrf()/.top(). Fuse it with a flatVectorbranch to get pinpoint chunks and tree-aggregated coverage in one plan:
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
use lunaris::{Query, Tree, Vector};
scoped.dsl().with_root(
Vector::new("chunks", 30)
.and(Tree::new("communities", 5))
.fuse_rrf(60)
.top(10),
).execute(Query::text("Summarize the incident and its root cause")).await?;
Ok(())
}
Prerequisite: the
communitiesindex must be populated. RAPTOR fills it at ingest (communitysummary_embedding, since the 2026-06-04 change). If.tree()comes back empty, the scope hasn’t ingested a document large enough to build a tree yet — see Ingesting Observations.
What’s proven today:
.tree()is verified wired and traversed — on a whole-document query it returns the full leaf set where flat top-kreturns a single chunk. Relevance-vs-flat ranking with a production embedder is not yet benchmarked; treat.tree()as a coverage / recall lever, fused withVectorfor precision. (crates/lunaris-retrieve/src/operators/tree.rs)
Combinators — .and() / .or() / .then()
Each operator carries .and(other), .or(other), .then(other)
(crates/lunaris-retrieve/src/operators/combinators.rs):
.and(other)— run both retrievers; both result sets flow into the next operator (the typical input to.fuse_rrf)..or(other)— fall back tootheronly if the left side yields nothing..then(other)— feed the left side’s hits as the input toother(re-ranking / refinement chains).
Fusion — .fuse_rrf(k)
Reciprocal-rank fusion over the upstream branches. Each branch contributes
1 / (k + rank_i); k = 60 is the conventional constant.
use lunaris::{Keyword, Vector};
async fn demo() -> Result<(), lunaris::LunarisError> {
let _operator_tree =
Vector::new("chunks", 30)
.and(Keyword::bm25("chunks", 30))
.fuse_rrf(60)
.top(5)
;
Ok(())
}
Moon-native vs client-side. When the handle was opened against a moon://
URL and the shape is Vector + Keyword(BM25) on the same index,
fuse_rrf dispatches to Moon’s native text().hybrid_search — one round trip
instead of two (crates/lunaris-retrieve/src/operators/fuse.rs, governed by
StorageCapabilities::native_rrf). Anything else folds client-side
(crates/lunaris-retrieve/src/operators/fuse.rs::client_side_rrf). Any Graph
branch in the tree forces client-side RRF — the Moon one-trip path only
fires for the Vector+Keyword(BM25) case. The API is identical either way.
Modifiers — .top(k), filter_str, .as_of(ts)
.top(k)— cap the final result set. Available on every operator and on the builder.RetrievalBuilder::filter_str(s)— parse a v0 string predicate into aFilter. Returns aResult<Self, FilterParseError>at builder time so invalid syntax surfaces before any IO. The v0 grammar parses two predicate forms only —field = 'value'(→Filter::Eq) andfield LIKE 'prefix%'(→Filter::StartsWith; the%must be the last character, no embedded%). Anything else is a parse error (crates/lunaris-retrieve/src/operators/modifiers.rs:80). Never.unwrap()on user input — propagate with?..filter(Filter)takes a pre-built filter (use it forFilter::And/Filter::Orcomposition).RetrievalBuilder::as_of(ts)— pin the bi-temporal snapshot to anHlc. Builder-level filter / as_of override the per-Queryfields only if the query didn’t already set them.
rerank(reranker)
Wrap the current root with a cross-encoder rerank pass over the top
DEFAULT_RERANK_TOP_IN = 30 candidates. Pass lunaris.reranker():
use lunaris::{Keyword, Query, Vector};
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
scoped.dsl()
.with_root(
Vector::new("chunks", 30)
.and(Keyword::bm25("chunks", 30))
.fuse_rrf(60)
.top(30),
)
.rerank(lunaris.reranker())
.top(5)
.execute(Query::text("brown fox"))
.await?;
Ok(())
}
The default reranker is BGE-Reranker-v2-m3. Budget seconds, not
milliseconds: it measures p50 1301.3 ms at the default top_in=60
(575.6 ms at top_in=30), plus a one-time ~1.0–1.4 s lazy GGUF load on the
first reranked recall of the process (capacity.md §4). Rerank is a
quality stage; enabling it voids the 25 ms p50 recall contract and the 100 ms
latency SLO. When its
weights are missing, Lunaris::open installs NoopReranker and
lunaris.reranker() still returns a working Arc<dyn Reranker> that passes
scores through unchanged — Hit::rerank_applied is false in that case.
Builders constructed via Lunaris::with_parts get NoopReranker by default;
opt into rerank yourself in tests/benches.
degraded_fallback(fallback)
Wrap the current root so that on any error from the primary it switches to
fallback and tags every returned hit with Hit::degraded = true
(crates/lunaris-retrieve/src/operators/degraded.rs). Pair it with
Lunaris::recall_with_degraded_check(), which reads the verifier queue depth
once and pre-flags every hit when the depth crosses
LUNARIS_VERIFY_QUEUE_WARN_THRESHOLD (default 1000) — see
Consolidation & Verification.
The build-up in four steps
use lunaris::{Keyword, Query, Vector};
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
// 1 — pure vector
scoped.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text("brown fox")).await?;
// 2 — add BM25
scoped.dsl().with_root(
Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).top(5),
).execute(Query::text("brown fox")).await?;
// 3 — fuse with reciprocal rank
scoped.dsl().with_root(
Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(5),
).execute(Query::text("brown fox")).await?;
// 4 — rerank
scoped.dsl().with_root(
Vector::new("chunks", 30).and(Keyword::bm25("chunks", 30)).fuse_rrf(60).top(30),
).rerank(lunaris.reranker()).top(5).execute(Query::text("brown fox")).await?;
Ok(())
}
Graph-aware: swap a branch for Graph::anchored:
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
use lunaris::{EntityId, Graph, Query, Vector};
let alice = EntityId::from_name_and_type("Alice", "Person");
scoped.dsl().with_root(
Vector::new("chunks", 30)
.and(Graph::anchored(vec![(alice, 1.0)], 2))
.fuse_rrf(60)
.top(30),
).rerank(lunaris.reranker()).top(5).execute(Query::text("Tell me about Alice")).await?;
Ok(())
}
The Hit you get back
use lunaris::{Hit, SourceOp};
use lunaris_core::Hlc;
async fn demo() -> Result<(), lunaris::LunarisError> {
pub struct Hit {
pub id: Vec<u8>, // backend id (a ULID's 16 bytes for chunks)
pub score: f32,
pub text: String, // chunk body — from the chunk's KV row
pub source: String, // episode source, "" if the episode row is gone
pub heading_path: Vec<String>,
pub valid_from: Hlc,
pub valid_to: Option<Hlc>, // None = still valid
pub degraded: bool, // came from a degraded_fallback path
pub rerank_applied: bool, // the real cross-encoder ran (not the noop)
pub source_op: SourceOp, // which operator produced it (RRF groups by this)
}
Ok(())
}
Query::text(t) builds a default query (k = 30, no filter, no as_of); the
struct-literal form lets you set every field explicitly.
tower::Service
For middleware-stacked use, RetrievalService implements
tower::Service<Query, Response = Vec<Hit>, Error = LunarisError>
(crates/lunaris-retrieve/src/service.rs), so
tower::ServiceBuilder::new().rate_limit(..).timeout(..).retry(..).service(retriever)
works. Note RetrievalService itself has no scope context (it uses
Scope::dev()); for scope-isolated reads go through ScopedLunaris::dsl().
Gotchas
- Empty hits are usually a filter problem. Over-tight
filter_str, or an index that nothing wrote to. (communitiesis now populated at ingest by RAPTOR — if it’s empty, this scope hasn’t ingested a document big enough to build a tree.) Drop the filter and re-run. filter_strparse errors are builder-time, not execute-time. Catch them with?where you build the chain.execute_rawreturns un-hydratedRawHits — for bench harnesses that measure search-path latency over non-chunksindices. Production callers useexecuteso “every hit has chunk text” holds.
See also
- Ingesting Observations — how the
chunksindex gets filled. - The Graph Pipeline — populating
entities/factsforGraph::anchored. - Cookbook → Document Knowledge Base — RRF-fused RAG without hand-composing the DSL.
- Configuration Reference — reranker / embedder backends and the degraded-check threshold.
How Recall Works — Memory Structure & the Millisecond Budget
This page answers two questions evaluators keep asking:
- How is a memory actually structured once Lunaris has ingested it?
- Where do the milliseconds go on a recall — i.e. how does the sub-25 ms contract hold, mechanically?
It mirrors the canonical
docs/ARCHITECTURE.md
sections of the same names. Every claim is anchored to a code path or a
published benchmark.
How a memory is structured
One ingested episode fans out into a small constellation of rows, all
minted under the canonical keyspace lunaris:{scope}:{kind}:{ulid}
(lunaris_core::keyspace) and all committed by the same
atomic_write (invariant INGEST-04 — see
Core Concepts):
Episode lunaris:{scope}:episode:{ulid} source, raw content, metadata, bt
└─ Chunk(s) lunaris:{scope}:chunk:{ulid} text + heading_path + episode_id
├─ vector 768-d embedding — same HSET document
├─ BM25 payload tokenized text — same FT index as the vector
└─ bt stamp [sys_from, sys_to) × [valid_from, valid_to)
└─ (opt-in graph) Entity / Relation / Fact rows + GraphNode/GraphEdge
in the per-scope named graph
└─ Audit row + one __lunaris_consolidate__ queue message
Three structural decisions carry the whole recall story:
- The chunk is the retrieval unit; the episode is the provenance
unit. Vector and BM25 hits return chunk ULIDs. Hydration walks
chunk → episode_id → episode, so every hit arrives with its source attached. - One document, two indices’ worth of duty. On Moon, a chunk’s
embedding, BM25-tokenized text,
TAGfilter fields, and bi-temporal stamp live in a singleHSETdocument indexed by one per-scopeFTindex (lunaris_{scope}_{kind}_idx). There is no “sync the vector DB with the text index” job because there is nothing to sync. - Every row is bi-temporal. The
btfield records system time (when Lunaris learned the fact) and valid time (when it was true in the world) as two half-open intervals.forgetand supersession close intervals instead of destroying rows — which is why.as_of(ts)can answer “what did the agent believe last Tuesday?”
Where the milliseconds go — anatomy of a recall
The sub-25 ms contract is not one trick — it is the absence of four
round trips. Here is how a typical
vector.and(keyword).fuse_rrf(60) recall spends its budget:
| Stage | What happens | Why it’s fast |
|---|---|---|
| 1. Query embed | granite-embedding-311m runs in-process on llama.cpp (CPU, Q4_K_M GGUF) | No HTTP hop to an embedding server — the single biggest win (see the 86 ms lesson) |
| 2. Hybrid search | ONE FT.SEARCH HYBRID round trip; Moon fuses vector KNN + BM25 with native RRF server-side | Fusion happens inside the engine that owns both indices — not N queries glued together in app code |
| 2a. Filters & time | TAG pre-filters (@source:{...}) and AS_OF <ms> resolve inside the same search command | Filtering before scoring; the temporal cut never becomes an app-side post-filter |
| 3. Hydrate | Every hit’s chunk row fetched concurrently (ordered fan-out, one HMGET per row); parent episodes fan out once per unique episode_id | Concurrent requests pipeline over one multiplexed connection — k hydrations cost ~1 batch of round trips, not 2k serial ones. Since-deleted chunks are skipped, not errored |
| 4. Rerank (opt-in) | bge-reranker-v2-m3 cross-encoder, in-process | A quality stage, not a latency-class stage — measured p50 1301.3 ms at the default top_in=60 (575.6 ms at top_in=30), ~100× the blueprint’s 12 ms allocation. Off by default; enabling it voids the 25 ms p50 contract (capacity.md §4) |
CJK and other case-less scripts — vector-only auto-planning
Two v0 behaviours to know if your corpus or queries are in Chinese, Japanese, or Korean (or any script without case):
- The auto-planner never picks the keyword leg for CJK queries. The
plan_queryhelper (exported bylunaris-retrieve; RETRIEVE-13) choosesHybrid(vector + BM25) only when it sees an entity-like ASCII-uppercase token mid-query — an English-only heuristic (crates/lunaris-retrieve/src/planner.rs). CJK text has no ASCII uppercase, so a CJK query always plansVectorOnlyand BM25 is never consulted on that path. This is pinned by thecjk_query_always_plans_vector_onlyunit test inplanner.rs, so the behaviour change will be visible when the graph-anchored planner replaces the heuristic. The multilingual granite-r2 embedder carries CJK recall in the meantime — and note this only affects auto-planned recall: an explicit DSL query (vector.and(keyword).fuse_rrf(60)) runs exactly the legs you wrote, in any script. - Sentence segmentation splits on ASCII terminals only. The ingest
chunker’s sentence mode splits paragraphs on
.!?(crates/lunaris-ingest/src/chunker/segment.rs); the full-width。!?terminals are not split points, so CJK prose in sentence mode degrades to paragraph-sized units. Chunks stay retrievable (the embedder is multilingual), just coarser.
The 86 ms lesson (historical — v0.1.1, 2026-04-23)
The same 10k-document SQuAD harness (scripts/bench-squad-kb.py)
measured:
- p50 86 ms when query embedding went through an out-of-process Ollama HTTP server, and
- ~11 ms on the strict-replay path that removes that hop
(
scripts/ollama-replay-server.py+scripts/precompute-embeds.py).
Treat the absolute numbers here as retired. That run used Ollama + EmbeddingGemma 300M at k=3 on a 10k corpus — a stack removed in v0.4 (Ollama) and again in v0.6 (candle). What survives is the ratio, not the milliseconds. The current latency envelope is the GA-2b one in
capacity.md.
The engine’s own search + hydrate path was ~10 ms all along; the
network hop to the embedder was ~75 ms of pure overhead. That
measurement is why v0.4 moved embedding in-process as the default
— the shipped configuration is the configuration the contract was
proven on. (v0.4 ran the in-process embedder on candle; the v0.6
llama.cpp-only cutover (docs/decisions/2026-07-10-llamacpp-only-cutover.md)
replaced candle with llama.cpp as the in-process runtime — the
“no network hop” property this lesson describes is unchanged.)
The same decomposition repeated on Moon v0.3.0 with the 4-bit GGUF granite embedder (3k-doc SQuAD train corpus): end-to-end p50 61.5 ms, retrieval-only p50 3.1 ms / p99 3.6 ms — the gap is now in-process quantized embedding compute, not a network hop, and the engine path still sits far inside the 25 ms contract (v0.3.0 rerun).
The 97 ms tail lesson
Hydration used to await one storage read per hit, serially — at k=30
that chain of round trips amplified every scheduler hiccup into the
tail. The 2026-06-10 fan-out change (one HMGET per row, all rows
concurrently over the multiplexed connection) flattened a measured
p50 12 ms / p99 97.3 ms at k=30 into p50 6.0 ms / p99 6.2 ms —
the tail now sits inside the p50 contract. Methodology and the full
A/B table: docs/benchmarks/v0.6-recall-fanout-ab.md.
Why a fan-out stack can’t follow
A typical agent-memory deployment runs a vector DB + a text-search engine + a graph DB + a message broker. That stack pays:
- one network round trip per lane, plus app-side fusion;
- no shared
TAG/ temporal pushdown — you over-fetch, then post-filter in application code; - no common snapshot — the lanes can disagree about what exists.
Lunaris-on-Moon spends its entire budget inside one process and one index, with one transaction boundary across all four lanes.
Where to go next
- The query language itself: The Retrieval DSL
- The primitives and the keyspace: Core Concepts
- Backend setup and its honest limits: The Storage Backend
- The published numbers:
docs/benchmarks/v0.2.x/
Forgetting (GDPR / audit)
Reach for this chapter when a primitive must stop being visible to future
queries — GDPR right-to-be-forgotten, retention windows, session cleanup.
Three variants, one entry point (Lunaris::forget,
crates/lunaris/src/forget.rs:208), one __lunaris_audit__ event per
successful call.
v0.2.x headline gotcha — read this first
Lunaris::forgetis hard-coded toScope::dev()internally for itsatomic_write/read_as_of/scan_rangecalls (crates/lunaris/src/forget.rs:300-303). Under any non-_dev_scope it silently returnsrows_written = 0, rows_deleted = 0— the Moon SCAN prefix filters everything out. It emits atracing::warn!on every call so the line above yourforget(...)says so. The real per-scope routing —ScopedLunaris::forget(target)with a403/404cross-scope contract — is a v0.3 deliverable. See RFC 0001 §11.6 andCHANGELOG.md“Known issues”. Until then, forget works against the_dev_scope only.
The three targets
// Shape only — this mirrors the real declaration rather than calling it,
// so it is `ignore`d: compiling a shadow copy of an enum proves nothing.
pub enum ForgetTarget {
Id(Ulid), // OPS-01 — single-primitive purge across KV + vector + graph
Scope(ScopeSpec), // OPS-02 — prefix / metadata / episode-id predicate
Before(Hlc), // OPS-03 — AS_OF cutoff
}
pub enum ScopeSpec {
BySource(String), // prefix match on episode.source
ByMetadata(String, String), // exact match on metadata[key] == value
ByEpisode(Ulid), // exact match on episode.id
}
(ForgetTarget and ScopeSpec are #[non_exhaustive],
crates/lunaris/src/forget.rs:49-73.)
Soft vs hard delete
| Request | What it does | atomic_write calls |
|---|---|---|
target (bare) | Soft delete. Stamps the MVCC bt.sys_to on each match — prior reads via as_of still see the data. | 1 (KvPut of sys-stamped payloads); 0 if no match |
target.dry_run() | Preview. No write at all. Returns a receipt with preview = true, rows_written = 0. The audit event still publishes (ops gets a trail of what almost happened). | 0 |
target.hard() (no token) | Err(LunarisError::Validate(ValidateError::ConfirmationRequired(_))) — not a panic. | 0 |
target.hard().with_token(t) | Hard delete. Irreversible KvDelete fan-out. Requires a ForgetConfirmation minted from a prior dry_run receipt (the D-21 two-step safety rail). | 1 (KvDelete ops) |
D-19 single-call invariant: every successful forget issues at most one
atomic_write (zero for dry-run or no-match).
Soft delete writes the sys_to inside the payload bytes — backends derive
persisted bi-temporal from the payload, so a typed-only mutation would be
silently lost. build_soft_delete_op patches both the in-memory BiTemporal
and payload["bt"]["sys"][1] (crates/lunaris/src/forget.rs), the same
cross-plan contract the verifier’s apply_supersede uses. This is what makes
“forget” compatible with bi-temporal MVCC: the data is hidden from
default-time queries but a recall().as_of(t_before_forget) still returns it.
Code
use lunaris::Scope;
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
use lunaris::{ForgetTarget, ScopeSpec};
// Soft delete a session prefix.
let target = ForgetTarget::Scope(ScopeSpec::BySource("helios:fs/session-42/".into()));
let receipt = lunaris.forget(target.clone()).await?;
assert!(!receipt.preview);
// receipt.rows_written == number of MVCC rows stamped (0 under a non-_dev_ scope!)
// Dry-run preview.
let preview = lunaris.forget(target.clone().dry_run()).await?;
assert!(preview.preview);
// Hard delete — two steps.
let token = lunaris.confirm_hard_forget(preview).await?;
let hard = lunaris.forget(target.hard().with_token(token)).await?;
assert!(!hard.preview);
assert_eq!(hard.rows_written, 0); // hard delete writes zero MVCC rows
// hard.rows_deleted == one KvDelete per match
Ok(())
}
confirm_hard_forget only accepts a preview: true receipt — replaying a
non-preview receipt returns the same ConfirmationRequired error
(crates/lunaris/src/forget.rs:333).
The receipt
use lunaris::{ForgetTarget, Graph, IndexKind, Lsn, Vector};
async fn demo() -> Result<(), lunaris::LunarisError> {
pub struct ForgetReceipt {
pub target: ForgetTarget,
pub indices_affected: Vec<IndexKind>, // Kv | Vector | Graph
pub rows_written: u64, // soft-delete MVCC writes; 0 for hard / dry-run
pub rows_deleted: u64, // irreversible deletes; 0 for soft / dry-run
pub audit_lsn: Lsn, // __lunaris_audit__ publish offset
pub preview: bool, // true iff dry_run (no atomic_write happened)
}
Ok(())
}
ForgetConfirmation carries for_audit_lsn and cannot be constructed by the
caller — only returned from confirm_hard_forget.
GDPR / audit notes
- One audit event per successful call — soft, hard, and dry-run. The
audit publish lands even on dry-run so operators have a complete trail. The
receipt’s
audit_lsnis the__lunaris_audit__offset. - Use
dry_runfirst for any destructive run — it’s also the only way to mint the hard-delete confirmation token. - Hard delete is irreversible. Soft delete is the GDPR-friendly default: the data leaves default-time queries immediately, and MVCC retention keeps it auditable until your retention policy hard-deletes it.
HTTP
Over the wire (POST /v1/forget), the request DTO carries
#[serde(deny_unknown_fields)] — a scope / tenant field is a 422. Hard
delete is two requests: dry_run: true, read the audit_lsn out of the
receipt, then repeat with hard: true and a confirmation_token formed from
the prior audit LSN. See the MemoryProtocol spec.
See also
- Cookbook → Helios Scratchpad — uses a
BySourceprefix soft delete (pad.forget()). - Durability & Recovery — bi-temporal MVCC and
what
as_ofrecovers after a forget. - Multi-Agent & Scope — why
forgetis_dev_-only in v0.2.x.
The Graph Pipeline (opt-in)
Reach for this chapter when you want to follow relations out from a known
entity — “everything we know about Alice that also matches this query”. The
graph is default OFF (blueprint §5.2). Turning it on makes
ingest also extract entities, relations, and facts with a small
local LLM, and unlocks the Graph::anchored retrieval operator.
Turning it on
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::Lunaris;
let lunaris = Lunaris::open("moon://localhost:6380").await?;
// Runtime toggle — idempotent.
lunaris.graph_pipeline().enable();
// ... or LUNARIS_GRAPH_ENABLED=1 seeds the initial state at open time.
Ok(())
}
GraphPipelineHandle (crates/lunaris/src/graph_pipeline.rs) exposes
enable() / disable() / is_enabled(), plus set_extractor(arc) /
snapshot_extractor(). The enabled bit is read once per ingest call — a
toggle change during an in-flight ingest takes effect on the next call. There
is no per-scope enable_for_scope on the graph handle (only the
consolidator handle has that).
| Knob | Where |
|---|---|
lunaris.graph_pipeline().enable() / .disable() | programmatic, idempotent |
LUNARIS_GRAPH_ENABLED (bool: 1/true/on) | seeds initial state at Lunaris::open |
lunaris.with_extractor(Arc::new(...)) | swap the extractor backend; apply before .enable() |
See Configuration → Backend / pipeline selection.
What extraction produces
With the pipeline on, the graph-on ingest branch runs each chunk through the
Extractor → validator::validate →
ValidatedExtraction, then extends the single WriteOp vector (the
INGEST-04 invariant still holds — one atomic_write per ingest call) with:
- per entity — a
GraphNode(props includeid_hex) + aVectorUpsertinto theentitiesindex; - per relation — a
GraphEdge; - per fact — a
KvPut+ aVectorUpsertinto thefactsindex.
EntityId is deterministic: the 16-byte truncation of
blake3(normalized_name || "::" || entity_type) — stable across re-ingest,
across chunks, across episodes. No second-pass dedupe round trip.
(crates/lunaris-extract/src/lib.rs, types::EntityId::from_name_and_type.)
The validator routes invalid items to a NeedsReview queue with one of four
structured reasons — InvalidBitemporal, StructuralContradiction (same
(subject, predicate) with overlapping validity and conflicting object,
within one episode), GbnfFailure, TransientAfterRetry. After the
atomic_write, every NeedsReview item publishes one __lunaris_verify__
message; it is only consumed when the verifier pipeline
is enabled. Cross-episode contradictions are deferred to the verifier.
Graph-aware recall
Once entities/relations have been written, compose Graph::anchored into the
DSL:
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
use lunaris::{EntityId, Graph, Query, Scope, Vector};
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
let alice = EntityId::from_name_and_type("Alice", "Person");
let hits = scoped
.dsl()
.with_root(
Vector::new("chunks", 30)
.and(Graph::anchored(vec![(alice, 1.0)], 2))
.fuse_rrf(60)
.top(30),
)
.rerank(lunaris.reranker())
.top(5)
.execute(Query::text("Tell me about Alice"))
.await?;
Ok(())
}
hopsis clamped to[1, MAX_GRAPH_HOPS = 5];DEFAULT_GRAPH_HOPS = 2.- Empty
entity_idsshort-circuits to an empty result without touching storage. .with_k(n)caps the candidate set (DEFAULT_GRAPH_K = 30);.with_graph(name)overrides the graph key (defaultlunaris_graph).- Any
Graphbranch forces client-side RRF — the Moon-native one-trip fusion path only fires forVector + Keyword(BM25)on the same index.
Extractor tiers (RFC 0004, superseded by the v0.6 llama.cpp-only cutover)
RFC 0004 originally defined an in-process candle extractor tier (Medium:
Gemma-3-4B) alongside Ollama and cloud-API backends. The candle tier was
deleted in the v0.6 llama.cpp-only cutover
(docs/decisions/2026-07-10-llamacpp-only-cutover.md) — the extractor is now
remote-only, plus a NoopExtractor fallback:
| Backend | Selector | Notes |
|---|---|---|
OllamaExtractor | Cargo feature ollama, used via with_extractor or LUNARIS_EXTRACT_PROVIDER=openai-compat | POSTs /api/chat (or the OpenAI-compatible endpoint) with a JSON-schema format field. |
| Cloud-API extractor | LUNARIS_EXTRACT_PROVIDER = anthropic|openai|gemini|minimax (Cargo feature cloud-api) | Single retry on transient errors then a sentinel that the validator routes to TransientAfterRetry. |
NoopExtractor | always available | applies() == false; installed automatically when no extract provider is configured. |
(crates/lunaris-extract/src/lib.rs; RFC 0004 “extractor tiers” — historical,
its candle tier no longer exists.)
Gotchas
- The extractor is required for graph ingest. With no
LUNARIS_EXTRACT_PROVIDERset (and nowith_extractoroverride),Lunaris::opensubstitutesNoopExtractorand emits atracing::warn!— in that stategraph_pipeline().enable()is a no-op and zeroGraphNodes get written. Fix by settingLUNARIS_EXTRACT_PROVIDER(e.g.minimax, oropenai-compatpointed at a local Ollama/llama-server) or supplying a customwith_extractorimpl. communitiesstays empty until the consolidator’s Leiden community-detection run lands (v1) — recall over that index returns nothing meanwhile.- Apply
with_extractorbefore.enable()—.enable()snapshots the current extractor; a swap afterwards propagates viaset_extractor. - Cross-scope graph references are disallowed by construction —
Relation.src/Relation.dstmust resolve within the same scope (RFC 0001 §2.3).
See also
- The Retrieval DSL — the full
Graph::anchoredsurface. - Consolidation & Verification — what consumes the
__lunaris_verify__messages this pipeline emits. - Configuration Reference — extractor feature
flags and
LUNARIS_*env vars.
Consolidation & Verification (opt-in)
Reach for this chapter when your deployment has latency budget to spare and you want quality/provenance signals landing in the audit stream. These are the two opt-in slow paths — neither runs on the recall hot path. Both ship default OFF (blueprint §5.1); turn them on once your queue-lag SLOs hold.
| Pipeline | Handle | Backend env | Enable env | What it does |
|---|---|---|---|---|
| Consolidate | ConsolidatorPipelineHandle | LUNARIS_CONSOLIDATOR_BACKEND (actr/noop) | LUNARIS_CONSOLIDATE_ENABLED | ACT-R activation, promotion/archival, Leiden communities |
| Verify | VerifierPipelineHandle | LUNARIS_VERIFY_PROVIDER (anthropic|openai|gemini|minimax|openai-compat, else Noop) | LUNARIS_VERIFY_ENABLED | Slow-path arbitration of contradicting / invalid primitives |
The verifier is remote-only since the v0.6 llama.cpp-only cutover — see
docs/decisions/2026-07-10-llamacpp-only-cutover.md (the cutover ADR). No
local model tiers to build or stage; a set-but-broken provider degrades
loudly to NoopVerifier (warn), never a silent backend swap. See
Configuration Reference.
The consolidator (ACT-R)
lunaris-consolidate consumes the __lunaris_consolidate__ queue (one
message per ingest commit, consumer group lunaris-consolidate-v0), debounces
per episode_id, and runs a consolidation pass:
- ACT-R base-level activation — Anderson 1996, decay
d = 0.5, with the Petrov 2006 O(1) incremental approximation (ActRScorer,crates/lunaris-consolidate/src/act_r.rs). High-activation episodes/notes get promoted toFactprimitives; stale facts get archived. - Leiden community detection — a hand-rolled label-propagation pass
(
leiden_pass,crates/lunaris-consolidate/src/leiden.rs) over the graph, producingCommunityprimitives (thecommunitiesrecall index). Rustworkx is deliberately rejected — it carriesunsafeblocks.
The crate has no LLM backends — community summaries are produced by the
Phase-3 Extractor acting as a summarizer, wired at the umbrella handle.
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
lunaris.consolidator_pipeline().enable();
Ok(())
}
ConsolidatorPipelineHandle (crates/lunaris/src/consolidator_pipeline.rs)
exposes enable() / disable() / is_enabled(), set_consolidator(arc),
bind_storage(arc), join_worker(), state_change_count(), and —
uniquely among the three pipeline handles — enable_for_scope(prefix):
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
// Promote only events whose source starts with "helios:fs/" — Consolidator
// stays off for every other tenant. Prefix match is exact: no regex, no glob.
lunaris.consolidator_pipeline().enable_for_scope("helios:fs/");
Ok(())
}
The prefix is a source-prefix filter on the consolidate-event stream, not
a Scope partition key — Consolidator::consolidate_scoped drops events
whose event.source doesn’t start with it before forwarding to
consolidate() (crates/lunaris-consolidate/src/lib.rs). An empty prefix is
rejected. lunaris_recipes::WorkingMemory::consolidate() and the
HeliosScratchpad recipe use this path (see
Cookbook → Helios Scratchpad).
Per-event audit records match the AuditEvent enum verbatim — one
ConsolidatorPromotion { episode_id, fact_id, activation_score } per
promotion, one ConsolidatorArchive { fact_id, final_activation, moved_to }
per archive. There is no rolled-up “report” audit variant.
The verifier (slow-path arbitration)
lunaris-verify consumes the __lunaris_verify__ queue (emitted by the
graph-on ingest path, consumer group lunaris-verify-v0). For
each NeedsReviewItem it produces a VerifyDecision naming the winner /
loser / reason; a non-deferred decision flows through one atomic_write
(the MVCC supersede invariant — the loser’s bt.sys_to is stamped via
read_as_of + BiTemporal::invalidate_sys), followed by one fire-and-forget
audit publish.
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
lunaris.verify_pipeline().enable();
Ok(())
}
VerifierPipelineHandle (crates/lunaris/src/verify_pipeline.rs) exposes
enable() / disable() / is_enabled(), set_verifier(arc),
bind_storage(arc), bind_clock(arc), join_worker(). No enable_for_scope
— the verifier runs handle-wide. Returning VerifyDecision::deferred() is
“abstain”: the worker skips the supersede write for that item.
Remote-only verifier (v0.6 llama.cpp-only cutover)
RFC 0006 originally shipped a candle-based laptop floor (verify-small,
Gemma-3-270M) vs a “get it right” tier (verify-large, Gemma-3-27B). Both
were deleted in the v0.6 llama.cpp-only cutover — see
docs/decisions/2026-07-10-llamacpp-only-cutover.md (the cutover ADR). There
is no in-process verifier model anymore; LUNARIS_VERIFY_PROVIDER selects a
remote provider, or the effective verifier is NoopVerifier:
LUNARIS_VERIFY_PROVIDER value | Backend | Notes |
|---|---|---|
| (unset) | NoopVerifier | No crash, no work done — the safe default |
anthropic | openai | gemini | minimax | Cloud-API verifier via the matching provider SDK | Needs the provider’s API key env var |
openai-compat | Generic OpenAI-compatible HTTP verifier | LUNARIS_OPENAI_COMPAT_BASE_URL (keyless allowed); covers Ollama, llama-server, vLLM, LM Studio |
A provider that is set but fails to construct (bad URL, missing key) logs a
tracing::warn! and degrades to NoopVerifier — it never silently falls
back to a different backend. Rust callers can still supply any custom impl
via with_verifier.
Surfacing backlog to readers — recall_with_degraded_check
The verifier is asynchronous, so recall results can be stale relative to
pending arbitrations. Lunaris::recall_with_degraded_check() reads the
verifier queue depth once and, if it exceeds
LUNARIS_VERIFY_QUEUE_WARN_THRESHOLD (default 1000,
crates/lunaris/src/recall.rs:26-31), seeds the builder so every returned
Hit::degraded is true:
use lunaris::{Lunaris, Query, Vector};
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let hits = lunaris
.recall_with_degraded_check()
.await?
.with_root(lunaris::Vector::new("chunks", 30).top(5))
.execute(lunaris::Query::text("status of x"))
.await?;
for h in &hits {
if h.degraded {
tracing::warn!("verifier backlog — results may be stale");
}
}
Ok(())
}
It is best-effort: if the backend’s queue_depth returns NotSupported, the
call falls through with degraded = false and still returns hits.
Gotchas
- Both pipelines default OFF.
.enable()with no real backend installed runsNoopConsolidator/NoopVerifier— no crashes, no work done. Wire a backend (with_consolidator/with_verifier, or the right Cargo feature + env) first. - Apply component swaps before
.enable()—.enable()snapshots the current component; later swaps propagate viaset_*. - Queue topics are hard-coded constants (
__lunaris_consolidate__,__lunaris_verify__,crates/lunaris/src/ingest.rs:48-54). The shipped workers use the-v0consumer groups so a future schema bump can land on a fresh group. - Per-scope supervisors exist but the pipeline handles still drive the
deprecated single-topic workers in v0.2.x; the supervisor migration is a
v0.3 item (RFC 0001 §11.6). See Multi-Agent & Scope for
LUNARIS_SCOPE_CONCURRENCYetc.
See also
- The Graph Pipeline — produces the
__lunaris_verify__messages. - Ingesting Observations — produces the
__lunaris_consolidate__messages. - Configuration Reference — every feature
flag and
LUNARIS_*env var.
Multi-Agent & Scope
Reach for this chapter when one Lunaris deployment serves more than one
agent or tenant. Scope is the partition key: every ingest, recall, and
consolidation/verification operation is bound to a scope, and an agent cannot
read, write, or delete across scope boundaries. Multi-agent isolation is the
headline OSS positioning — Lunaris enforces it at the type level (RFC 0001),
not by convention.
The Scope newtype
Scope (lunaris_core::Scope, crates/lunaris-core/src/scope.rs) is a thin
newtype around SmolStr — short identifiers stay inline, clones are O(1).
Two scopes compare equal iff their strings match byte-for-byte. There is no
implicit “default” scope — you construct one explicitly.
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::Scope;
let s = Scope::new("acme.agent-1")?; // -> Ok(Scope)
assert_eq!(s.as_str(), "acme.agent-1");
Ok(())
}
Alphabet — [A-Za-z0-9_\-.]{1,128}, no colons
Scope::new validates against ^[A-Za-z0-9_\-.]{1,128}$. The hand-rolled
Deserialize impl re-runs the same validator on wire bytes — wire data is
never trusted (RFC 0001 §11). The colon was removed in v0.2.1 so the Moon
KV format lunaris:{scope}:{kind}:{ulid} cannot byte-alias across scopes
(RFC 0001 §11.3, RC-2). Use dots in identifiers: acme.agent-42,
not acme:agent-42.
Upgrading from v0.2.0? Any scope/tenant string containing
:now fails at the HTTP boundary withinvalid scope. Rewrite thetenantentries in your server-side bearer-tokens file (LUNARIS_TOKENS_FILE) and restart (acme:agent-42→acme.agent-42) — there are no JWTs in v0; the tenant lives in the tokens file, not inside the token (see Security & Hardening). Moon keys are immutable — re-ingest colon-keyed data under the rewritten scope. Recipe: RFC 0001 §11.4.
Scope::dev() is #[doc(hidden)] pub — a test/migration crutch only. Any
Scope::dev() call site in production code is a carry-over, not a pattern.
(Lunaris::forget still uses it internally in v0.2.x — see Forgetting.)
Binding an engine to a scope — ScopedLunaris<'a>
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::{EpisodeBuilder, Lunaris, Query, Scope, Vector};
let lunaris = Lunaris::open("moon://127.0.0.1:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?); // ScopedLunaris<'_>
let lsn = scoped.ingest(EpisodeBuilder::new("notes.md", "Alice met Bob.")).await?;
let hits = scoped.dsl()
.with_root(Vector::new("chunks", 30).top(5))
.execute(Query::text("Alice"))
.await?;
Ok(())
}
ScopedLunaris::ingest takes an EpisodeBuilder (scope-less payload) and
stamps its own scope — callers cannot override the bound scope mid-call. That
is the compile-time guard: “ingest into agent A, retrieve from agent B” can’t
type-check. ScopedLunaris::recall(query) is the one-shot form; .dsl()
returns a RetrievalBuilder pre-seeded with the scope for chained queries.
.scope() returns the bound &Scope.
Under the hood &Scope is threaded through every partitioned StoragePort
method (atomic_write, vector_search, graph_traverse, scan_range,
read_as_of, publish, subscribe, queue_depth) and KeywordPort::keyword_search
— Moon partitions via a per-scope keyspace prefix lunaris:{scope}: and
per-scope FT/GRAPH/MQ resources.
Per-scope worker supervision
The consolidator and verifier slow paths run a JoinSet<()> of one task per
active scope (ConsolidateSupervisor / VerifySupervisor,
crates/lunaris-consolidate/src/supervisor.rs,
crates/lunaris-verify/src/supervisor.rs). A new scope is registered from a
heartbeat the HTTP server sends on every authenticated request. Failure
isolation: a panic in scope A’s worker doesn’t stall scope B — the supervisor
restarts only the failed scope’s task.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_SCOPE_CONCURRENCY | 8 | Max concurrent event-batch tasks per scope (semaphore — a hot scope can’t saturate the embedder GPU) |
LUNARIS_SCOPE_IDLE_TIMEOUT_MS | 1800000 (30 min) | Idle-scope worker eviction — sheds dormant tasks so file descriptors don’t leak at high scope counts |
LUNARIS_WORKER_DRAIN_MS | 5000 (5 s) | Graceful drain window when a scope worker shuts down |
Operational ceiling: N scopes ⇒ N Moon FT indices and N MQ topics. Moon’s
soft limit is ~512 FT indices per node before recall p99 degrades — surfaced
as StorageCapabilities::max_scopes_recommended. Above that, multi-tenant
pooling is a future RFC.
See Configuration → Supervision / worker pool.
The HTTP multi-agent contract
Over lunaris-server, the scope comes from the JWT tenant claim in the
bearer-token map — it is the only source of truth for the partition scope.
Route handlers ignore any scope / tenant field on the request body; all
public request DTOs carry #[serde(deny_unknown_fields)].
{
"my-bearer-token": { "tenant": "agent.helios", "scopes": ["ingest", "recall", "forget"] }
}
tenant must match ^[A-Za-z0-9_\-.]{1,128}$ — anything else is 401 on
every request that uses the token. A token lacking the route’s required scope
(the scopes array) is 403. The five executable UAT scenarios below map 1:1
to crates/lunaris-server/tests/multi_agent_uat.rs; an external consumer’s CI
gate is met when all five pass.
UAT-1 — cross-scope ingest + recall isolation
Two tokens, tenants agent.alpha and agent.beta.
# ingest under alpha
curl -X POST http://localhost:8080/v1/ingest -H "Authorization: Bearer tok-alpha" \
-H "Content-Type: application/json" \
-d '{"source":"agent-alpha:notes","content":"Alice met Bob today"}'
# -> 200 {"lsn":{"wall_ms":...,"counter":1},"queue_lag_warn":false}
# ingest under beta
curl -X POST http://localhost:8080/v1/ingest -H "Authorization: Bearer tok-beta" \
-H "Content-Type: application/json" \
-d '{"source":"agent-beta:reports","content":"Quarterly revenue grew 12%"}'
# recall "Alice" as alpha -> 200, non-empty, first hit text contains "Alice"
curl -X POST http://localhost:8080/v1/recall -H "Authorization: Bearer tok-alpha" \
-H "Content-Type: application/json" -d '{"query":"Alice","k":5}'
# recall "Alice" as beta -> 200, EMPTY array [] — no cross-scope leak
curl -X POST http://localhost:8080/v1/recall -H "Authorization: Bearer tok-beta" \
-H "Content-Type: application/json" -d '{"query":"Alice","k":5}'
# recall "revenue" as beta -> 200, non-empty (its own data)
curl -X POST http://localhost:8080/v1/recall -H "Authorization: Bearer tok-beta" \
-H "Content-Type: application/json" -d '{"query":"revenue","k":5}'
UAT-2 — malformed scope → 401
A token whose tenant is empty, longer than 128 chars, or contains a
character outside [A-Za-z0-9_\-.] (%, space, \, :) is rejected
before any handler runs:
{"error":"unauthorized","message":"token tenant is not a valid scope identifier"}
UAT-3 — request body cannot override scope
IngestBody is #[serde(deny_unknown_fields)], so a "scope" or "tenant"
key in the body is 422 Unprocessable Entity before the handler runs — and
zero episodes are written for the caller’s scope (no partial writes).
curl -X POST http://localhost:8080/v1/ingest -H "Authorization: Bearer tok-alpha" \
-H "Content-Type: application/json" \
-d '{"source":"evil","content":"x","scope":"victim-scope"}'
# -> 422 {"error":"unprocessable_entity","message":"... unknown field `scope` ..."}
UAT-4 — forget honors scope
A cross-scope forget finds zero rows in the caller’s partition and has no
effect on anyone else’s data — rows_written == 0, rows_deleted == 0,
preview reflects the request. (Note the v0.2.x forget caveat:
Lunaris::forget is _dev_-scoped internally; full ScopedLunaris::forget
with a 403/404 cross-scope contract lands in v0.3 — consumers should be
ready for both shapes.)
UAT-5 — concurrent multi-agent traffic smoke
10 agents (tenants agent.0 … agent.9), each 3 ingest + 3 recall calls
concurrently (60 HTTP calls). All 200 OK; no agent sees another’s data in
its recall results.
Multi-agent patterns
There are three ways memory gets partitioned in Lunaris, nested from hardest to softest. Pick the level that matches the boundary you actually need — a tenant wall costs Moon resources; a thread label costs nothing.
| Level | How you set it | Strength | Cost |
|---|---|---|---|
| Scope | lunaris.scoped(Scope::new("acme.agent-a")?) — or, over HTTP, the JWT tenant claim → scope | Hard wall. Per-scope Moon keyspace lunaris:{scope}: + per-scope FT/GRAPH/MQ resources. A cross-scope read is a type error — you’d need a different ScopedLunaris. | One Moon FT index + one MQ topic per scope; soft ceiling ~512 scopes/node before recall p99 degrades (StorageCapabilities::max_scopes_recommended). |
| Source prefix | The source string on EpisodeBuilder::new(source, content) — or the prefix arg to MessageStream::new / the "chat:<user>/" prefix MultiTurnConversation derives | Soft, filter-based. Within one scope, Hit.source carries the episode’s source; you narrow client-side (hits.retain(|h| h.source.starts_with("conv:mon"))). Nothing stops a same-scope query from seeing all prefixes. | Free — it’s just a string. |
| Session / thread id | MultiTurnConversation::remember(turn, thread_id) — or MessageStream::ingest(msg, thread_id, participant) | A segment of the source prefix (chat:<user>/<thread_id>/). Recall spans all threads by default; narrow by source prefix as above. thread_id + participant_id also land in the episode metadata map. | Free. |
Scope "acme.agent-a" ← hard wall (RLS / per-scope keyspace)
└─ source prefix "conv:" ← soft, client-side filter on Hit.source
└─ thread id "conv:mon/" ← a segment of the source string
└─ episode "conv:mon/" + ULID + chunks
└─ source prefix "task:"
└─ thread id "task:deploy/"
The honest caveat: recipes use Scope::dev() today
The recipe wrappers — ChatAgentMemory, MultiTurnConversation,
MessageStream, EmailThreading, MeetingNotesMemory, … — currently
construct every Episode with Scope::dev() and partition only by source
prefix (verified in crates/lunaris-recipes/src/message_stream.rs:
scope: lunaris_core::Scope::dev()). Threading a real Scope through the
recipe surface is a v0.3 SDK item. So today:
- Hard per-agent isolation goes through the low-level
lunaris.scoped(scope)handle (or, inlunaris-server, the JWTtenantclaim → scope). That is the only path with an RLS-grade / type-level wall. - The recipes give you source-prefix + thread partitioning within one (dev) scope — fine for a single-tenant agent that wants per-conversation organization, not fine for multi-tenant isolation.
If you need both — a chat-agent ergonomic surface and a tenant wall — wrap
your own thin type over ScopedLunaris for now, mirroring
MultiTurnConversation’s shape but taking a Scope.
Worked snippets (from the runnable example)
These are distilled from examples/multi-agent-rs/ — a standalone crate that
runs end-to-end against a live Moon backend. It builds the handle by hand
(Lunaris::with_parts_keyword(storage, keyword, embedder, clock) with
MoonStorage as both the storage and the BM25 keyword port, and a
deterministic StubEmbedder::new(768)) so it needs no external services and
no model download — swap the stub for Lunaris::open("moon://…") (the
native granite-r2 default) and the same code recalls semantically.
Handle construction (what the example actually does — no model download):
async fn demo() -> Result<(), lunaris::LunarisError> {
use std::sync::Arc;
use lunaris::{Embedder, HlcClock, KeywordPort, Lunaris, MoonStorage, StoragePort, StubEmbedder};
let moon = Arc::new(MoonStorage::connect("moon://localhost:6380").await?);
let storage: Arc<dyn StoragePort> = moon.clone();
let keyword: Arc<dyn KeywordPort> = moon.clone(); // MoonStorage IS the BM25 port too
let embedder: Arc<dyn Embedder> = Arc::new(StubEmbedder::new(768)); // 768d == Moon's `chunks` FT index
let lunaris = Lunaris::with_parts_keyword(storage, keyword, embedder, HlcClock::new(0));
// Production: `let lunaris = Lunaris::open("moon://localhost:6380").await?;` instead — the
// native granite-r2 default downloads granite-embedding-311m-multilingual-r2 weights once and recalls semantically.
Ok(())
}
Agent-a vs agent-b — the scoped handle is the wall:
use lunaris::Lunaris;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
use lunaris::{EpisodeBuilder, Query, Scope, Vector};
let scoped_a = lunaris.scoped(Scope::new("acme.agent-a")?);
let scoped_b = lunaris.scoped(Scope::new("acme.agent-b")?);
scoped_a.ingest(EpisodeBuilder::new("agent-a:notes", "The acme widget ships Friday. Owner: Alice.")).await?;
scoped_b.ingest(EpisodeBuilder::new("agent-b:notes", "The beta gadget recall is paused. Owner: Bob.")).await?;
let a_hits = scoped_a.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text("owner")).await?;
let b_hits = scoped_b.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text("owner")).await?;
assert!(a_hits.iter().all(|h| h.source.starts_with("agent-a:"))); // no agent-b leak
assert!(b_hits.iter().all(|h| h.source.starts_with("agent-b:"))); // no agent-a leak
Ok(())
}
Sessions / tasks within one agent — the source field:
use lunaris::{EpisodeBuilder, Hit, Query, Vector};
use lunaris::Lunaris;
use lunaris::Scope;
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped_a = lunaris.scoped(Scope::new("acme.agent-1")?);
// The first arg to EpisodeBuilder::new IS the `source` — encode the session there.
// EpisodeBuilder auto-generates a fresh ULID per episode; override with `.id(...)`
// only for idempotent replay.
scoped_a.ingest(EpisodeBuilder::new("conv:mon", "Monday standup: acme widget rollout Friday.")).await?;
scoped_a.ingest(EpisodeBuilder::new("conv:tue", "Tuesday sync: QA signed off on the acme widget.")).await?;
scoped_a.ingest(EpisodeBuilder::new("task:deploy", "Deploy task: cut the acme widget release branch.")).await?;
// One recall spans all sessions:
let all = scoped_a.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text("acme widget")).await?;
// Narrow to one session — client-side over Hit.source (there is no server-side
// source-prefix push-down today; the v0 `filter_str` DSL targets episode
// METADATA, not the source string):
let mon_only: Vec<_> = all.iter().filter(|h| h.source.starts_with("conv:mon")).collect();
Ok(())
}
Resume across a process boundary — re-open + re-scope, no load step:
use lunaris::{Lunaris, Query, Scope, Vector};
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
let scoped = lunaris.scoped(Scope::new("acme.agent-1")?);
drop(lunaris); // process exits
let lunaris = Lunaris::open("moon://localhost:6380").await?; // new process
let scoped_a = lunaris.scoped(Scope::new("acme.agent-a")?);
let hits = scoped_a.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text("owner")).await?;
assert!(!hits.is_empty()); // agent-a's episodes are still there
Ok(())
}
Episode IDs.
EpisodeBuilderauto-generates a fresh ULID per episode (into_episodedoesself.id.unwrap_or_else(Ulid::new)), so distinct ingests never collide on a KV row. Override the id with.id(...)only when you want idempotent replay — re-ingesting the same logical episode without creating a duplicate.
Verified run output
The example was run against a single-shard Moon server
(moon://localhost:6380, moon --port 6380 --shards 1) — cargo run exits
0 with all assertions passing. Verbatim stdout (RUST_LOG=error):
multi-agent: run id 36619
multi-agent: scope_a = acme.agent-a-36619
multi-agent: scope_b = acme.agent-b-36619
=== 1. hard isolation between two agents (distinct Scopes) ===
multi-agent: ingested agent-a episode at lsn=Lsn { wall_ms: 1778570918020, counter: 0 }
multi-agent: ingested agent-b episode at lsn=Lsn { wall_ms: 1778570918061, counter: 0 }
multi-agent: scope_a recall("owner") -> 1 hit(s), sources=["agent-a:notes"]
multi-agent: scope_b recall("owner") -> 1 hit(s), sources=["agent-b:notes"]
multi-agent: OK — neither agent can see the other's episode
=== 2. multiple sessions / tasks within agent-a (source-prefix partition) ===
multi-agent: ingested 3 session/task episodes under scope_a
multi-agent: scope_a recall("acme widget") -> 4 hit(s), sources=["agent-a:notes", "conv:mon", "conv:tue", "task:deploy"]
multi-agent: distinct source-prefix kinds seen across the recall: ["agent-a", "conv", "task"]
multi-agent: client-side narrowed to source-prefix `conv:mon` -> 1 hit(s): ["conv:mon"]
multi-agent: NOTE — there is no server-side `source`-prefix filter today; the v0 `filter_str` DSL targets Episode metadata, not the source string. Narrowing is client-side over `Hit.source` (matches the recipes' MessageStream behaviour).
=== 3. resume across a process boundary (drop handle, re-open, re-scope) ===
multi-agent: dropped the Lunaris handle (simulating process exit)
multi-agent: after re-open, scope_a recall("owner") -> 4 hit(s), sources=["agent-a:notes", "conv:mon", "conv:tue", "task:deploy"]
multi-agent: after re-open, scope_b recall("owner") -> 1 hit(s)
multi-agent: OK — agent-a memory is durable across the process boundary
multi-agent: ALL ASSERTIONS PASSED ✔
multi-agent: NOTE — the recipe wrappers (MultiTurnConversation, ChatAgentMemory, MessageStream) currently build episodes with Scope::dev() and partition only by source prefix; hard per-agent isolation today goes through the low-level lunaris.scoped(scope) handle shown above (or, in lunaris-server, the JWT `tenant` claim). See docs/book/src/guides/multi-agent.md.
What this proves and what it doesn’t. The
StubEmbedderemits deterministic, non-semantic 768-d vectors, so cosine scores are all0.0and ranking is meaningless — the assertions check “≥ 1 hit” / “no cross-scope source leak”, not “the right hit ranked first”. What the run does establish: (1) the ingest → recall round-trip works against a real Moon backend; (2) twoScopes under one handle are isolated — neither agent’s recall returns the other’s chunks; (3) severalsource-tagged episodes under one scope all show up in a single recall, and the source prefix is preserved onHit.source; (4) the data survives dropping and re-opening the handle (Moon is durable; there is no explicit load step). Run it yourself:examples/multi-agent-rs/.
Shared scratchpad across agents
Within one scope, the four memory.scratchpad_* MCP tools act as a shared
blackboard for agent teams:
- Scope is the hard boundary — every agent that resolves the same scope (same git remote + branch, or the same explicit override) reads and writes the same store, even across runtimes (Claude Code and Codex side by side).
- Namespaces partition by agent — the full key is
{namespace}{key}(default namespacescratchpad/,/allowed), so teams carve per-agent areas likescratchpad/executor/status. This is an organizational prefix, not a security boundary; teammates are supposed to read each other’s entries (scratchpad_grepon a prefix). - Handoffs are immediate — write-then-read is read-your-writes consistent on every backend, and scratchpad writes never load the embedder.
scratchpad_consolidatepromotes the team’s hot items — the ACT-R drain moves high-activation entries into durable memory (where semanticrecallfinds them later) and archives stale working state. Requires a native-queue backend.
Gotchas
- No hierarchical scopes in v0.2 — flat strings only.
org/team/agenttree semantics are a future RFC. - Cross-scope graph references are disallowed by construction —
Relation.src/Relation.dstmust resolve within the same scope. Scopeis an identifier, not a permission system — AuthZ stays in thelunaris-servermiddleware (the token map’sscopesarray).- v0.1 → v0.2 had no on-the-wire compatibility. Pre-scope rows were
backfilled to
scope = '_legacy'(the reserved fallback literal), andmetadata.tenantis no longer silently honored as a tenant key. The SQL-driven half of that recipe went with the Postgres backend in 0.7.0. Full recipe — including the v0.2.0 → v0.2.1 colon-removal step (migrations/20260512000007_scope_regex_tighten.sql) — indocs/migration/0.1-to-0.2.md.
See also
- Ingesting Observations and The Retrieval DSL — the scoped surface in detail.
- Forgetting — why
forgetis_dev_-only in v0.2.x. - Configuration Reference — the bearer-token map format and supervision env vars.
- MemoryProtocol 0.1 — the full HTTP/SSE wire spec.
Cookbook
Reach for the cookbook when you don’t want to hand-write episodes and retrieval plans — pick the named recipe that matches your data shape and you get a ≤ 30-LOC, parity-tested API instead.
Want the raw query surface instead of a recipe wrapper? Querying Three Ways composes the same operators by hand — direct
recall, DSL fusion, and theTreeoperator. Same Moon, no wrapper.
Lunaris ships a small library of recipe types in the lunaris-recipes
crate (plus HeliosScratchpad in the umbrella lunaris crate). They are
layered:
- 4 primitives —
MessageStream,DocumentCorpus,TemporalQuery<S>,WorkingMemory. The building blocks. Each wraps anArc<lunaris::Lunaris>handle and exposes 3–6 public methods. - 5 conversational wrappers (
lunaris_recipes::conversational) —ChatAgentMemory,MultiTurnConversation,SlackArchive,EmailThreading,MeetingNotesMemory. Thin compositions overMessageStream(+WorkingMemory, + an optional graph-pipeline toggle). - 5 documentary wrappers (
lunaris_recipes::documentary) —DocumentKnowledgeBase,ResearchPaperCorpus,CodeRepoMemory,TimelineReconstruction,CustomerSupportHistory. Thin compositions overDocumentCorpusandTemporalQuery<Documents>(+MessageStreamfor the support history).
Every wrapper forwards into at most two primitive method invocations per
public method and holds zero business logic — they exist to make a
blueprint §7 recipe discoverable from the public surface, not to add
behaviour. None of them bundle a second vector or BM25 library; the fused
recall plan they assemble (Vector + Keyword ⊕ RRF) lowers through the
retrieval DSL and dispatches to Moon-native
FT.* inside RetrievalBuilder::execute.
All ten wrappers carry live-Moon tests under
crates/lunaris-recipes/tests/*_parity.rs (and the documentary trio also
under tests/documentary_rust_integration.rs). They were Moon-vs-Postgres
byte-identity assertions until 0.7.0 removed the second backend; what remains
is the same hit-count + hit-id-ordering contract, asserted against Moon alone.
The tests are feature-gated behind moon-it
and probe the backend with a 1-second TCP check, so a default
cargo test -p lunaris-recipes stays zero-config.
The recipe map
| Recipe | Type | Module path | Composes | Reach for it when… |
|---|---|---|---|---|
MessageStream | primitive | lunaris_recipes::MessageStream | Lunaris | you have a stream of short messages and want recency-weighted recall |
DocumentCorpus | primitive | lunaris_recipes::DocumentCorpus | Lunaris | you have a document corpus and want hybrid RAG (Vector + BM25 + RRF) |
TemporalQuery<S> | primitive | lunaris_recipes::TemporalQuery | Lunaris | you want time-travel — “what did the agent know at time T” |
WorkingMemory | primitive | lunaris::WorkingMemory (re-exported by lunaris_recipes) | Lunaris (+ Consolidator) | you want a scope-prefixed scratchpad with optional consolidator promotion |
ChatAgentMemory | conversational | lunaris_recipes::conversational::ChatAgentMemory | MessageStream + WorkingMemory | one chat agent, per-user remember / recall |
MultiTurnConversation | conversational | lunaris_recipes::conversational::MultiTurnConversation | MessageStream + WorkingMemory | same as above plus a cross-session consolidate() pass |
SlackArchive | conversational | lunaris_recipes::conversational::SlackArchive | MessageStream | a Slack workspace export, channel/user-narrowed recall |
EmailThreading | conversational | lunaris_recipes::conversational::EmailThreading | MessageStream + WorkingMemory (+ graph toggle) | email threads, optional sender/recipient graph |
MeetingNotesMemory | conversational | lunaris_recipes::conversational::MeetingNotesMemory | MessageStream + WorkingMemory (+ graph toggle) | meeting notes by heading, attendee-narrowed recall, optional graph |
DocumentKnowledgeBase | documentary | lunaris_recipes::documentary::DocumentKnowledgeBase | DocumentCorpus | a generic doc corpus with metadata filters |
ResearchPaperCorpus | documentary | lunaris_recipes::documentary::ResearchPaperCorpus | DocumentCorpus (+ graph toggle) | papers, optional citation graph |
CodeRepoMemory | documentary | lunaris_recipes::documentary::CodeRepoMemory | DocumentCorpus + TemporalQuery<Documents> | commits / PRs / code, “function body as-of commit N” |
TimelineReconstruction | documentary | lunaris_recipes::documentary::TimelineReconstruction | DocumentCorpus + TemporalQuery<Documents> | a dated event narrative, between(lo, hi) / as_of(ts) |
CustomerSupportHistory | documentary | lunaris_recipes::documentary::CustomerSupportHistory | DocumentCorpus + MessageStream (+ graph toggle) | tickets and chat transcripts, recall across both |
HeliosScratchpad | (umbrella recipe) | lunaris::HeliosScratchpad | WorkingMemory | an agent filesystem: write/read/edit/grep/ls + as_of time-travel |
Each remaining chapter in this section is one (or a small group of) recipes with a runnable-shaped example derived from the parity test for that recipe.
Primitives
MessageStream — recency-weighted message recall
MessageStream (crates/lunaris-recipes/src/message_stream.rs) is the
substrate every conversational wrapper composes over. It binds an
Arc<Lunaris> to a thread prefix ("messages:", "slack:archive/", …)
and exposes:
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, thread_prefix: impl Into<String>) -> Self | binds the prefix |
with_top_k | fn with_top_k(self, k: usize) -> Self | builder knob; default 8 |
ingest | async fn ingest(message, thread_id, participant_id) -> Result<Lsn, LunarisError> | one message → one Episode under {prefix}{thread_id}/; thread_id + participant_id land in Episode.metadata |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | fuses Vector + Keyword via RRF(k=60), filters to the prefix, then blends an ACT-R base-level activation score (Anderson 1996, d = 0.5) with the fused RRF score so more-recent messages rank higher |
ingest delegates to Lunaris::ingest — the umbrella pipeline performs
chunking + embedding + a single atomic_write (the INGEST-04
contract). One call = one message = one internal
atomic write.
Reach for it when your data is a flowing stream of short messages — chat turns, Slack posts, email bodies, meeting notes — and recency matters to recall ordering.
Sessions persist automatically.
MessageStream(and the conversational wrappers over it) are stateless handles over durable storage — there is no “save”/“load session” step. To resume, reconstruct the wrapper with the same id (and, forMultiTurnConversation, the samethread_idonremember); the backend already holds every prior turn. See Chat Agent Memory → Resuming a session.
DocumentCorpus — hybrid Vector + Keyword RAG
DocumentCorpus (crates/lunaris-recipes/src/document_corpus.rs) is the
RAG primitive. It binds an Arc<Lunaris> to a source prefix
("kb:papers/", "repo:src/", …) and is a small fluent builder:
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Self | binds the prefix |
ingest | async fn ingest(chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError> | each (content, metadata) pair → one Episode under {prefix}{ulid} |
filter | fn filter(self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Self | adds a Filter::Eq on a metadata field; multiple calls AND together |
top | fn top(self, k: usize) -> Self | caps output; default 10 |
search | async fn search(self, query: &str) -> Result<Vec<Hit>, LunarisError> | consumes self; fans out a Vector + Keyword(BM25) ⊕ RRF(60) plan with a generous over-fetch, executes, then prunes to the source prefix and caps at k |
The native-RRF vs client-side-fold branch lives inside
RetrievalBuilder::execute; the primitive is pure plan composition.
Reach for it when your data is a mostly-static document corpus and the hot path is retrieval, not ingest.
TemporalQuery<S> — typestate time-travel
TemporalQuery<S> (crates/lunaris-recipes/src/temporal_query.rs) is a
time-travel combinator where S is a compile-only phantom marker —
Messages, Documents, or Facts. Method availability is bound by sealed
traits (SupportsAsOf, SupportsBetween), so an invalid combination fails
at cargo check, not at runtime.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Self | source S is a phantom — no value needed |
as_of | fn as_of(self, ts: Hlc) -> Self | snapshot at ts |
before / after | fn before(self, ts: Hlc) -> Self / fn after(self, ts: Hlc) -> Self | valid-time bounds |
between | fn between(self, after: Hlc, before: Hlc) -> Self | requires S: SupportsBetween; panics if after > before; range is [after, before) (lower inclusive, upper exclusive) |
execute | async fn execute(self, query: &str) -> Result<Vec<Hit>, LunarisError> | dispatch handled by RetrievalBuilder::execute (Moon TEMPORAL.SNAPSHOT_AT) |
Reach for it when you need “what did the agent know at time T” as a query rather than a rebuild — audit replay, post-incident debugging, pinned regression fixtures. See Durability & Recovery for the bi-temporal MVCC model underneath.
WorkingMemory — scope-prefixed scratchpad
WorkingMemory lives in lunaris::primitives::working_memory (it is
re-exported as lunaris_recipes::WorkingMemory so use lunaris_recipes::WorkingMemory; keeps compiling). It is a key-prefixed
scratchpad: (k, v) pairs stored under {scope_prefix}{k} as Episodes.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, scope_prefix: impl Into<String>) -> Self | binds the prefix |
write | async fn write(k: &str, v: serde_json::Value) -> Result<Lsn, LunarisError> | value is JSON, not a bare string |
read | async fn read(k: &str) -> Result<Option<serde_json::Value>, LunarisError> | None = no hit |
grep | async fn grep(pattern: &str) -> Result<Vec<(String, serde_json::Value)>, LunarisError> | all pairs whose source starts with {scope_prefix}{pattern} |
consolidate | async fn consolidate() -> Result<ConsolidationReport, LunarisError> | drains up to 1024 recent consolidate events and runs one scope-filtered ACT-R promotion pass (no-op if no Consolidator is installed — default OFF) |
consolidate() is the engine behind MultiTurnConversation::consolidate()
and HeliosScratchpad’s per-scope promotion toggle. See
Consolidation & Verification.
Reach for it when an agent needs a small mutable working set that lives in the same bi-temporal store as everything else, and you want the option of promoting hot scratchpad notes into long-term facts.
Querying Three Ways
Reach for this page when you want to see the raw retrieval surface — not a
recipe wrapper. One ingested document, one question, asked three ways: the
one-shot recall(query), a composed DSL plan that fuses flat chunks with
RAPTOR tree descent, and the Tree operator on its own.
This page used to be the zero-deps SQLite tour. 0.7.0 deleted the embedded backend, so it now needs a Moon like every other page:
docker run -d -p 6380:6379 ghcr.io/pilotspace/moon:0.8.5 \ --shards 1 --protected-mode no --appendonly yesWhat it still is: the unwrapped surface. The recipe pages (
DocumentKnowledgeBase, …) hand you a prebuiltVector + Keyword(BM25) ⊕ RRFplan; this page composes the operators by hand so you can see what a plan is made of.
The three forms
| Form | Call | Use it when… |
|---|---|---|
| One-shot | scoped.recall(query) | a plain semantic lookup is enough |
| DSL fusion | scoped.dsl().with_root(Vector….and(Tree…).fuse_rrf(k))….execute() | you want to blend flat chunks with hierarchical context |
| Tree | scoped.dsl().with_root(Tree::new("communities", k).with_depth(2))… | a whole-document question whose answer spans many chunks |
All three are the same engine: recall(query) is just dsl() with the
default root left in place. See Two ways to
query for the distinction.
Example
Shaped after the ingest fixtures in
crates/lunaris-ingest/tests/raptor_wiring.rs (which ingest a headed document
and assert RAPTOR builds communities with 768-d summary embeddings) and the
Tree discrimination benchmark in
crates/lunaris-retrieve/tests/tree_recall.rs.
use anyhow::{Context, Result};
use lunaris::{EpisodeBuilder, Lunaris, Query, Scope, Tree, Vector};
/// A multi-section document, sized like `MULTI_SECTION_DOC` in the
/// `tree_recall.rs` test we cite. RAPTOR builds an H1 → H2 → chunk community
/// tree at ingest, so `communities` is populated and `Tree` at `depth=2` has
/// sub-communities to descend into. The chunker targets ~500 tokens, so the doc
/// must clear that threshold (~two padded headed sections) to produce ≥2 chunks
/// and a non-flat topology — a one-paragraph blurb collapses to a single chunk
/// and `Tree` would return nothing.
const DOC: &str = "# Agent Memory Architecture
# Section A: Core Design
The agent memory system uses a bi-temporal MVCC store. Each observation is
recorded with both a valid-time and a transaction-time. This dual timestamp
enables point-in-time queries and auditing of historical states: we can ask
what was known at any point in transaction time, and what was true at any
point in valid time. That is essential for agents reconciling information
gathered at different moments and reasoning about how the world has changed.
Writes commit through a single atomic_write, so a multi-primitive ingest —
episode row, chunk rows, vector upserts, and the RAPTOR community tree — is
all-or-nothing. There is no window in which the chunks exist but their
community summaries do not; a reader either sees the whole ingest or none of
it. This is the atomicity contract that fan-out architectures cannot make.
Memory isolation between agents uses scope partitioning. Each agent receives a
unique scope key that prefixes its KV entries and FT index slots, so the
backend enforces isolation at the data layer rather than trusting application
code to filter correctly. A misconfigured caller cannot read another agent's
memories, because the partition boundary is encoded into the keyspace itself.
# Section B: Retrieval and Performance
Recall fuses semantic vector search with BM25 keyword search using Reciprocal
Rank Fusion, which combines per-branch reciprocal ranks into one score that is
robust to the scale differences between cosine similarity and normalized BM25.
Each branch contributes independently, and the fused score reflects consensus
across retrieval strategies rather than the idiosyncrasies of either one.
RAPTOR organises related chunks into a hierarchy of summary communities. Each
community node aggregates the semantic content of its child chunks into a
single embedded vector, so a whole-document question can match the summary
node and then descend to every leaf chunk underneath it — including chunks
that would never score into a flat top-k on their own. This is the core
insight: summarise at multiple granularities, then match at the right level.
The system targets sub-25 ms recall, measured at 100k documents per scope. Vector
search runs in single-digit milliseconds, community summary embeddings let
whole-document queries bypass flat chunk retrieval, and no LLM sits on the read
path. Summaries are embedded with the same model as chunks, so cosine scores
are directly comparable across the chunks and communities indices.
";
#[tokio::main]
async fn main() -> Result<()> {
// `moon://host:port` is the only scheme 0.7.0 accepts.
let lunaris = Lunaris::open("moon://127.0.0.1:6380").await.context("open")?;
let scoped = lunaris.scoped(Scope::new("demo").context("scope")?);
// Ingest once. The umbrella pipeline chunks + embeds + builds the RAPTOR
// community tree, all under one atomic_write (INGEST-04).
scoped
.ingest(EpisodeBuilder::new("demo:architecture.md", DOC))
.await
.context("ingest")?;
let question = "What are the main themes across the whole document?";
// ── Form 1: one-shot recall ────────────────────────────────────────────
// Default plan = Vector over `chunks`. No fusion, no rerank. Vec<Hit> back.
let flat = scoped.recall(Query::text(question)).await.context("recall")?;
println!("[recall] {} hit(s)", flat.len());
// ── Form 2: DSL fusion (flat chunks ⊕ RAPTOR tree) ─────────────────────
// Compose two vector-backed operators and fold their rankings with RRF.
// Both branches run on the embedded backend — no server needed.
let fused = scoped
.dsl()
.with_root(
Vector::new("chunks", 20)
.and(Tree::new("communities", 3).with_depth(2))
.fuse_rrf(60)
.top(8),
)
.execute(Query::text(question))
.await
.context("recall (dsl fusion)")?;
println!("[fusion] {} hit(s)", fused.len());
// ── Form 3: Tree on its own (RAPTOR hierarchical descent) ──────────────
// Find the nearest community summary, then descend to its leaf chunks.
// depth=2 walks H1 root → H2 sub-communities → leaf chunks.
let tree = scoped
.dsl()
.with_root(Tree::new("communities", 1).with_depth(2))
.execute(Query::text(question))
.await
.context("recall (tree)")?;
println!("[tree] {} hit(s)", tree.len());
Ok(())
}
What’s proven where
- Ingest builds communities.
raptor_wiring.rsingests a headed document and asserts each community carries a 768-dsummary_embedding— thecommunitiesvector index is populated at ingest, via the singleatomic_writeincrates/lunaris-ingest/src/pipeline.rs. Treeuses onlyvector_search+read_as_of(seecrates/lunaris-retrieve/src/operators/tree.rs) — both coreStoragePortmethods, no Cypher and noGRAPH.QUERY.- The tree-beats-flat discrimination benchmark (
tree_recall.rs) is measured against Moon, which since 0.7.0 is the only place it could be measured.
Scaling up: hybrid BM25 fusion
The classic hybrid plan fuses semantic vector search with lexical BM25:
use lunaris::{Keyword, Query, Vector};
let hits = scoped
.dsl()
.with_root(
Vector::new("chunks", 30)
.and(Keyword::bm25("chunks", 30))
.fuse_rrf(60)
.top(5),
)
.execute(Query::text("who loves chocolate"))
.await?;
The Keyword branch rides Moon’s native inverted index, and when both legs sit
on the same index fuse_rrf collapses them into one round trip instead of two.
For a batteries-included hybrid-RAG wrapper over the same plan, reach for
DocumentKnowledgeBase.
Notes
recall(query)≠ hybrid. The one-shot form is pure vector overchunks. If you reached forrecall()expecting fusion or rerank, usedsl()instead.Treereturns empty when the scope has no communities. A freshly-created scope (nothing ingested) or a single-paragraph document that collapses to a flat topology will yield zero tree hits — that is the normal graceful-empty state, not an error. Ingest a multi-section document first.- Depth costs fan-out, not index scans.
Tree::with_depth(d)issues one vector search, then walks community KV rows up todlevels — latency scales with depth × fan-out, capped atMAX_TREE_DEPTH = 4. - See The Retrieval DSL for the full operator and combinator surface, and The Storage Backend for the Moon setup this page assumes.
Chat Agent Memory
Reach for ChatAgentMemory when you want a single chat agent to
remember turns and recall them per user, with nothing else to wire.
ChatAgentMemory (crates/lunaris-recipes/src/conversational/chat_agent_memory.rs)
is the thinnest conversational wrapper: a per-user MessageStream + a
per-user WorkingMemory, both bound to the same "chat:<user_id>/" scope
prefix (the shared prefix is what keeps a later consolidator pass from
leaking across users). It exposes three methods:
| Method | Signature | Forwards to |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, user_id: &str) -> Self | MessageStream::new + WorkingMemory::new |
remember | async fn remember(turn: impl Into<String>) -> Result<Lsn, LunarisError> | MessageStream::ingest(turn, "default", "user") |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | MessageStream::recall |
Chat sessions are a flat stream in this wrapper — thread_id is always
"default". If you need per-session partitioning, use
MultiTurnConversation instead.
Recall ordering is the ACT-R recency-weighted blend inherited from
MessageStream::recall: the fused Vector + Keyword ⊕ RRF(60) score is
summed with an Anderson-1996 base-level activation term (d = 0.5), so a
turn from a minute ago outranks a same-relevance turn from an hour ago.
Example
Shaped after chat_agent_memory_moon_postgres_parity in
crates/lunaris-recipes/tests/conversational_parity.rs: open a handle,
construct a per-user memory, replay a handful of turns, then recall.
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::ChatAgentMemory;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
// One handle per process — share via Arc. URL scheme picks the backend.
let lunaris = Arc::new(Lunaris::open("moon://127.0.0.1:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-support-bot")?;
let mem = ChatAgentMemory::new(lunaris.clone(), scope.clone(), "user-42");
// Record conversational turns as they happen.
mem.remember("I'm planning a trip to Kyoto in April.").await?;
mem.remember("My budget is around 3000 USD.").await?;
mem.remember("I'd like a ryokan with an onsen for at least one night.").await?;
// Later — recall what's relevant to the next prompt.
let hits = mem.recall("what kind of accommodation does the user want?").await?;
for h in &hits {
println!("score={:.3} source={} text={}", h.score, h.source, h.text);
}
Ok(())
}
Swap the URL for moon://localhost:6380 and the code is byte-for-byte
identical — that is the parity contract.
Resuming a session
There is no explicit “load session” step — you resume by reconstructing
ChatAgentMemory with the same user_id. The wrapper is a stateless
handle over durable storage: ChatAgentMemory::new does no I/O, it just builds
the "chat:<user_id>/" scope prefix; every prior remember already wrote an
Episode into the backend keyspace (lunaris:{scope}:{kind}:{ulid}). So a
fresh process — a new request handler, a restarted service, a different machine
— that constructs the same id immediately recalls the full history:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::ChatAgentMemory;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
// A brand-new process. Nothing was kept in memory between runs.
let lunaris = Arc::new(Lunaris::open("moon://127.0.0.1:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-support-bot")?;
// Same user id as before ⇒ same `"chat:user-42/"` scope ⇒ same memory.
// `new` is pure — the first storage round-trip is the `recall` below.
let mem = ChatAgentMemory::new(lunaris.clone(), scope.clone(), "user-42");
let prior = mem.recall("what has the user told me so far?").await?;
for h in &prior {
println!("score={:.3} text={}", h.score, h.text);
}
// ... then carry on: `mem.remember(...)` appends to the same history.
Ok(())
}
Same id ⇒ same memory; that is the whole contract. There is nothing to serialize, snapshot, or hand back between turns — the backend is the session store, and constructing the wrapper is just a name binding.
Notes
- One
ChatAgentMemoryper user. Construction is pure (no I/O); the first storage round-trip happens on the firstremember/recall. newtakes&strfor the user id, notimpl Into<String>— the scope prefix is built immediately as"chat:<user_id>/".- No
consolidate()here. The wrapper holds aWorkingMemoryfor future additive surface, but the cross-session promotion pass isMultiTurnConversation’s differentiator. See Multi-Turn Conversation. - Multi-agent scoping. The
"chat:<user_id>/"prefix isolates one user’s turns inside thisMessageStream— butMessageStreambuilds episodes withScope::dev(), so that is source-prefix isolation, not a tenant wall. For RLS-grade per-agent isolation (separate agent platforms, the HTTPtenantclaim, the low-levellunaris.scoped(scope)handle), see Multi-Agent & Scope → Multi-agent patterns and the runnableexamples/multi-agent-rs/. - Embedder / backend tuning lives in the
Configuration Reference — the recipe adds
no knobs of its own beyond
MessageStream::with_top_kon the underlying primitive (not surfaced onChatAgentMemory).
Multi-Turn Conversation
Reach for MultiTurnConversation when a chat agent runs many sessions per
user and you want a cross-session consolidation pass that promotes hot
scratchpad notes into long-term facts — without leaking across users.
MultiTurnConversation
(crates/lunaris-recipes/src/conversational/multi_turn_conversation.rs)
adds one thing to the ChatAgentMemory shape: a
consolidate() method. It composes a MessageStream + a WorkingMemory,
both captured at the same "chat:<user_id>/" scope prefix. That shared
prefix is the load-bearing invariant — consolidate() runs through
Consolidator::consolidate_scoped(Some("chat:<user_id>/")), so any event
whose source does not start with that prefix is rejected by the scope
filter (closing the cross-user consolidator-leak risk).
| Method | Signature | Forwards to |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, user_id: &str) -> Self | MessageStream::new + WorkingMemory::new |
remember | async fn remember(turn: impl Into<String>, thread_id: impl Into<String>) -> Result<Lsn, LunarisError> | MessageStream::ingest(turn, thread_id, "user") |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | MessageStream::recall |
consolidate | async fn consolidate() -> Result<ConsolidationReport, LunarisError> | WorkingMemory::consolidate |
Note remember here takes a session id (thread_id) — turns from
different sessions land under different Episode source segments but all
recall together.
consolidate() is a no-op unless a Consolidator is installed on the
handle — the consolidator pipeline defaults OFF (blueprint §5.2). Install
one with lunaris.consolidator_pipeline().set_consolidator(...); see
Consolidation & Verification for the
ACT-R promotion model and the ConsolidationReport shape.
Example
Shaped after multi_turn_conversation_cross_session_consolidation_parity in
crates/lunaris-recipes/tests/conversational_parity.rs: seed turns across
two sessions for one user, recall across both, then run a scoped
consolidation pass.
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::MultiTurnConversation;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-support-bot")?;
// (Optional) install a consolidator — without one, consolidate() is a
// no-op that returns an empty report. The default pipeline is OFF.
// lunaris.consolidator_pipeline().set_consolidator(Arc::new(my_consolidator));
let conv = MultiTurnConversation::new(lunaris.clone(), scope.clone(), "user-42");
// Session "trip-planning".
conv.remember("I'm planning a trip to Kyoto in April.", "trip-planning").await?;
conv.remember("Budget is around 3000 USD.", "trip-planning").await?;
// A later session, same user.
conv.remember("Booked a ryokan in Higashiyama for two nights.", "booking").await?;
// Recall spans every session for this user.
let hits = conv.recall("where is the user staying in Kyoto?").await?;
for h in &hits {
println!("score={:.3} source={} text={}", h.score, h.source, h.text);
}
// One scope-filtered consolidation pass: only `chat:user-42/` events are
// eligible for promotion; another user's turns are rejected by the
// scope filter.
let report = conv.consolidate().await?;
println!("promotions this pass: {}", report.promotions.len());
Ok(())
}
Resuming a session
Like ChatAgentMemory, there is no
explicit load — reconstruct MultiTurnConversation::new(handle, scope, "user-42")
with the same user_id and the backend already holds every prior turn. To
resume one specific session, pass the same thread_id to remember again:
turns from that session keep landing under the same Episode source segment.
recall still spans all sessions for that user — the thread_id only
shapes how turns are grouped, not what recall sees:
use lunaris::{Lunaris, Scope};
async fn demo() -> Result<(), lunaris::LunarisError> {
let lunaris = Lunaris::open("moon://localhost:6380").await?;
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::MultiTurnConversation;
async fn run(lunaris: Arc<Lunaris>) -> Result<(), lunaris::LunarisError> {
let scope = Scope::new("acme-support-bot")?;
// A new process — same user, continuing the "trip-planning" session.
let conv = MultiTurnConversation::new(lunaris.clone(), scope.clone(), "user-42");
conv.remember("Confirmed the ryokan for the 14th.", "trip-planning").await?;
// Recall still spans every session this user has ever had.
let hits = conv.recall("what's confirmed for the Kyoto trip?").await?;
let _ = hits;
Ok(())
}
Ok(())
}
Construction is pure — MessageStream::new / WorkingMemory::new do no I/O —
so resuming is just a name binding; the durable backend is the session store.
Notes
- The scope prefix is captured at
new. Both the write path (MessageStream::ingest) and the promotion filter (WorkingMemory::consolidate) see"chat:<user_id>/", so isolation is enforced at both ends. consolidate()is bounded. Each call drains at most 1024 recent consolidate events with a 50 ms per-pull timeout — heavy callers should invoke it repeatedly rather than expect one call to drain everything.- Successful promotions emit an audit event. Each promotion publishes
AuditEvent::ConsolidatorPromotionto the__lunaris_audit__topic. - Use
ChatAgentMemoryinstead if you don’t need sessions or consolidation — it’s the same recall behaviour with a flatter surface.
Slack / Email / Meeting Notes
Reach for these three when your conversational data arrives in
channels, threads, or headings — SlackArchive for a workspace export,
EmailThreading for mail threads, MeetingNotesMemory for meeting
minutes.
All three wrap MessageStream
(the email and meeting wrappers also hold a WorkingMemory for future
additive surface, and expose an opt-in
graph pipeline toggle). Each has a hard-coded source
prefix — they are named recipes, not general-purpose composers. If you
need an alternate prefix, compose MessageStream directly.
| Wrapper | Source prefix | Composes | Public surface |
|---|---|---|---|
SlackArchive | slack:archive/ | MessageStream | new / ingest_channel / recall / channel / user |
EmailThreading | email:thread/ | MessageStream + WorkingMemory (+ graph toggle) | new / ingest / thread / recall / with_graph_pipeline |
MeetingNotesMemory | meeting:notes/ | MessageStream + WorkingMemory (+ graph toggle) | new / note / recall / attendees / with_graph_pipeline |
SlackArchive
crates/lunaris-recipes/src/conversational/slack_archive.rs — a channel +
user-filtered message archive. Read-heavy, so it holds only a
MessageStream (no WorkingMemory).
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Self | no prefix arg — rooted at slack:archive/ |
ingest_channel | async fn ingest_channel(channel: impl Into<String>, participant_id: impl Into<String>, message: impl Into<String>) -> Result<Lsn, LunarisError> | channel becomes the MessageStream thread_id; both channel + participant_id land in metadata |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | recall across the whole archive |
channel | fn channel(id: impl Into<String>) -> SlackArchiveQuery | narrow to one channel (no I/O — deferred to SlackArchiveQuery::recall) |
user | fn user(id: impl Into<String>) -> SlackArchiveQuery | narrow to one user |
SlackArchiveQuery adds with_user(id) (chain a user narrow on top of a
channel narrow → Filter::And) and recall(query). The narrowed recall
builds the same Vector + Keyword ⊕ RRF(60) plan and attaches a pre-built
Filter::Eq on the channel / participant_id field — no new retrieval
DSL is introduced.
The
channel/participant_idchunk-payload fields are not yet emitted by the ingest pipeline, so the metadata-Eqnarrow is structurally wired (it passes both backend translators) but currently matches an empty set until that payload extension lands. The archive-widerecalland thesource-prefix narrowing path are fully wired end-to-end. See the module rustdoc for the full caveat.
Example
Shaped after slack_archive_channel_filter_parity in
crates/lunaris-recipes/tests/conversational_parity.rs:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::SlackArchive;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-workspace")?;
let archive = SlackArchive::new(lunaris.clone(), scope.clone());
// Bulk-ingest a workspace export, message by message.
archive.ingest_channel("general", "U_ALICE", "Standup in 5, room Helios.").await?;
archive.ingest_channel("general", "U_BOB", "I'll be 2 min late.").await?;
archive.ingest_channel("incident-2025-05-12", "U_ALICE", "Rolled back deploy 0.2.3.").await?;
// Recall across the whole archive.
let wide = archive.recall("what happened with the deploy?").await?;
println!("archive-wide hits: {}", wide.len());
// Narrowed to one channel.
let narrow = archive.channel("general").recall("standup").await?;
println!("#general hits: {}", narrow.len());
Ok(())
}
EmailThreading
crates/lunaris-recipes/src/conversational/email_threading.rs — a
thread-scoped email archive with an opt-in graph builder.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Self | no prefix arg — rooted at email:thread/ |
ingest | async fn ingest(root_id: impl Into<String>, from: impl Into<String>, body: impl Into<String>) -> Result<Lsn, LunarisError> | one email into thread root_id, authored by from |
thread | fn thread(root_id: impl Into<String>) -> EmailThreading | returns a narrowed Self scoped at email:thread/<root_id>/ — the Filter::StartsWith on source does the narrowing (fully wired) |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | recall across the current scope (whole archive, or one thread on a narrowed handle) |
with_graph_pipeline | fn with_graph_pipeline(self, enable: bool) -> Self | flips lunaris.graph_pipeline().enable() / disable(); builder-style; idempotent. Graph defaults OFF (blueprint §5.2) — opt in deliberately |
Example
Shaped after email_threading_graph_off_parity /
email_threading_graph_on_opt_in:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::EmailThreading;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://127.0.0.1:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-workspace")?;
// Opt in to the sender/recipient graph BEFORE ingest if you want edges
// (the extractor runs inside the ingest hot path, not after the fact).
let email = EmailThreading::new(lunaris.clone(), scope.clone()).with_graph_pipeline(true);
email.ingest("RFC-0042", "alice@example.com", "Proposing the new retention sweep.").await?;
email.ingest("RFC-0042", "bob@example.com", "+1, but let's cap it at 90 days.").await?;
email.ingest("RFC-0042", "alice@example.com", "Done, capped at 90d in v2.").await?;
// Recall across all threads.
let all = email.recall("retention sweep cap").await?;
println!("all-threads hits: {}", all.len());
// Narrow to one thread, then recall within it.
let in_thread = email.thread("RFC-0042").recall("what was the cap?").await?;
println!("RFC-0042 hits: {}", in_thread.len());
Ok(())
}
MeetingNotesMemory
crates/lunaris-recipes/src/conversational/meeting_notes_memory.rs — stores
meeting headings as thread_id and note bodies as message content; supports
attendee-narrowed recall and the same graph toggle.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Self | no prefix arg — rooted at meeting:notes/ |
note | async fn note(heading: impl Into<String>, body: impl Into<String>) -> Result<Lsn, LunarisError> | one note under heading; participant defaults to "scribe" |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | recall across the meeting corpus |
attendees | fn attendees(attendees: Vec<String>) -> MeetingNotesQuery | narrow to notes attributed to attendees — takes an owned Vec<String>, not &[&str] |
with_graph_pipeline | fn with_graph_pipeline(self, enable: bool) -> Self | same semantics as EmailThreading |
MeetingNotesQuery::recall(query) emits a Filter::And of per-attendee
Filter::Eq { field: "participant_id", .. } (all attendees must be present)
— same metadata-payload caveat as SlackArchive’s channel narrow.
Example
Shaped after meeting_notes_memory_transcript_parity:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::conversational::MeetingNotesMemory;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-workspace")?;
let notes = MeetingNotesMemory::new(lunaris.clone(), scope.clone());
notes.note("2025-05-12 / Roadmap", "Decided to ship the 90-day retention cap in v2.").await?;
notes.note("2025-05-12 / Roadmap", "Action item: Alice to write the migration doc.").await?;
notes.note("2025-05-13 / Standup", "Migration doc drafted, in review.").await?;
// Recall across the whole corpus.
let hits = notes.recall("what was decided about retention?").await?;
println!("corpus hits: {}", hits.len());
// Narrow to notes attributed to a set of attendees (owned Vec<String>).
let by_alice = notes.attendees(vec!["scribe".to_string()]).recall("action items").await?;
println!("attendee-narrowed hits: {}", by_alice.len());
Ok(())
}
Notes
- Hard-coded prefixes.
slack:archive/,email:thread/,meeting:notes/— if you need different roots, composeMessageStreamyourself; these wrappers won’t take a prefix argument. - Graph is opt-in and ingest-time.
with_graph_pipeline(true)must be called before ingest if you want entity/relation edges; retrofitting graph on an already-ingested corpus requires re-ingest. See The Graph Pipeline. - Tenant isolation is orthogonal to these channel prefixes — see Multi-Agent & Scope.
Document Knowledge Base
Reach for DocumentKnowledgeBase when you have a corpus of documents and
want hybrid RAG over it — semantic + BM25 fused with RRF — with metadata
filters and a result cap.
DocumentKnowledgeBase
(crates/lunaris-recipes/src/documentary/document_knowledge_base.rs) is a
thin wrapper over DocumentCorpus.
It exists to make “knowledge base over a document source” discoverable from
the public surface; every method forwards into at most one primitive call
and there is no business logic of its own.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Self | binds the inner DocumentCorpus to source_prefix (e.g. "kb:docs/") |
ingest | async fn ingest(chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError> | each (content, metadata) pair → one Episode under {prefix}{ulid} |
filter | fn filter(self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Self | adds a Filter::Eq on a metadata field; multiple calls AND together; consumes self |
top | fn top(self, k: usize) -> Self | caps output; default 10; consumes self |
search | async fn search(self, query: &str) -> Result<Vec<Hit>, LunarisError> | consumes self; fans out a Vector + Keyword(BM25) ⊕ RRF(60) plan with a generous over-fetch, executes, then prunes to the source prefix and caps at k |
search returns Vec<Hit> ranked by the fused RRF score. The dispatch to
Moon’s native RRF (versus a client-side merge for a backend that does not
declare the capability) lives inside RetrievalBuilder::execute — the wrapper
is pure plan composition. Source
prefix scoping runs post-hydrate (the chunks FT schema does not carry
source), so a modest over-fetch is applied before pruning to the corpus’s
prefix.
If your documents are pre-chunked already, pass them directly; otherwise chunk them upstream (the umbrella ingest pipeline’s markdown chunker targets ~500 tokens with 100-token overlap — see Ingesting Observations).
Example
Shaped after document_knowledge_base_parity_quickstart_rag in
crates/lunaris-recipes/tests/documentary_parity.rs:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::documentary::DocumentKnowledgeBase;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://127.0.0.1:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-docs")?;
let kb = DocumentKnowledgeBase::new(lunaris.clone(), scope.clone(), "kb:docs/");
// Ingest pre-chunked content with metadata.
let chunks = vec![
(
"Lunaris commits all storage fan-out in a single atomic_write.".to_string(),
serde_json::Map::from_iter([
("lang".to_string(), serde_json::json!("en")),
("section".to_string(), serde_json::json!("architecture")),
]),
),
(
"The retrieval DSL fuses Vector, Keyword, and Graph operators via RRF.".to_string(),
serde_json::Map::from_iter([
("lang".to_string(), serde_json::json!("en")),
("section".to_string(), serde_json::json!("retrieval")),
]),
),
];
kb.ingest(chunks).await?;
// Hybrid RAG: filter on metadata, cap results, search.
let hits = kb
.filter("lang", "en")
.top(5)
.search("how does Lunaris guarantee atomicity?")
.await?;
for h in &hits {
println!("score={:.3} source={} text={}", h.score, h.source, h.text);
}
Ok(())
}
Swap the URL for moon://localhost:6380 — same code, same result set
(parity contract).
Notes
- Builder methods consume
self.kb.filter(..).top(..).search(..)is the idiom; you can’t reuse aDocumentKnowledgeBaseafter callingsearch. filteris metadata-Eqonly (Filter::Eqfromlunaris-coreis canonical — never build SQLWHEREstrings). Multiple.filtercalls AND together. For valid-time filtering useTimelineReconstructionorTemporalQuerydirectly.- No batch-ingest helper —
ingestissues one internalatomic_writeper chunk. A true batched bulk-ingest path is a post-v0.1 addition; for large static corpora today, prefer the bench-crate bulk helpers (see the RAG scenario in Helios Scratchpad) or accept the per-chunk write cost. - For citation-graph-aware paper corpora, see
ResearchPaperCorpus.
Research Papers & Code Repos
Reach for ResearchPaperCorpus when you want a paper corpus with an
opt-in citation graph; reach for CodeRepoMemory when you want
“function body as-of commit N” — point-in-time recall over committed code.
Both wrap DocumentCorpus
on the ingest side; CodeRepoMemory adds TemporalQuery<Documents>
on the recall side.
ResearchPaperCorpus
crates/lunaris-recipes/src/documentary/research_paper_corpus.rs — a
DocumentCorpus plus an opt-in citation graph. The graph-on path toggles
Lunaris::graph_pipeline().enable() (the graph defaults OFF per blueprint
§5.2; opt in via the explicit builder call).
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Self | binds the inner corpus (e.g. "papers:") |
with_graph_pipeline | fn with_graph_pipeline(self, on: bool) -> Self | enable() / disable() on the graph handle; idempotent; builder-style; consumes self |
ingest | async fn ingest(chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError> | forwards to DocumentCorpus::ingest |
search | async fn search(self, query: &str) -> Result<Vec<Hit>, LunarisError> | forwards to DocumentCorpus::search; consumes self |
Put the paper’s id / venue / year in the chunk metadata so a later
metadata-Eq filter (via the underlying DocumentCorpus) can narrow by
year or venue. To get citation edges in the graph, call
with_graph_pipeline(true) before ingest — extraction runs inside the
ingest hot path.
Example
Shaped after research_paper_corpus_parity_graph_off_recall in
crates/lunaris-recipes/tests/documentary_parity.rs:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::documentary::ResearchPaperCorpus;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-research")?;
// Opt in to the citation graph before ingest. Default is OFF.
let papers = ResearchPaperCorpus::new(lunaris.clone(), scope.clone(), "papers:")
.with_graph_pipeline(true);
let chunks = vec![
(
"Reciprocal Rank Fusion outperforms Condorcet fusion on TREC runs.".to_string(),
serde_json::Map::from_iter([
("paper_id".to_string(), serde_json::json!("cormack2009")),
("year".to_string(), serde_json::json!(2009)),
]),
),
(
"ACT-R base-level activation models declarative memory decay.".to_string(),
serde_json::Map::from_iter([
("paper_id".to_string(), serde_json::json!("anderson1996")),
("year".to_string(), serde_json::json!(1996)),
]),
),
];
papers.ingest(chunks).await?;
let hits = papers.search("how is rank fusion evaluated?").await?;
for h in &hits {
println!("score={:.3} source={} text={}", h.score, h.source, h.text);
}
Ok(())
}
CodeRepoMemory
crates/lunaris-recipes/src/documentary/code_repo_memory.rs — models
“function body as-of commit N”. Each commit is ingested once per chunk with
commit_sha and committer_date_unix_ms stamped into the Episode
metadata; recall time-travels via TemporalQuery::<Documents>::as_of.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, repo_prefix: impl Into<String>) -> Self | binds the inner corpus (e.g. "repo:lunaris/") |
ingest_commit | async fn ingest_commit(commit_sha: impl Into<String>, committer_date_unix_ms: i64, chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError> | stamps commit_sha + committer_date_unix_ms into each chunk’s metadata, then forwards to DocumentCorpus::ingest (1 primitive call) |
recall | async fn recall(query: &str, as_of: Hlc) -> Result<Vec<Hit>, LunarisError> | 2 primitive calls: TemporalQuery::<Documents>::new + .as_of(ts).execute(query) |
Hlc’s native shape is Unix-milliseconds ({wall_ms: u64, counter: u32, node_id: u16}) — there is nowhere on the Hlc surface for RFC3339-nanos.
ingest_commit takes the committer date as an i64 of Unix-ms;
recall(..., as_of: Hlc) takes the Hlc directly, so you control the
counter / node-id disambiguation in dense-commit scenarios. Build one with
Hlc::from_parts(unix_ms as u64, 0, 0).
TemporalQueryrecalls across all Documents —CodeRepoMemorydoes not partition by repo at the recall layer. If you store more than one repo on the same handle, isolate them with distinct prefixes and a metadata filter, or use separate handles. The parity tests are fixture-isolated.
Example
Shaped after code_repo_memory_as_of_commit_50_round_trip_moon_postgres in
crates/lunaris-recipes/tests/documentary_rust_integration.rs:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_core::hlc::Hlc;
use lunaris_recipes::documentary::CodeRepoMemory;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://127.0.0.1:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-research")?;
let repo = CodeRepoMemory::new(lunaris.clone(), scope.clone(), "repo:lunaris/");
// Commit A — first version of the function.
let commit_a_ms: i64 = 1_715_000_000_000; // committer date, Unix-ms
repo.ingest_commit(
"a1b2c3",
commit_a_ms,
vec![(
"fn recall(query: &str) -> Vec<Hit> { /* v1 */ }".to_string(),
serde_json::Map::from_iter([("path".to_string(), serde_json::json!("src/recall.rs"))]),
)],
).await?;
// Commit B — the function changed.
let commit_b_ms: i64 = 1_715_600_000_000;
repo.ingest_commit(
"d4e5f6",
commit_b_ms,
vec![(
"fn recall(query: &str, as_of: Hlc) -> Vec<Hit> { /* v2 */ }".to_string(),
serde_json::Map::from_iter([("path".to_string(), serde_json::json!("src/recall.rs"))]),
)],
).await?;
// Time-travel: what did `recall` look like at commit A's timestamp?
let as_of_a = Hlc::from_parts(commit_a_ms as u64, 0, 0);
let hits = repo.recall("recall function signature", as_of_a).await?;
for h in &hits {
println!("source={} text={}", h.source, h.text);
}
Ok(())
}
Notes
recallhas no builder —as_ofis a positionalHlcargument. UseHlc::from_parts(unix_ms as u64, 0, 0)(or capture a causal timestamp fromlunaris.clock().tick()).ingest_commitis oneatomic_writeper chunk, batched per invocation — preserves the INGEST-04 contract at the per-chunk grain.- Graph posture — only
ResearchPaperCorpusexposes a graph toggle;CodeRepoMemorydoes not. For commit-graph-style traversal, drop to theLunarishandle and composeGraph::anchored(...)yourself — see The Graph Pipeline. - For a corpus without either citation graph or time-travel, use
DocumentKnowledgeBase.
Timeline Reconstruction
Reach for TimelineReconstruction when you have a stream of dated events
and want to stitch a narrative for a time window — “what happened between
Jan 10 and Jan 16” or “what did the timeline look like as of Jan 13”.
TimelineReconstruction
(crates/lunaris-recipes/src/documentary/timeline_reconstruction.rs) is a
deliberately thin two-call composition of
DocumentCorpus
(ingest) and TemporalQuery<Documents>
(recall). Its value is discoverability as a named recipe, not code volume.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Self | binds the inner corpus (e.g. "timeline:events/") |
ingest | async fn ingest(events: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError> | forwards to DocumentCorpus::ingest (1 primitive call) |
between | async fn between(query: &str, lo: Hlc, hi: Hlc) -> Result<Vec<Hit>, LunarisError> | events in [lo, hi) — lower inclusive, upper exclusive; 2 primitive calls (TemporalQuery::<Documents>::new + .between(lo, hi).execute(query)) |
as_of | async fn as_of(query: &str, ts: Hlc) -> Result<Vec<Hit>, LunarisError> | the snapshot at ts; 2 primitive calls (TemporalQuery::<Documents>::new + .as_of(ts).execute(query)) |
The boundary gotcha
between is lower-bound inclusive, upper-bound exclusive — the Phase
9.1 renderer emits @valid_time:[lo hi]
(crates/lunaris-recipes/src/documentary/timeline_reconstruction.rs:15-19).
To include “days 10 through 15 inclusive” (six days), pass hi = Jan 16 00:00:00Z, not hi = Jan 15. This carries straight into the Python / TS
parity tests too — same convention everywhere.
Hlc’s native shape is Unix-milliseconds; build bounds with
Hlc::from_parts(unix_ms as u64, 0, 0).
Example
Shaped after timeline_reconstruction_between_returns_exactly_6_events in
crates/lunaris-recipes/tests/documentary_rust_integration.rs:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_core::hlc::Hlc;
use lunaris_recipes::documentary::TimelineReconstruction;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-ops")?;
let timeline = TimelineReconstruction::new(lunaris.clone(), scope.clone(), "timeline:events/");
// Ingest dated events. Stamp the event's valid time into metadata so
// your own queries can filter on it; the bi-temporal `valid_from` the
// backend stamps drives `.between` / `.as_of`.
let events = vec![
(
"Deploy 0.2.3 shipped to 5% of traffic.".to_string(),
serde_json::Map::from_iter([
("event_id".to_string(), serde_json::json!("e-001")),
("event_valid_time_unix_ms".to_string(), serde_json::json!(1_736_467_200_000_i64)), // 2025-01-10
]),
),
(
"Deploy 0.2.3 rolled back after error spike.".to_string(),
serde_json::Map::from_iter([
("event_id".to_string(), serde_json::json!("e-002")),
("event_valid_time_unix_ms".to_string(), serde_json::json!(1_736_726_400_000_i64)), // 2025-01-13
]),
),
];
timeline.ingest(events).await?;
// "What happened between Jan 10 and Jan 16?" — note hi is Jan 16, so
// Jan 10..=Jan 15 are all included.
let lo = Hlc::from_parts(1_736_467_200_000, 0, 0); // 2025-01-10T00:00:00Z
let hi = Hlc::from_parts(1_737_072_000_000, 0, 0); // 2025-01-16T00:00:00Z (exclusive)
let window = timeline.between("deploy 0.2.3", lo, hi).await?;
println!("events in [Jan 10, Jan 16): {}", window.len());
// "What did the timeline look like as of Jan 13?"
let as_of_jan13 = Hlc::from_parts(1_736_726_400_000, 0, 0);
let snapshot = timeline.as_of("deploy 0.2.3", as_of_jan13).await?;
println!("snapshot hits: {}", snapshot.len());
Ok(())
}
The recipe tests assert the returned set for both between and as_of
against a live Moon; moon://host:port is the only URL scheme 0.7.0
accepts.
Notes
- Always add a day to
hiif you mean an inclusive upper bound. This is the single doc-worthy footgun of this recipe. betweenpanics iflo > hi— the bound check lives inTemporalQuery::between(check_between_bounds). Equal endpoints are allowed (empty-or-single-instant window).- No metadata filter on the recipe itself —
TemporalQuery<Documents>recalls across all Documents on the handle. Isolate distinct timelines with distinct prefixes (and a metadata filter via the underlyingDocumentCorpus) or separate handles. - For point-in-time recall over code rather than generic events, see
CodeRepoMemory. For the bi-temporal MVCC model underneath, see Durability & Recovery.
Customer Support History
Reach for CustomerSupportHistory when your support data is split across
two shapes — ticket bodies and chat transcripts — and you want one
recall call that returns hits from both.
CustomerSupportHistory
(crates/lunaris-recipes/src/documentary/customer_support_history.rs)
composes a DocumentCorpus
for ticket bodies (source = "ticket:<ulid>", ticket_id lands in metadata)
and a MessageStream
for chat transcripts (source = "chat:<ticket_id>/<turn_idx>/"), plus an opt-in
graph pipeline toggle for a product/customer
relationship graph.
| Method | Signature | Notes |
|---|---|---|
new | fn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Self | no prefix arg — prefixes ticket: and chat: are hard-coded |
with_graph_pipeline | fn with_graph_pipeline(self, on: bool) -> Self | enable() / disable() on the graph handle; builder-style; consumes self; default OFF |
ingest_ticket | async fn ingest_ticket(ticket_id: impl Into<String>, body: impl Into<String>) -> Result<(), LunarisError> | ticket_id is stamped into metadata; 1 primitive call (DocumentCorpus::ingest) |
ingest_chat | async fn ingest_chat(ticket_id: impl Into<String>, turn_idx: usize, participant: impl Into<String>, msg: impl Into<String>) -> Result<Lsn, LunarisError> | turn_idx is usize; the ticket_id/turn_idx slug becomes the MessageStream thread id so chats cluster per ticket; 1 primitive call (MessageStream::ingest) |
recall | async fn recall(query: &str) -> Result<Vec<Hit>, LunarisError> | 2 primitive calls (DocumentCorpus::search + MessageStream::recall); returns the concatenation of (ticket hits, chat hits) |
How recall fuses the two buckets
It doesn’t fuse across types. RRF runs within each primitive’s own
bucket — ticket hits are RRF-fused among themselves, chat hits among
themselves — and recall returns tickets ++ chats. Ordering across the
two buckets is not normalised (tie-bucket behaviour is deferred). Each
returned hit is checked to carry its expected source prefix (ticket: vs
chat:) — a record double-indexed under both prefixes would be caught here
rather than silently collapsing duplicates.
The prefixes are hard-coded — this wrapper is a named recipe, not a general composer. If you need different prefixes, compose
DocumentCorpus
MessageStreamdirectly.
Example
Shaped after the "refund" recall scenario in
crates/lunaris-recipes/tests/documentary_rust_integration.rs:
use std::sync::Arc;
use lunaris::{Lunaris, Scope};
use lunaris_recipes::documentary::CustomerSupportHistory;
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://127.0.0.1:6380").await?);
// The partition every recipe below reads and writes in.
let scope = Scope::new("acme-support-bot")?;
let hist = CustomerSupportHistory::new(lunaris.clone(), scope.clone());
// Ticket bodies → DocumentCorpus under `ticket:<id>`.
hist.ingest_ticket("T-1042", "Customer requests a refund for order #5567 — duplicate charge.").await?;
hist.ingest_ticket("T-1043", "Shipment delayed; customer asks for partial refund.").await?;
// Chat transcripts → MessageStream under `chat:<ticket_id>/<turn>`.
hist.ingest_chat("T-1042", 0, "customer", "Hi, I was charged twice for order #5567.").await?;
hist.ingest_chat("T-1042", 1, "agent", "Apologies — I've issued a full refund, 3-5 business days.").await?;
hist.ingest_chat("T-1042", 2, "customer", "Thank you!").await?;
// One recall across BOTH buckets — returns ticket hits ++ chat hits.
let hits = hist.recall("refund for a duplicate charge").await?;
for h in &hits {
let bucket = if h.source.starts_with("ticket:") { "ticket" } else { "chat" };
println!("[{bucket}] score={:.3} source={} text={}", h.score, h.source, h.text);
}
Ok(())
}
Swap the URL for moon://localhost:6380 — the parity test asserts both
backends return the same set and that source prefixes are preserved.
Notes
turn_idxisusize, notu32— pass a plain index.recallreturns a concatenation, not a globally ranked list. If you need a single ranked stream across tickets and chats, re-rank the combinedVec<Hit>yourself (e.g. byHit::score) or use the cross-encoder reranker on a hand-composed plan — see The Retrieval DSL.- Graph is opt-in and ingest-time. Call
with_graph_pipeline(true)before ingesting if you want product/customer edges. - GDPR / retention — to purge one customer’s data, route through
Lunaris::forget(ForgetTarget::Scope(...))on the relevantticket:/chat:prefixes; see Forgetting.
Helios Scratchpad
Reach for CodingSessionMemory when an agent needs a filesystem-shaped
working store — write / read / edit / grep / ls over a
session-scoped namespace — backed by Lunaris’s bi-temporal MVCC store, with
as_of time-travel for free.
CodingSessionMemory is exported by the umbrella lunaris crate (not
lunaris-recipes) — use lunaris::{CodingSessionMemory, Lunaris, Scope, Hlc};. It
was built for Helios, Lunaris’s
first downstream consumer, which replaces deepagents’ ephemeral dict-backed
mock filesystem with a real bi-temporal store. It is a convenience over the
WorkingMemory
primitive — Lunaris doesn’t know Helios exists; the recipe is not a coupling.
This chapter is the public-facing recipe summary. For the full integration story — multi-session servers, GDPR purge, graph-aware entity recall, degraded-state handling, dual-backend portability, and a production checklist — see
docs/helios-integration.md.
The frozen 9-method surface
CodingSessionMemory holds an Arc<Lunaris> + a session_prefix (e.g.
"helios:fs/session-42/") + a delegated WorkingMemory (itself
Arc<Lunaris> + String), and is Clone (every field is cheap). Its
public surface is frozen at nine symbols — the
helios_scratchpad_public_surface_under_50_loc test asserts exactly nine
at compile time, so the surface can neither grow nor shrink without an
HELIOS-* requirement update:
| # | Method | Signature |
|---|---|---|
| 1 | new | fn new(lunaris: Arc<Lunaris>, scope: Scope, session_id: &str) -> Self |
| 2 | write | async fn write(path: &str, content: impl Into<String>) -> Result<Lsn, LunarisError> |
| 3 | read | async fn read(path: &str) -> Result<Option<String>, LunarisError> |
| 4 | edit | async fn edit(path: &str, _old: &str, new: &str) -> Result<Lsn, LunarisError> |
| 5 | grep | async fn grep(pattern: &str, k: usize) -> Result<Vec<Hit>, LunarisError> |
| 6 | ls | async fn ls(prefix: Option<&str>) -> Result<Vec<String>, LunarisError> |
| 7 | forget | async fn forget() -> Result<ForgetReceipt, LunarisError> |
| 8 | as_of | fn as_of(ts: Hlc) -> AsOfScratchpad<'_> |
| 9 | AsOfScratchpad::read | async fn read(path: &str) -> Result<Option<String>, LunarisError> |
A few load-bearing facts:
newis pure — no I/O. The session prefix ishelios:fs/<session_id>/, frozen by convention. The first storage round-trip happens on the firstwrite/read/grep/ls/forget. Use aUlid(or UUIDv7) for the session id in multi-session servers — two pads with the same id co-mingle and oneforget()wipes both.write/readroute throughWorkingMemory— the contentStringis wrapped asserde_json::Value::String(...)on write and unwrapped on read.readreturnsNonefor “never written / already purged” (notSome("")); for large payloads that the chunker split,readfalls back to a multi-chunk reconstruction path that concatenates up to 8 hits.editis a plainwriteof the new content._oldis accepted for Helios’s Read/Edit symmetry but unused — MVCC supersede stamps the prior version’sbt.sys[1]automatically when the new ingest commits. No history is overwritten in place;pad.as_of(pre_edit_ts).read(path)returns the pre-edit bytes.grepis hybrid recall (Vector + Keyword(BM25) + RRF + rerankperLunaris::recalldefaults) scoped to thehelios:fs/<sid>/prefix viaFilter::StartsWith— never a SQL wildcard fragment. It surfacesHit::degradedper hit when the verifier queue is backed up; the agent UX decides what to do with that flag.forget()is soft-delete only. It lowers toForgetTarget::Scope(ScopeSpec::BySource(session_prefix))with default options — an MVCC supersede that stampsbt.sys[1]; rows are still physically present and return fromread_as_of(ts)for anytsbefore the delete. There is nopad.hard_forget()— for GDPR-irreversible purge you drop to theLunarishandle’s two-stepconfirm_hard_forgetrail. See Forgetting.as_ofreturns a borrowed, read-only view.AsOfScratchpad<'a>holds&CodingSessionMemoryso the borrow checker stops you moving the pad while a time-travel view is alive. Its only method isread(path). There is no historicalwrite/edit/grep/forget.
Everything else — graph-aware recall, dry-run forget, hard-delete
confirmation, verifier queue tuning — drops one level to the Lunaris
handle itself. The recipe is intentionally narrow.
Example — basic session lifecycle
use std::sync::Arc;
use lunaris::{CodingSessionMemory, Lunaris, Scope};
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
// One handle per process — share via Arc. URL scheme picks the backend.
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// Session prefix becomes "helios:fs/session-42/".
let scope = Scope::new("helios")?;
let pad = CodingSessionMemory::new(lunaris.clone(), scope, "session-42");
// Write two docs.
pad.write("notes.md", "# Notes\nFirst draft.").await?;
pad.write("todo.md", "- [ ] finish draft").await?;
// Read back. `read` returns `Option<String>` — `None` means no hit.
let notes = pad.read("notes.md").await?;
assert!(notes.is_some());
// Edit — `_old` is accepted for symmetry but unused; MVCC supersedes
// the prior version automatically.
pad.edit("notes.md", "First draft.", "# Notes\nSecond draft.").await?;
// Hybrid recall over this session's namespace.
let hits = pad.grep("draft", 5).await?;
for h in &hits {
println!("source={} score={:.3} degraded={}", h.source, h.score, h.degraded);
}
// List stored paths (session-prefix stripped, sorted, deduped).
let paths = pad.ls(None).await?;
println!("session paths: {paths:?}");
// End of session: soft-delete every primitive under the session prefix.
let _receipt = pad.forget().await?;
Ok(())
}
Example — time-travel debugging
use std::sync::Arc;
use lunaris::{CodingSessionMemory, Hlc, Lunaris, Scope};
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
let scope = Scope::new("helios")?;
let pad = CodingSessionMemory::new(lunaris.clone(), scope, "session-42");
// t1: agent writes the first draft.
pad.write("plan.md", "Plan v1: go left").await?;
// Capture a causal timestamp at the decision point. `HlcClock::tick()`
// (via `lunaris.clock()`) is the only monotonic-now source — `Hlc::now()`
// does not exist.
let decision_hlc: Hlc = lunaris.clock().tick();
// t2: agent edits the plan.
pad.edit("plan.md", "Plan v1: go left", "Plan v2: go right").await?;
// The live read sees the latest version.
let latest = pad.read("plan.md").await?;
assert_eq!(latest.as_deref(), Some("Plan v2: go right"));
// ... but the time-travel view reads the state as-of t1.
let as_of_view = pad.as_of(decision_hlc);
let historical = as_of_view.read("plan.md").await?;
assert_eq!(historical.as_deref(), Some("Plan v1: go left"));
Ok(())
}
Notes
- Resuming a session needs no load step.
CodingSessionMemory::new(handle, session_id)is pure (no I/O) — it just builds the"helios:fs/<session_id>/"prefix. A later process that reconstructs the pad with the samesession_idsees every priorwrite/edit(the data lives in the durable backend keyspacelunaris:{scope}:{kind}:{ulid}); apad.as_of(ts)view still reads any historical state. Same id ⇒ same pad — which is also why two pads with the same id co-mingle. CodingSessionMemoryis the recipe;WorkingMemoryis the primitive. If you want a JSON-valued scratchpad rather than a string-valued filesystem, useWorkingMemorydirectly. If you want consolidator promotion of hot notes, that is toggled per-scope on the consolidator pipeline (lunaris.consolidator_pipeline()...) —CodingSessionMemoryitself adds noconsolidatemethod.- Backend —
moon://host:portis the only selector as of 0.7.0. The latency budget is Moon recall p50 ≤ 25 ms; see The Storage Backend. - For everything beyond the basics — multi-session servers, hard
delete, graph-aware entity recall, degraded-state handling, the
production checklist — read
docs/helios-integration.md.
Configuration Reference
Lunaris is configured along four axes:
- Cargo feature flags — chosen at build time; decide which embedder / reranker / extractor / verifier backends are compiled in.
- Environment variables (
LUNARIS_*) — read at runtime, mostly whenLunaris::openresolves the default pipeline. CLI flags on thelunaris-serverbinary override the matching env var (12-factor; see CONTEXT.md D-26). - Builder / pipeline toggles — programmatic switches on the
Lunarishandle (graph pipeline on/off, verifier on/off, per-scope enablement). - The storage URL scheme —
moon://…is the only one 0.7.0 accepts.
Defaults are tuned for “a Moon you run, a local embedder, graph and verifier off” — the safe production floor. Turn things on deliberately.
1. Cargo feature flags
lunaris (umbrella crate)
v0.6 llama.cpp-only cutover. The candle inference stack (
native,embedder-gguf,reranker-gguf,verify-small,verify-large,cpu-accelerate,cpu-mkl,cuda-fa2) is deleted.llamacppis the only local embed/rerank runtime and is on by default; it needs cmake + a C++ toolchain. Seedocs/decisions/2026-07-10-llamacpp-only-cutover.md(the cutover ADR) anddocs/migration/0.5-to-0.6-llamacpp-only.md(the migration guide).
| Feature | Default | Effect |
|---|---|---|
llamacpp | ✅ | In-process llama.cpp embedder (LlamaCppEmbedder, granite-r2 Q4_K_M GGUF) + reranker (LlamaCppReranker, bge-reranker-v2-m3 Q5_K_M GGUF) |
metal | GPU offload on Apple Silicon — forwards to llama-cpp-2 | |
cuda | GPU offload via CUDA — forwards to llama-cpp-2 | |
vulkan | GPU offload via Vulkan — forwards to llama-cpp-2 | |
embed-remote | Ollama HTTP embedder escape hatch (operator-only); activated by LUNARIS_EMBEDDER_OLLAMA_URL; resolves after the llamacpp step | |
ollama | OllamaExtractor (with_extractor) / Ollama HTTP verifier backend selector (NOT the embedder) | |
cloud-api | Cloud-API extractor / verifier backends (pulls reqwest) |
default = ["llamacpp"]. CPU is the default device; device selection is a
runtime probe, overridden by LUNARIS_DEVICE=cpu (the kill-switch that forces
zero GPU layers even on an accelerated build). Extractor and verifier are
remote-only — LUNARIS_EXTRACT_PROVIDER / LUNARIS_VERIFY_PROVIDER
(anthropic|openai|gemini|minimax|openai-compat) or a
caller-supplied with_extractor / with_verifier impl; unset resolves to
NoopExtractor/NoopVerifier. For a pure-Rust, no-C++-toolchain build
(Tier-0, small devices): default-features = false →
NoopEmbedder/NoopReranker. (The umbrella also forwards moon-it /
pg-it integration features to the storage crates.)
lunaris-llamacpp
The only local embed/rerank runtime. LlamaCppEmbedder and
LlamaCppReranker load their GGUF eagerly and raise/log a WARN +
NoopEmbedder/NoopReranker fallback on a missing or corrupt artifact —
there is no auto-download; the MCP server stages GGUFs lazily on first
recall, other deployments download them out-of-band and verify against the
canonical SHA-256s printed by cargo run -p lunaris-bench --bin stage-models -- --help.
| Feature | Default | Effect |
|---|---|---|
metal / cuda / vulkan | Forwarded from the umbrella — GPU offload via llama-cpp-2 |
lunaris-extract
| Feature | Default | Effect |
|---|---|---|
ollama | OllamaExtractor — HTTP /api/chat extractor | |
cloud-api | Cloud-API extractor (pulls reqwest) | |
extractor-it | Enables integration tests that hit a live Ollama / cloud API |
With no feature/provider active, NoopExtractor is the fallback (applies() == false). Provider selection at runtime is via LUNARIS_EXTRACT_PROVIDER.
lunaris-verify
| Feature | Default | Effect |
|---|---|---|
| (none) | ✅ | No verifier backend compiled — NoopVerifier only |
ollama | Ollama HTTP verifier | |
cloud-api | Cloud-API verifier (pulls reqwest) | |
verifier-it | Enables integration tests that hit a live Ollama / cloud API |
The candle-based verify-small (Gemma-3-270M) / verify-large (Gemma-3-27B)
tiers from RFC 0006 were deleted in the v0.6 llama.cpp-only cutover — the
verifier is remote-only (LUNARIS_VERIFY_PROVIDER) or NoopVerifier, with no
in-process model tier.
Integration-test features (never default)
lunaris-recipes / lunaris-storage-moon: moon-it. (pg-it was removed
in 0.7.0 with the Postgres backend.)
lunaris-conformance: chaos-it. lunaris-bench: budget-it.
Excluded from
cargo test --workspace:lunaris-pyandlunaris-tsarecdylibs and fail to link under the workspace test runner. Test them withmaturin+pytest/napi build+vitest, or viascripts/sdk-real-evidence.sh.
2. Environment variables
All variables below are read by Lunaris::open (or the lunaris-server
binary) unless noted. Boolean variables accept 1, true, on (case-
insensitive) for “enabled”; anything else (or unset) is “disabled”.
Backend / pipeline selection
| Variable | Values | Default | Controls |
|---|---|---|---|
LUNARIS_VERIFY_PROVIDER | anthropic|openai|gemini|minimax|openai-compat | unset | Remote verifier provider (v0.6 llama.cpp-only cutover deleted the local 270m/27b model tiers — see docs/decisions/2026-07-10-llamacpp-only-cutover.md). Set-but-broken logs a tracing::warn! and falls back to NoopVerifier; unset is also NoopVerifier. |
LUNARIS_CONSOLIDATOR_BACKEND | actr | noop | actr | ACT-R consolidator vs no-op |
LUNARIS_GRAPH_ENABLED | bool | off | Entity / community graph extraction pipeline. (The graph’s name is the compile-time constant LUNARIS_GRAPH_NAME = "lunaris_graph" — not an env var.) |
LUNARIS_RAPTOR_ENABLED | bool | off | RAPTOR community-tree write at ingest (crates/lunaris-ingest/src/pipeline.rs). When on, every ingest builds a hierarchical community tree over the document’s headings, summarises each node, embeds the summaries and writes 2 × N extra ops into the communities index. Nothing on any default recall path reads that index — production_root is chunks_leg + facts_leg only, and communities is queried solely by the opt-in .tree(..) DSL operator. Leave it off unless you drive .tree(..) yourself. Independent of LUNARIS_GRAPH_ENABLED; see docs/decisions/2026-08-21-gate-raptor-community-write.md. |
LUNARIS_VERIFY_ENABLED | bool | off | Slow-path arbitration verifier pipeline |
LUNARIS_CONSOLIDATE_ENABLED | bool | off | Consolidation pipeline |
LUNARIS_RECALL_RERANK | bool | off | Opt-in cross-encoder rerank stage on the production recall root (applies to MCP memory.recall and HTTP /v1/recall / SDK Lunaris::recall(); the hook’s context-injection hot path never reranks). Read ONCE at handle construction, like LUNARIS_GRAPH_ENABLED. |
LUNARIS_RECALL_RERANK_TOP_IN | positive int | 2*k | Candidate-pool depth fed to the cross-encoder when LUNARIS_RECALL_RERANK is on. Clamped to at least the final top-k; 0 / non-numeric falls back to the default. |
The production recall pipeline (GA-1)
Every production surface builds ONE canonical recall root,
lunaris_retrieve::production_root(k, graph_enabled):
- graph-OFF:
Vector("chunks",k) ∧ BM25("chunks",k) → fuse_rrf(60) → top(k) - graph-ON: the same chunks legs fused with the fact legs
(
Navigate("entities",k, fallback "facts") ∧ BM25("facts",k)), thentop(k)
Per-surface deltas on top of that shared root:
| Surface | Fact legs | Activation boost | Cross-encoder rerank |
|---|---|---|---|
MCP memory.recall (+contextd) | with LUNARIS_GRAPH_ENABLED | yes (LUNARIS_ACTIVATION_BOOST, default on) | opt-in via LUNARIS_RECALL_RERANK |
HTTP /v1/recall + SDK Lunaris::recall() | with LUNARIS_GRAPH_ENABLED | yes (LUNARIS_ACTIVATION_BOOST, default on) | opt-in via LUNARIS_RECALL_RERANK |
| Hook context injection | always on (LUNARIS_CONTEXT_RECALL=vector opts out) | yes (LUNARIS_ACTIVATION_BOOST, default on) | never (latency-critical path) |
Rerank is OFF by default on every surface; with it off the bge-reranker GGUF
is never loaded. When enabled, the stage runs between RRF fusion and the
final top(k) over the top LUNARIS_RECALL_RERANK_TOP_IN candidates.
Budget seconds, not milliseconds, for it — the measured cost is
p50 1301.3 ms at top_in=60 and 575.6 ms at top_in=30
(capacity §4).
It is a quality stage, not a latency-class stage, and turning it on voids
the 25 ms p50 recall contract.
Activation boost — on by default, and it changes ranking
| Variable | Default | Controls |
|---|---|---|
LUNARIS_ACTIVATION_BOOST | on (any value except 0) | Applies the ACT-R activation-ledger prior to the fused candidate set on every recall surface — MCP memory.recall, contextd, lunaris-cli, HTTP /v1/recall, hook context injection, and all three SDKs. Memories that have been referenced before rank higher than their raw hybrid score alone would place them. Set LUNARIS_ACTIVATION_BOOST=0 to opt out (crates/lunaris/src/recall.rs). |
LUNARIS_BOOST_CACHE_CAPACITY | 10000 | Entries in the in-process activation-boost lookup cache (crates/lunaris-verify/src/reflect_apply.rs). Non-numeric / 0 → default. |
Read this before you A/B anything. The boost is on by default on every surface (W1.8 — it was previously applied only on the
lunaris-memory-servicepath, so the same query could rank differently through MCP than through/v1/recall; that divergence is gone). Any benchmark, eval, or regression comparison MUST still state which value it ran with, and both arms of an A/B must run with the same one — the prior is a real ranking input, not a tiebreaker. It is also the first thing to check when recall ordering looks inexplicable in production.One consequence worth knowing before you read a flamegraph: with the boost on, every recall issues one activation-ledger point read per distinct hit — bounded by the hit set, never by the scope’s size, and pinned by
ledger_read_cost_is_one_point_read_per_distinct_hit. Callers that never write reinforcement signals still pay those reads and get an empty ledger back;LUNARIS_ACTIVATION_BOOST=0removes them entirely.
How much the boost is worth, and for how long. The prior is additive on a
hit’s fused score, bounded by BOOST_CAP (0.30, an asymptote — not a value any
real record reaches), and it decays with the age of the memory’s most recent
reference. Measured for a memory referenced ten times (weighted = 30):
| age since last reference | 10s | 1 min | 10 min | 1 h | 6 h | 1 day | 7 days |
|---|---|---|---|---|---|---|---|
| boost added to score | 0.204 | 0.183 | 0.155 | 0.133 | 0.111 | 0.096 | 0.076 |
A memory that was never referenced gets exactly 0.0 — the ledger only ever
promotes, never demotes, so an unreferenced memory keeps its raw hybrid rank.
Until the fix for F43 landed, this curve clamped negative ACT-R activation to zero, which made the prior read exactly
0.0for any memory older thanweighted²seconds — 9 seconds after a single strong reference, 15 minutes after ten. If you are reading recall traces from an older build, the boost column is expected to be zero almost everywhere; that was a defect (F43), not a tuning choice.
The llama.cpp granite-r2 embedder and bge-reranker-v2-m3 reranker are selected unconditionally when the
llamacppfeature is on (default) — there is noLUNARIS_EMBEDDER_BACKENDorLUNARIS_RERANKER_BACKENDvariable. Use the env vars below to change the GGUF path or (operators only) redirect the embedder to a remote Ollama endpoint.
Embedder and reranker details
| Variable | Default | Controls |
|---|---|---|
LUNARIS_EMBED_CACHE_CAPACITY | 2048 | Exact-text embedding LRU entries per Lunaris handle. Set 0 to disable. |
LUNARIS_EMBEDDER_GGUF | ~/.lunaris/models/granite-embedding-311m-multilingual-r2.Q4_K_M.gguf | Path to the granite-r2 Q4_K_M GGUF loaded by LlamaCppEmbedder. No auto-download — a missing/corrupt file logs a WARN and falls back to NoopEmbedder. |
LUNARIS_RERANKER_GGUF | ~/.lunaris/models/bge-reranker-v2-m3.Q5_K_M.gguf | Path to the bge-reranker-v2-m3 Q5_K_M GGUF loaded by LlamaCppReranker (lazy-loaded on first recall). Missing/corrupt file logs a WARN and falls back to NoopReranker. |
LUNARIS_DEVICE | auto | cpu is the runtime kill-switch forcing zero GPU layers even on a metal/cuda/vulkan build. |
LUNARIS_EMBEDDER_OLLAMA_URL | — | Operator escape hatch only. Routes the embedder to a remote Ollama HTTP endpoint; requires --features embed-remote; resolves after the llama.cpp step. Not the supported path. |
LUNARIS_OLLAMA_MODEL | embeddinggemma:300m | Ollama model tag for the embed-remote escape-hatch embedder |
LUNARIS_EMBEDDER_OPENAI_URL | — | Selector for the OpenAI-compatible remote embedder (lunaris-embed-remote::OpenAiEmbedder, also --features embed-remote): setting it routes embedding to that /v1/embeddings endpoint, checked ahead of the Ollama hatch (lunaris/src/handle.rs). Empty = off. |
LUNARIS_EMBEDDER_OPENAI_MODEL | text-embedding-3-small | Model id sent in the OpenAI-compatible /v1/embeddings request (lunaris-embed-remote/src/openai.rs) |
LUNARIS_EMBEDDER_OPENAI_API_KEY | — | Optional bearer token for that endpoint; empty/whitespace → no Authorization header (keyless llama-server/vLLM allowed). Redacted in Debug output. |
LUNARIS_SUPPRESS_DEGRADED_WARNING | — | Silences the one-time stderr line Lunaris::open prints when the embedder resolved to noop — every vector is zeros, so semantic recall degrades to keyword-only while every call keeps succeeding. Only 1/true/yes/on suppress; 0, false and an empty value do not. Nothing is printed when a tracing subscriber is installed, because that host already receives the WARN. Query the backend directly with embedder_backend(). |
LUNARIS_EMBED_MAX_BATCH_TOKENS | 4096 | llama.cpp batch-token window for the in-process embedder (lunaris/src/handle.rs); values < 16 rejected → default |
LUNARIS_CONTEXT_EMBED_MAX_BATCH_TOKENS | 1024 | Same knob for the interactive/contextd embedder — smaller default (~1.1 GB compute buffer vs ~2.5 GB at 4096); values < 16 rejected → default |
Verifier / extractor providers (remote-only)
| Variable | Default | Controls |
|---|---|---|
LUNARIS_EXTRACT_PROVIDER | unset | anthropic|openai|gemini|minimax|openai-compat — remote provider for the extractor backend (per-provider key is <PROVIDER>_API_KEY, e.g. ANTHROPIC_API_KEY); unset is NoopExtractor |
LUNARIS_VERIFY_API_KEY | — | Shared API key for the verifier (falls back to the provider-specific env var, e.g. OPENAI_API_KEY) |
LUNARIS_OPENAI_COMPAT_BASE_URL | — | Base URL for the openai-compat provider (Ollama / llama-server / vLLM / LM Studio); keyless allowed |
OPENAI_COMPAT_EXTRACT_MODEL | — | Model tag for the openai-compat extractor |
OPENAI_COMPAT_VERIFY_MODEL | — | Model tag for the openai-compat verifier |
OLLAMA_URL | http://localhost:11434 | Endpoint honoured by OllamaExtractor / the Ollama verifier backend (Cargo feature ollama, distinct from openai-compat) |
OLLAMA_EXTRACT_MODEL | gemma3:4b | Model tag for OllamaExtractor |
OLLAMA_VERIFY_MODEL | gemma3:27b | Model tag for the Ollama verifier backend |
A provider that is set but fails to construct (bad URL, missing key) logs a
tracing::warn! and degrades to NoopExtractor/NoopVerifier — never a
silent backend swap.
Workspace-wide LLM defaults (lunaris-llm, src/config.rs) — consulted
only when the per-pipeline variable (LUNARIS_EXTRACT_PROVIDER /
LUNARIS_VERIFY_PROVIDER / LUNARIS_REFLECT_PROVIDER) is unset. Precedence:
per-pipeline env → workspace env → TOML file → built-in default.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_LLM_PROVIDER | ollama (built-in fallback pair is ollama + gemma3:4b) | Default provider for the extract/verify/reflect pipelines: ollama|anthropic|openai|gemini|openai-compat; unknown value is a hard UnknownProvider error |
LUNARIS_LLM_MODEL | gemma3:4b | Default model id for those pipelines |
LUNARIS_LLM_CONFIG | unset | Path to a TOML file with optional [default]/[extract]/[verify]/[reflect] sections (provider, model); env vars still win over file values; unreadable path is a hard error |
LUNARIS_EXTRACT_MODEL | — | Per-pipeline model override for the extractor. Beats LUNARIS_LLM_MODEL; the general form is LUNARIS_{EXTRACT,VERIFY,REFLECT}_MODEL (lunaris-llm/src/config.rs:170). |
LUNARIS_VERIFY_MODEL | — | Same, for the verifier. |
LUNARIS_REFLECT_MODEL | — | Same, for the reflect pipeline. |
LUNARIS_OPENAI_COMPAT_API_KEY | — | API key for the openai-compat provider. Keyless endpoints (llama-server, vLLM, LM Studio) may leave it unset. |
Precedence for a pipeline’s provider/model, highest first: the per-pipeline
env var (LUNARIS_EXTRACT_PROVIDER / LUNARIS_EXTRACT_MODEL) → the workspace
env var (LUNARIS_LLM_PROVIDER / LUNARIS_LLM_MODEL) → the matching section of
LUNARIS_LLM_CONFIG’s TOML → the built-in fallback pair (ollama +
gemma3:4b). Env always wins over the file.
Moon storage tuning (lunaris-storage-moon)
Read by the Moon adapter in any embedding process (server, MCP, SDKs).
| Variable | Default | Controls |
|---|---|---|
LUNARIS_MOON_OP_TIMEOUT | 10 (whole seconds — no _MS/_SECS suffix in the name) | Per-command Moon response timeout on the multiplexed connection (HSET/FT.*/TXN/PING), so a stalled Moon cannot hang ingest or recall (lunaris-storage-moon/src/client.rs). ≤ 0/unparseable → warn + default. |
LUNARIS_MOON_COMPACT_MIN | 512 | Minimum vector-upsert count in a bulk ingest before the BulkIngestComplete maintenance hint issues FT.COMPACT on the scope’s vector indexes (lunaris-storage-moon/src/vector.rs). 0/garbage → warn + default. |
LUNARIS_MOON_SNAPSHOT_EVERY_COMMIT | true | Whether every atomic_write commit also registers a TEMPORAL.SNAPSHOT_AT (lunaris-storage-moon/src/atomic.rs). Set false to save one Moon round trip per write if you never use AS_OF recall. |
LUNARIS_MOON_DISCOVERY_TIMEOUT_MS | 25 | Liveness-probe budget when a hook / MCP / CLI process resolves a Moon URL from the contextd discovery file ~/.lunaris/contextd-moon.url (lunaris-core/src/store_discovery.rs). Deliberately tiny: this runs on the startup path of a one-shot binary, where a stale discovery file must cost milliseconds, not seconds. Raise it only if your Moon is genuinely slow to answer PING; 0 is rejected (connect_timeout refuses a zero duration). |
Supervision / worker pool
| Variable | Default | Controls |
|---|---|---|
LUNARIS_SCOPE_CONCURRENCY | 8 | Max concurrent message-process tasks per scope |
LUNARIS_SCOPE_IDLE_TIMEOUT_MS | 1800000 (30 min) | Idle-scope worker eviction timeout |
LUNARIS_WORKER_DRAIN_MS | 5000 (5 s) | Graceful drain window when a scope worker shuts down |
LUNARIS_CONSOLIDATE_DEBOUNCE_MS | 60000 (60 s) | Debounce window the consolidation worker waits before flushing a batch of episode events to consolidate_scoped (lunaris-consolidate/src/worker.rs) |
Ingest + embedding batching
The embedder batches by token window, not by row count, and the two families
of knob are independent: *_BATCH_TOKENS sizes the llama.cpp compute buffer,
*_BATCH* sizes how many items the caller hands over at once.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_EMBED_MAX_BATCH_TOKENS | 4096 | llama.cpp batch-token window for the in-process embedder (lunaris/src/handle.rs:2105). Values < 16 are rejected → default. This is the main RSS lever: ~2.5 GB compute buffer at 4096. |
LUNARIS_CONTEXT_EMBED_MAX_BATCH_TOKENS | 1024 | The same knob for the interactive / contextd embedder. Smaller on purpose (~1.1 GB vs ~2.5 GB) — an interactive path should not hold a server-sized buffer. Values < 16 → default. |
LUNARIS_EMBED_BATCH | 32 | Rows per embed call on the ingest driver (lunaris-ingest/src/pipeline.rs). Re-read per batch, so a long-running daemon picks up changes without a restart (issue #49). Values below the built-in 32 are clamped up, not honoured — this knob can only raise the batch. |
LUNARIS_EMBED_BATCH_SIZE | 16 | Rows per embed call in the hook’s embed-promotion worker (lunaris-hook/src/embed_promotion.rs). Minimum 1. |
LUNARIS_EMBED_BATCH_WAIT_MS | 25 | How long that worker waits to accumulate a batch before flushing. |
LUNARIS_EMBED_PROMOTION_ENABLED | true | Whether hook-captured rows get embedded (promoted from keyword-only to vector-searchable) at all. |
LUNARIS_EMBED_PROMOTION_WORKER | true | Whether promotion runs on a background worker. false keeps promotion enabled but inline. |
LUNARIS_EMBED_DIM | 768 | Vector dimension asserted for the NoopEmbedder path (lunaris_core::NOOP_DEFAULT_DIM). Changing it on a store that already holds vectors re-embeds nothing. |
LUNARIS_PREWARM_CONCURRENCY | 4 | Max concurrent speculative warm-up recall tasks spawned by ScopedLunaris::end_turn. Must be a positive integer; 0 / non-numeric / unset → default. One INFO line per process records the resolved value. |
LUNARIS_GRAPH_EXTRACT_GRANULARITY | chunk | session / episode / doc make graph extraction one LLM call over the whole episode instead of one per chunk — far fewer calls, coarser entities. chunk / chunks / unset keeps the per-chunk default (lunaris/src/ingest.rs). |
Scope resolution
| Variable | Default | Controls |
|---|---|---|
LUNARIS_SCOPES_FILE | ~/.lunaris/scopes.json | Path to the JSON map from working directory to Scope, used by lunaris-hook and anything else resolving a scope from cwd (lunaris-core/src/scope_resolver.rs). Point it elsewhere for a per-project or per-machine layout. |
LUNARIS_HOOK_SCOPE | — | Hard override: skips cwd resolution entirely and uses this scope. Highest precedence (lunaris-hook/src/scope.rs). |
LUNARIS_SCOPE | — | The --scope flag’s env fallback for lunaris-cli. |
contextd — the shared local daemon
lunaris-contextd keeps one warm embedder and one Moon connection for all the
short-lived hook / MCP / CLI processes on a machine. Everything below tunes how
those processes find it and how long they will wait.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_CONTEXTD_SOCKET | ~/.lunaris/codex-contextd.sock | Unix socket path. Read first, ahead of any store discovery — a developer running contextd would otherwise silently redirect tests and hooks (lunaris-hook/src/context.rs::default_socket_path). |
LUNARIS_CONTEXTD_EMBEDDED_MOON | enabled | Set 0 / false to stop contextd launching its own in-process Moon; it then requires an external store. |
LUNARIS_CONTEXTD_MOON_DIR | ~/.lunaris/contextd-moon-data | Data directory for that embedded Moon. An unusable directory logs a WARN and disables the embedded path rather than failing the daemon. |
LUNARIS_CLI_CONNECT_MS | 500 | How long lunaris-cli waits to connect to the contextd socket before falling back to a direct store connection (lunaris-cli/src/route.rs). |
LUNARIS_CLI_LOG | warn | Tracing filter for lunaris-cli (stderr). |
LUNARIS_CONTEXTD_AUTOSTART | 1 | Whether the Codex hook adapter starts lunaris-contextd on demand. 0 / false requires you to run it yourself. |
LUNARIS_CONTEXTD_BIN | resolved from PATH | Explicit path to the lunaris-contextd binary. |
LUNARIS_HOOK_BIN | resolved from PATH | Explicit path to the lunaris-hook binary. |
Codex hook-adapter budgets
scripts/lunaris-codex-hook-adapter.py is a thin shim that talks to contextd
over the socket above. These are its per-phase deadlines, in milliseconds; each
one degrades to “inject nothing” rather than delaying the agent.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_CONTEXT_ENABLED | on | Master switch for context injection through the adapter. Any disabling value turns injection off while leaving capture alone. |
LUNARIS_CONTEXT_TIMEOUT_MS | 300 | Budget for the prompt-phase recall round trip. |
LUNARIS_CONTEXT_POST_TOOL_TIMEOUT_MS | LUNARIS_CONTEXT_TIMEOUT_MS (300) | Budget for the post-tool-call recall. |
LUNARIS_CONTEXT_DIGEST_TIMEOUT_MS | LUNARIS_CONTEXT_TIMEOUT_MS (300) | Budget for the SessionStart digest. |
LUNARIS_CONTEXT_CAPTURE_TIMEOUT_MS | 120 | Budget for a capture write. Tighter than recall on purpose — a capture is fire-and-forget. |
LUNARIS_CONTEXT_COLD_TIMEOUT_MS | 15000 | Budget for the first request after contextd starts, which pays the model-load cost. |
LUNARIS_CONTEXT_CAPTURE_FAST | on | Fire-and-forget capture path. Turning it off makes captures synchronous — useful when debugging a capture that seems to vanish. |
LUNARIS_CONTEXT_CAPTURE_GATE | on | The signal gate that drops low-value captures. off is the kill-switch that captures everything. |
LUNARIS_HOOK_OUTPUT_TARGET | codex | Output dialect the hook emits (codex / claude). |
Hook context injection
What the Claude Code / Codex hook may inject, and the latency budgets it may spend doing it. Every timeout here is a hard budget on a user-visible path — exceeding one degrades to injecting nothing, never to blocking the turn.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_CONTEXT_RECALL | hybrid | Recall plan for context injection. vector opts out of the keyword / graph legs. |
LUNARIS_CONTEXT_MAX_CHARS | 1600 | Character budget for prompt-phase injected context. |
LUNARIS_CONTEXT_MIN_SCORE | 0.55 | Score floor for a hit to be injected at prompt phase. Raise it if injected memories feel irrelevant; lower it if the hook injects nothing. |
LUNARIS_CONTEXT_POST_TOOL_MAX_CHARS | 900 | Same budget for post-tool-call injection (falls back to LUNARIS_CONTEXT_MAX_CHARS). |
LUNARIS_CONTEXT_POST_TOOL_MIN_SCORE | 0.60 | Score floor post-tool (falls back to LUNARIS_CONTEXT_MIN_SCORE). Higher than the prompt floor on purpose — a mid-turn interruption must clear a higher bar. |
LUNARIS_CONTEXT_DIGEST_MAX_CHARS | 2000 | Character budget for the SessionStart digest. |
LUNARIS_CONTEXT_INCLUDE_TOOLCALLS | off | 1 restores raw tool-call captures to context injection at every phase. Off by default: they are substrate, not context. A census of a live store found 99.9% of injected memories were raw tool calls and two were curated, so the agent was reading its own shell history instead of its decisions. Captures are still written, still stored and still returned by memory.recall — only the automatic injection is off. |
LUNARIS_CONTEXT_PROMPT_INCLUDE_TOOLCALLS | off | Deprecated alias for the row above, still honoured. It used to lift the exclusion at the prompt phase only, which was the whole exclusion at the time; now it lifts it everywhere. Prefer LUNARIS_CONTEXT_INCLUDE_TOOLCALLS. |
LUNARIS_HOOK_CONTEXT_BUDGET_MS | 250 | Wall-clock budget for building handover context. Clamped to [10, 10000]. |
LUNARIS_HOOK_DROP_AFTER_MS | 100 | Emergency-drop deadline (HOOK-06): past this the hook warns and exits 0 rather than delaying the agent. Clamped to [10, 10000]. |
LUNARIS_TRANSCRIPT_TAIL_BYTES | 4194304 (4 MiB) | How much of a transcript file’s tail the hook reads per turn. |
LUNARIS_HOOK_INCLUDE | — | Colon-separated glob allow-list of paths the hook may capture. |
LUNARIS_HOOK_EXCLUDE | — | Colon-separated glob deny-list, applied on top of the built-in defaults rather than replacing them. |
The LUNARIS_CODEX_* spellings (LUNARIS_CODEX_CONTEXT_MAX_CHARS,
…_CONTEXT_MIN_SCORE, …_POST_TOOL_*) are legacy aliases consulted after
the LUNARIS_CONTEXT_* names above. Prefer the unprefixed names; the Codex
ones exist so older Codex hook configs keep working.
Recall degradation signal
| Variable | Default | Controls |
|---|---|---|
LUNARIS_VERIFY_QUEUE_WARN_THRESHOLD | 1000 | recall_with_degraded_check() flags every returned hit degraded = true when the verifier queue depth exceeds this at recall start (lunaris/src/recall.rs). Lower it for earlier warning that verification is falling behind. |
Logging
There is no LUNARIS_LOG variable — the library/server filter is the
standard RUST_LOG; the auxiliary binaries each have their own clap-backed
filter var.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_ENV | — | production selects the JSON tracing subscriber; otherwise pretty. Also auto-selects JSON when stdout is not a TTY. (lunaris::init_logging() / lunaris::logging::init()) |
RUST_LOG | info when unset | Standard tracing-subscriber env filter for the library / lunaris-server |
LUNARIS_HOOK_LOG | warn | Tracing filter for the lunaris-hook binary (lunaris-hook/src/main.rs) |
LUNARIS_HOOK_LOG_JSON | unset | 1 switches hook logs to JSON lines |
LUNARIS_CONTEXTD_LOG | warn | Tracing filter for the lunaris-contextd daemon (lunaris-hook/src/contextd.rs) |
LUNARIS_MCP_LOG | info,rmcp=warn | Tracing filter for lunaris-mcp (stderr — stdout is the MCP transport) |
HTTP server (lunaris-server)
crates/lunaris-server/src/config.rs — every var has a matching CLI flag
that takes precedence.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_BIND | 0.0.0.0:8080 | Listen address |
LUNARIS_STORAGE | (required) | Storage URL — moon://host:port. No default; no other scheme is accepted. |
LUNARIS_TOKENS_FILE | (required) | Path to the bearer-token map JSON (see below) |
LUNARIS_RATE_PER_SECOND | 60 | Per-tenant sustained request rate |
LUNARIS_RATE_BURST | 120 | Per-tenant burst budget |
LUNARIS_CORS_ORIGINS | * | CORS allow-list — * or a comma-separated list. Set an explicit list if browsers talk to your deployment (Security & Hardening). |
LUNARIS_SHUTDOWN_GRACE_SECS | 30 | Graceful-shutdown drain window |
LUNARIS_HTTP_TIMEOUT_SECS | 30 | Per-request wall-clock budget; exceeding it returns 408. Covers producing the response, not streaming an SSE body. 0 disables (bound requests at your proxy instead). |
LUNARIS_HTTP_CONCURRENCY | 256 | Max concurrently-served requests; arrivals beyond the cap are shed immediately (503 + Retry-After), never queued. 0 disables the limit. |
Plus the --metrics-disabled CLI flag, which removes the /metrics
endpoint. The per-command Moon timeout the server inherits is
LUNARIS_MOON_OP_TIMEOUT — a storage-layer variable, not a
lunaris-server flag (see Moon storage tuning above).
Bearer-token map format (LUNARIS_TOKENS_FILE, D-07):
{
"<opaque-bearer-token>": { "tenant": "acme", "scopes": ["ingest", "recall", "forget"] },
"<another-token>": { "tenant": "globex", "scopes": ["recall"] }
}
tenantis the partition scope for the token (typed asScope, validated against[A-Za-z0-9_\-.]{1,128}) — and the only source of truth for it. Route handlers ignore anyscope/tenantfield on the request body; every public DTO carries#[serde(deny_unknown_fields)], so a body that contains such a field is rejected (HTTP 422).scopesis the verb-permission set — which ofingest/recall/forgetthis token may call. A request whose route requires a verb not in this list is403.- A missing or invalid token is
401.
MCP server (lunaris-mcp)
Every var has a matching CLI flag (clap) that takes precedence. Storage resolution is explicit → contextd-advertised → refuse-to-boot; see MCP Server.
| Variable | Default | Controls |
|---|---|---|
LUNARIS_MCP_STORAGE | unset (no default since 0.7.0) | Storage URL (moon://host:port). Unset → adopt a live lunaris-contextd-advertised store, else refuse to boot with the external-Moon quickstart. Must match contextd’s store or the two write to different Moons. |
LUNARIS_MCP_SCOPE | unset | Overrides the auto-derived memory scope (git remote + branch, else cwd hash) |
LUNARIS_MODELS_DIR | ~/.lunaris/models | Where staged GGUFs live. Moves the staging target and the engine’s lookup together. To use one specific file rather than a different directory, set LUNARIS_EMBEDDER_GGUF instead. |
LUNARIS_MCP_MODELS_DIR | — | Predecessor of LUNARIS_MODELS_DIR; still honoured. Before v0.7.2 only the MCP stager read it, so setting it staged into a directory the engine did not consult. |
LUNARIS_MCP_SKIP_STAGE | unset | Presence-only: skip lazy GGUF staging on first recall (CI / operator override) |
LUNARIS_MCP_DISABLE_CONTEXTD | unset | Presence-only: serve every op Direct instead of proxying to the warm lunaris-contextd daemon |
LUNARIS_MCP_CONTEXTD_CONNECT_MS | 500 | Cold-start budget for connecting to contextd’s socket before falling back to Direct |
LUNARIS_MCP_CONTEXTD_BREAKER_N | 3 | Consecutive contextd failures before the circuit breaker opens (ops go Direct) |
(LUNARIS_MCP_LOG is in the Logging table above.)
Hooks & context injection (lunaris-hook / lunaris-contextd)
| Variable | Default | Controls |
|---|---|---|
LUNARIS_STORE_URL | unset | The hook binary’s storage URL (moon://host:port). Unset → adopt a live contextd-advertised store, else hard-error with the external-Moon quickstart (lunaris-hook/src/scope.rs). This is the real name — LUNARIS_HOOK_STORAGE does not exist. |
LUNARIS_CONTEXT_RECALL | hybrid | vector forces the legacy vector-only recall path for context injection; anything else is hybrid (vector + BM25 RRF, degrading to vector on failure) |
LUNARIS_CONTEXT_RECALL_TIMEOUT_MS | 1500 | Deadline on the hybrid retrieve; timeout degrades to the vector path |
LUNARIS_CONTEXT_MAX_HITS / _MIN_SCORE / _MAX_CHARS | 5 / 0.55 / 1600 | Prompt-phase injection budget: max memories, min cosine score, char cap |
LUNARIS_CONTEXT_POST_TOOL_MAX_HITS / _MIN_SCORE / _MAX_CHARS | 3 / 0.60 / 900 | Post-tool-call injection budget |
LUNARIS_CONTEXT_DIGEST_MAX_HITS / _MAX_CHARS | 8 / 2000 | SessionStart digest budget |
LUNARIS_CONTEXT_INCLUDE_TOOLCALLS | off | 1/true re-includes raw tool-call captures in context injection at every phase (excluded by default — substrate, not context) |
LUNARIS_CONTEXT_PROMPT_INCLUDE_TOOLCALLS | off | Deprecated alias for the row above; still honoured |
LUNARIS_CONTEXT_EMBED_CACHE_MAX | 256 | Max entries in contextd’s query-embedding cache (cleared wholesale when full, not LRU) |
LUNARIS_CONTEXT_PROFILE | off | Exactly 1 (not true) emits latency breadcrumbs for recall / embedding / promotion |
LUNARIS_INFER_WATCHDOG_MS | 120000 | Per-inference-call timeout in contextd; a timed-out call fails only that request (recall fail-opens) |
LUNARIS_INFER_WATCHDOG_TRIP | 2 | Consecutive inference timeouts that count as “wedged, not slow” — trips the wedge policy (contextd exits 70; hooks respawn it) |
LUNARIS_DREAM_NUDGE_THRESHOLD | 5 | Ripe-memory count at which the SessionStart digest nudges “run /dream”; 0 disables the check |
LUNARIS_SESSIONS_FILE | ~/.lunaris/sessions.json | Where session markers / session pads persist (tests + non-default homes) |
LUNARIS_CODEX_CONTEXT_* / LUNARIS_CODEX_POST_TOOL_* | same defaults | Lowest-priority aliases of the matching LUNARIS_CONTEXT_* knobs — consulted only when the generic var is unset/unparseable, for any client (not Codex-detected) |
LUNARIS_DREAM_CRONandLUNARIS_DREAM_PIGGYBACKappear in the/dreamskill docs as v2 stubs but are not read by any code yet — setting them does nothing today.
Bench-only variables
The LUNARIS_EVAL_* family (plus the bench harness knobs) configures the
LongMemEval / PersonaMem benchmark rigs only — never a production process.
They are documented next to the harnesses:
scripts/bench/lme/README.md
and
scripts/bench/pm/README.md.
Integration-test probes (not for production)
| Variable | Example | Used by |
|---|---|---|
MOON_URL | moon://localhost:6390 | #[cfg(feature = "moon-it")] tests |
LUNARIS_MOON_URL | moon://127.0.0.1:6380 (default in the storage-moon live tests) | Live-Moon integration/conformance tests only; unset → those #[ignore]d tests skip. Production code never reads it. |
MOON_TEST_BINARY | /path/to/moon | lunaris-test-harness — the moon binary it spawns per fixture. Without it (and without vendor/moon/target/{release,debug}/moon) the harness panics; there is no in-memory fallback since 0.7.0. |
Point these at a dedicated Moon. Never at a store you care about — the fixtures own their instance and clear it.
3. Builder / pipeline toggles (programmatic)
Each opt-in pipeline exposes a handle on Lunaris plus an env-seeded initial
state:
| Pipeline | Handle | Env seed | Runtime control |
|---|---|---|---|
| Graph | GraphPipelineHandle (lunaris::graph_pipeline) | LUNARIS_GRAPH_ENABLED (GRAPH_ENABLED_ENV_VAR) | .enable() / .disable() |
| Verify | VerifierPipelineHandle (lunaris::verify_pipeline) | LUNARIS_VERIFY_ENABLED (VERIFY_ENABLED_ENV_VAR) | .enable() / .disable() |
| Consolidate | ConsolidatorPipelineHandle (lunaris::consolidator_pipeline) | LUNARIS_CONSOLIDATE_ENABLED (CONSOLIDATE_ENABLED_ENV_VAR) + LUNARIS_CONSOLIDATOR_BACKEND | .enable() / .disable() / .enable_for_scope(prefix) (source-prefix filter) |
The handles are obtained from the Lunaris value after open; see
Guides → Consolidation & Verification and
Guides → The Graph Pipeline.
4. Storage URL scheme
Lunaris::open(url) / lunaris::open(url) dispatch on the scheme:
| Scheme | Backend | Notes |
|---|---|---|
moon://host:port | Moon (Redis-compatible) | Native FT.SEARCH vector + BM25, GRAPH.QUERY, message queue, native RRF fusion. The adapter creates its chunks FT index at the configured embedder’s dimension (default 768; Lunaris::open passes embedder.dim(), MoonStorage::connect_with_dim sets it directly) — Moon itself has no dim cap. Start Moon with --shards 1: an ingest is one MULTI/EXEC transaction and a sharded Moon rejects it. |
postgres:// / postgresql:// / memory:// / sqlite:///path | — | Removed in 0.7.0. StorageError::UnsupportedScheme, with the message naming docs/migration/0.6-to-0.7.md. |
| anything else | — | StorageError::UnsupportedScheme |
There is no schema migration and no role bootstrap to run — per-scope
keyspaces, FT indices, GRAPH keys, and MQ topics are created lazily on the
first atomic_write per scope.
See also Operations → The Storage Backend.
API Reference (docs.rs)
The exhaustive, type-level API reference is the generated rustdoc — this book covers the how and why; rustdoc covers every type, trait, method, and signature.
- Online (per release):
https://docs.rs/lunaris-memory(the umbrella crate is published aslunaris-memory; its library name islunaris) — built fromcargo docwith the documentation feature set (see each crate’s[package.metadata.docs.rs]). - Locally:
cargo doc --workspace --no-deps --open(ormake docs-rust).
The lunaris umbrella crate
lunaris re-exports the surface you normally touch. For the common subset,
glob-import the prelude:
use lunaris::{EpisodeBuilder, ForgetTarget, Graph, Hit, Keyword, Lunaris, Query, Scope, ScopeSpec, Tree, Vector};
async fn demo() -> Result<(), lunaris::LunarisError> {
use lunaris::prelude::*;
// → Lunaris, ScopedLunaris, Scope, EpisodeBuilder, Query, Hit,
// Vector, Keyword, Graph, Tree, RetrievalBuilder, ForgetTarget, ScopeSpec,
// LunarisError, Embedder, HlcClock,
// Reranker/NoopReranker, Extractor/NoopExtractor,
// Verifier/NoopVerifier, Consolidator/NoopConsolidator
Ok(())
}
The prelude is intentionally small. Reach into the full re-export list (or the
member crates directly) for everything else — extractor/verifier backend
structs, the storage concrete (MoonStorage), the recipe
types, init_logging, the pipeline handles, etc.
Crate map
| Crate | Role |
|---|---|
lunaris | Umbrella: Lunaris / ScopedLunaris handles, open() URL dispatcher, the ingest hot path, re-exports, prelude |
lunaris-core | Shared primitives (Episode/Chunk/Entity/Fact/Relation/Community), StoragePort trait, HLC clock, Scope newtype, keyspace, error taxonomy, circuit breaker |
lunaris-ingest | Markdown chunker + batched embedder driver + the single atomic_write |
lunaris-retrieve | The retrieval DSL (Vector/Keyword/Graph/Tree, combinators, RRF fusion, rerank, fallback), tower::Service-shaped |
lunaris-extract | Entity/relation/fact extractor (remote-only: Ollama / cloud-API providers) + validator |
lunaris-consolidate | ACT-R consolidator (Anderson 1996; Leiden communities) — opt-in |
lunaris-verify | Slow-path arbitration verifier (remote-only providers) + MVCC supersede writer — opt-in |
lunaris-llamacpp | Embedder + Reranker impls — in-process llama.cpp (LlamaCppEmbedder: granite-r2 Q4_K_M GGUF; LlamaCppReranker: bge-reranker-v2-m3 Q5_K_M GGUF); default-enabled llamacpp feature on the umbrella |
lunaris-embed-remote | Embedder impl — Ollama HTTP escape hatch (--features embed-remote); resolves after the llama.cpp step |
lunaris-rerank | Reranker trait + NoopReranker (the cross-encoder impl lives in lunaris-llamacpp) |
lunaris-storage-moon | StoragePort on a Redis-compatible substrate (native FT.*, GRAPH.QUERY, MQ, RRF) |
lunaris-server | HTTP + SSE MemoryProtocol 0.1 server (axum) |
lunaris-recipes | Recipe primitives + conversational / documentary wrappers (see Cookbook) |
lunaris-py / lunaris-ts | PyO3 / napi-rs bindings (see SDKs) |
lunaris-codegen,lunaris-conformance, andlunaris-benchare internal tooling crates — not part of the public API.
Publishing note. The umbrella crate is published as
lunaris-memory(the barelunarisname on crates.io is taken; the library name stayslunaris, so the import path is unchanged).lunaris-storage-moondepends onmoondbfrom crates.io (version-pinned), so the workspace is crates.io-publishable; the[package.metadata.docs.rs]blocks are wired sodocs.rsbuilds the right feature set.
Error Taxonomy
Every public lunaris API returns Result<_, LunarisError>. LunarisError
is one umbrella enum with a sub-enum per subsystem; the HTTP server maps each
variant onto a status code (see Protocol → MemoryProtocol 0.1).
Source: crates/lunaris-core/src/error.rs.
LunarisError (umbrella)
#[non_exhaustive] — always include a wildcard arm when matching; new
subsystems can be added in a patch release.
| Variant | Wraps | Meaning |
|---|---|---|
Storage(StorageError) | backend / scheme / (de)serialization / IO faults | the KV-vector-graph-queue substrate failed or was misconfigured |
Extract(ExtractError) | local-LLM extractor faults | only reachable when the graph pipeline is on |
Validate(ValidateError) | input-validation faults | bad bi-temporal bounds, contradictions, missing confirmation token |
Retrieve(RetrieveError) | retrieval-operator faults | an operator in the DSL tree or its backend call failed |
Consolidate(ConsolError) | ACT-R consolidator faults | only reachable when the consolidate pipeline is on |
Sub-enums
StorageError
| Variant | Notes |
|---|---|
Backend(String) | the Moon backend returned a RESP-level error |
NotSupported(&'static str) | a capability the chosen backend doesn’t offer |
UnsupportedScheme(String) | the URL passed to Lunaris::open / lunaris::open had a scheme other than moon:// — every other spelling was retired in 0.7.0 |
Serde(serde_json::Error) | a stored value could not be (de)serialized |
Io(std::io::Error) | socket / file IO failure |
ExtractError
Timeout (the extractor model didn’t answer in budget) · GrammarReject(String) (the model’s output failed the constrained-decoding grammar) · Backend(String).
ValidateError
| Variant | Notes |
|---|---|
Temporal | valid_from >= valid_to on a primitive |
Contradiction(String) | the validator detected a contradiction it routed to the verifier |
ConfirmationRequired(String) | forget(...).hard() was called without a confirmation token — do a dry_run + confirm_hard_forget round-trip first (see Guides → Forgetting) |
RetrieveError
OperatorFailed(String) (an operator in the retrieval tree errored) · Backend(String).
ConsolError
ActivationUnderflow (ACT-R base-level activation went negative — a calibration bug) · Backend(String).
HTTP mapping (lunaris-server)
| Rust error | HTTP status | error code |
|---|---|---|
ValidateError::ConfirmationRequired | 428 Precondition Required (re-issue the dry_run + confirmation_token flow) | confirmation_required |
ValidateError::Temporal, ValidateError::Contradiction (and every other ValidateError) | 400 Bad Request | validate |
StorageError::NotSupported (a capability the chosen backend doesn’t offer) | 501 Not Implemented | not_supported |
StorageError::UnsupportedScheme | 400 Bad Request | unsupported_scheme |
request body carrying a scope / tenant field (forbidden by #[serde(deny_unknown_fields)]) | 422 Unprocessable Entity | (serde rejection) |
| missing / invalid bearer token | 401 Unauthorized | unauthorized |
| valid token without the verb the route requires | 403 Forbidden | forbidden |
| per-tenant rate limit exceeded | 429 Too Many Requests | (empty body; Retry-After header) |
every other StorageError, RetrieveError, ExtractError, ConsolError, unmapped LunarisError | 500 Internal Server Error | storage | retrieve | extract | consolidate | unknown |
A handful of statuses are produced by handler-local validation rather than
map_error — these are not LunarisError variants:
| Server cause | HTTP status | error code |
|---|---|---|
mode: "graph" on a backend without graph_native and with the graph pipeline off (routes/recall.rs) | 501 Not Implemented | graph_mode_unavailable |
malformed as_of (not RFC-3339) on /v1/recall | 400 Bad Request | invalid_request |
malformed confirmation_token (not a serialized ForgetReceipt) on /v1/forget | 400 Bad Request | invalid_confirmation_token |
malformed snapshot Lsn path segment on /v1/snapshot/{lsn} | 400 Bad Request | invalid_lsn |
{id} path segment on /v1/episode/{id} is not a valid Crockford base-32 ULID | 400 Bad Request | invalid_episode_id |
{lsn} wall_ms on /v1/snapshot/{lsn} is strictly greater than the engine’s current wall clock | 404 Not Found | snapshot_out_of_range |
no episode found for {id} in the caller’s JWT-bound scope on /v1/episode/{id} | 404 Not Found | episode_not_found |
/metrics requested while --metrics-disabled is set | 404 Not Found | (plain-text body) |
The auth / rate-limit middleware emits 401 / 403 / 429 before the
LunarisError → status map runs (crates/lunaris-server/src/middleware/error.rs::map_error);
that map only covers business-logic errors. map_error also increments
lunaris_error_total{kind=…} for every error it handles.
The wire-level error body shape is specified in MemoryProtocol 0.1.
Running the HTTP Server
Reach for lunaris-server when an agent harness — or a non-Rust service —
needs to talk to a shared Lunaris memory engine over HTTP/SSE instead of
linking the crate. It is the reference implementation of
MemoryProtocol 0.1: an axum 0.8 binary
exposing the blueprint verbs plus a Prometheus /metrics endpoint.
The crate is lunaris-server; config lives in
crates/lunaris-server/src/config.rs. The full env-var / feature-flag matrix
is in the Configuration Reference — this page
covers operating the binary.
Quick start
cargo build -p lunaris-server # → target/debug/lunaris-server
LUNARIS_STORAGE=moon://localhost:6380 \
LUNARIS_TOKENS_FILE=/etc/lunaris/tokens.json \
target/debug/lunaris-server --bind 127.0.0.1:8080
On startup the binary prints its bound address to stderr as
LISTENING_ON <addr> (e.g. LISTENING_ON 127.0.0.1:8080) — the conformance
subprocess runner parses this; see crates/lunaris-server/src/main.rs. Then
probe it:
curl -s localhost:8080/healthz # {"ok":true,"version":"0.2.1"} (version = CARGO_PKG_VERSION)
curl -s localhost:8080/readyz # {"ready":true,"version":...,"checks":{"ping":"ok","canary":"ok","embedder":"ok"}}
curl -s -H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"source":"demo","content":"Alice loves chocolate."}' \
localhost:8080/v1/ingest
Configuration
Every flag has a matching LUNARIS_* env var (12-factor; CLI flag wins over
env). Source: crates/lunaris-server/src/config.rs.
| Flag / env var | Default | Meaning |
|---|---|---|
--bind / LUNARIS_BIND | 0.0.0.0:8080 | Listen address |
--storage / LUNARIS_STORAGE | (required) | Storage URL — moon://host:port, the only accepted scheme (see The Storage Backend) |
--tokens-file / LUNARIS_TOKENS_FILE | (required) | Path to the bearer-token map JSON (below) |
--rate-per-second / LUNARIS_RATE_PER_SECOND | 60 | Per-tenant sustained request rate |
--rate-burst / LUNARIS_RATE_BURST | 120 | Per-tenant burst budget |
--cors-origins / LUNARIS_CORS_ORIGINS | * | CORS allow-list — * or a comma-separated origin list |
--shutdown-grace-secs / LUNARIS_SHUTDOWN_GRACE_SECS | 30 | Graceful-shutdown drain window (a ceiling — see Deployment notes) |
--http-timeout-secs / LUNARIS_HTTP_TIMEOUT_SECS | 30 | Per-request wall-clock budget; over-budget → 408. 0 disables |
--http-concurrency / LUNARIS_HTTP_CONCURRENCY | 256 | Max requests served concurrently; arrivals beyond the cap are shed with 503 + Retry-After, never queued. 0 disables |
--metrics-disabled | (off) | Remove the /metrics endpoint (no env var) |
The same LUNARIS_EMBEDDER_DIR / LUNARIS_EMBEDDER_GGUF / LUNARIS_GRAPH_ENABLED / verifier /
consolidator env vars that Lunaris::open reads also apply here — see
Configuration Reference §2.
The bearer-token map (LUNARIS_TOKENS_FILE)
A JSON object mapping opaque bearer tokens to a tenant id + scope set (CONTEXT.md D-07):
{
"<opaque-bearer-token>": { "tenant": "acme", "scopes": ["ingest", "recall", "forget"] },
"<another-token>": { "tenant": "globex", "scopes": ["recall"] }
}
tenantis the partition scope for the token (typed and validated as aScope) — the only source of truth for it. Route handlers consume the token-bound scope and ignore anyscope/tenantfield on the request body; every public DTO carries#[serde(deny_unknown_fields)], so a request body that contains such a field is rejected outright (HTTP 422).scopesis the verb-permission set for the token — which ofingest/recall/forgetit may call. A request whose route requires a verb the token doesn’t carry →403 Forbidden.- A missing or invalid token →
401 Unauthorized.
Heads-up on
forgetunder real scopes. In v0.2.x,Lunaris::forgetstill routes throughScope::dev()internally for itsatomic_write/read_as_of/scan_rangecalls — aforgetissued under any non-_dev_scope silently returnsrows_written = 0,rows_deleted = 0(the Moon SCAN prefix filters everything out). It emits atracing::warn!on every call. The real per-scope routing —ScopedLunaris::forget(target)with a403/404cross-scope contract — is a v0.3 deliverable (RFC 0001 §11.6,CHANGELOG.md“Known issues”). See Forgetting anddocs/migration/0.1-to-0.2.md§10.2.
Endpoints (operational view)
The wire spec is MemoryProtocol 0.1; the operational summary:
| Route | Required scope | What it does |
|---|---|---|
POST /v1/ingest | ingest | Ingest one Episode; server chunks + embeds + does one atomic_write. Returns {lsn, queue_lag_warn}. |
POST /v1/recall | recall | Hybrid retrieval (Vector + BM25 + RRF + optional rerank). Accept: application/json → array of hits; Accept: text/event-stream → SSE stream (event: hit … event: done, 15 s keep-alive). mode: "graph" needs a graph-capable backend or GraphPipeline::enable() (else 501). |
POST /v1/forget | forget | Single-target / by-source / temporal-bound purge. Two-step hard-delete rail: dry_run:true → preview receipt; then hard:true + confirmation_token: <serialized prior receipt> → real delete. hard:true without the token → 428 Precondition Required. |
GET /v1/snapshot/{lsn} | recall | Streams every primitive at the given Hlc (<wall_ms>.<counter>[.<node_id>]) as application/x-ndjson. Returns 404 snapshot_out_of_range if the wall_ms is strictly in the future; an empty past snapshot is 200 + empty body. |
GET /v1/episode/{id} | recall | Fetch a single episode by ULID from the caller’s scope. 200 + JSON on hit; 400 invalid_episode_id on malformed ULID; 404 episode_not_found when absent. |
GET /healthz | (none) | Liveness probe — {"ok":true,"version":...}; 503 when the storage PING fails. No auth, not rate-limited, never writes. |
GET /readyz | (none) | Readiness probe — storage PING + a write canary + embedder state. 200 {"ready":true,"checks":{...}} / 503 {"ready":false,...}. No auth, not rate-limited. |
GET /metrics | (none) | Prometheus text exposition. No auth — front it with a network ACL or reverse-proxy auth. 404 when --metrics-disabled. |
Liveness vs readiness
Wire them to the matching Kubernetes probes — they answer different questions and have different remedies:
| Probe | Endpoint | Asks | Failure remedy |
|---|---|---|---|
livenessProbe | /healthz | Is the process up and is storage reachable? | Restart the pod |
readinessProbe | /readyz | Can the process actually serve? | Remove the pod from the LB |
/readyz runs three checks, each bounded at 2 s, and reports them
individually in checks:
ping—StoragePort::health_check(MoonPING);canary— aKvPut+KvDeleteof the fixed reserved keylunaris:__health__:canaryin the reserved__health__scope;embedder— the embedder is configured with a usable dimension. This is a structural check: a probe must never run inference, because a wedged ggml pool cannot be cancelled and the probe itself would wedge.
The canary check is the point of the endpoint. Both production wedges we have
hit (the MA1 write-stall, the dashtable recovery crash-loop) answered PING
happily while refusing every write, so a PING-only probe kept traffic flowing
into a store that could not accept a byte. A canary of timeout is that exact
signature. Note that liveness deliberately stays green in that case:
restarting the process does not un-wedge the backend, it just adds a cold start
to the incident.
Readiness results are cached for 5 s and probes single-flight, so at most one
canary write reaches the store every 5 s regardless of probe rate — probe
traffic can never become write traffic. lunaris_ready (1/0) exports the
last verdict for alerting.
$ curl -s localhost:8080/readyz | jq
{ "ready": true, "version": "0.6.0", "checks": { "ping": "ok", "canary": "ok", "embedder": "ok" } }
Request timeout, concurrency limit and load shedding
Applied to every route (probes included), outside the per-route auth / rate-limit stack:
- a request that exceeds
--http-timeout-secsis cut off with408and the{error:"request_timeout"}envelope, and counted inlunaris_http_timeout_total. The budget covers producing the response, not streaming its body — an SSE/v1/recallstream is never severed mid-flight; - a request arriving while
--http-concurrencyrequests are already in flight is shed with503+Retry-After: 1and the{error:"overloaded"}envelope, counted inlunaris_http_shed_total. It is never queued: queueing in front of a slow backend is how a latency blip becomes an OOM.
Order on the request path is
CORS → load-shed(concurrency) → in-flight gauge → timeout → auth/rate-limit → handler,
so the worst-case backlog is bounded by --http-concurrency × --http-timeout-secs
rather than by client patience. Rationale for that order lives in
crates/lunaris-server/src/middleware/resilience.rs.
Rate limiting
Per-tenant, applied to every /v1/* request (key = the tenant claim).
Exceeded → 429 Too Many Requests with a Retry-After: <seconds> header.
Un-authenticated routes (/healthz, /metrics) are not rate-limited.
Metrics
/metrics exposes (Plan 05-05; CONTEXT.md D-25):
| Metric | Type | Labels |
|---|---|---|
lunaris_ingest_total | counter | tenant, status |
lunaris_ingest_duration_seconds | histogram | tenant |
lunaris_recall_total | counter | tenant, mode, status |
lunaris_recall_duration_seconds | histogram | tenant, mode |
lunaris_forget_total | counter | tenant, target_kind, hard |
lunaris_verify_queue_depth | gauge | topic |
lunaris_consolidator_queue_depth | gauge | topic |
lunaris_error_total | counter | kind (cardinality cap ≤ 10) |
lunaris_eval_score | gauge | harness |
lunaris_http_in_flight | gauge | (none) |
lunaris_http_shed_total | counter | (none) |
lunaris_http_timeout_total | counter | (none) |
lunaris_ready | gauge | (none) |
Time-series count grows linearly with tenant count (the tokens-file map
size), not with traffic. Content-Type is the standard
text/plain; version=0.0.4; charset=utf-8.
Deployment notes
- Stateless process. Every byte of durable state lives in the backend; the server holds nothing on disk. Scale horizontally; restarts are free. See Durability & Recovery.
- Graceful shutdown. On
SIGTERM/SIGINTthe server stops accepting new connections and drains in-flight requests for at most--shutdown-grace-secs(default 30 s) before exiting. The window is a ceiling, not a sleep: an idle server exits in milliseconds. If the window expires the server logsWARN shutdown grace window expired; abandoning in-flight requests aborted_in_flight=<n>(n from thelunaris_http_in_flightgauge) and exits anyway — so a wedged backend can no longer pin the process until the orchestrator escalates to SIGKILL. Set your orchestrator’s termination grace period a few seconds ABOVE--shutdown-grace-secs. - HTTP-only image. A
cargo build --no-default-features -p lunarisbuild links neither the native embedder nor the reranker stack — useful when the server uses the Ollama HTTP escape hatch (--features embed-remote,LUNARIS_EMBEDDER_OLLAMA_URL) and you want a small image. - TLS / OAuth. v0 is plain HTTP with opaque bearer tokens; terminate TLS
and do OAuth2/OIDC issuance at a reverse proxy. Managed-cloud JWT issuance
is a v1 gate (
DEPLOY-V1-01). - Errors. The HTTP status ↔
LunarisErrormapping lives incrates/lunaris-server/src/middleware/error.rs::map_error; the full table is in the protocol spec and Error Taxonomy.
See also
- MemoryProtocol 0.1 — the wire spec
- Conformance — certifying a server
- The Storage Backend — Moon setup and its honest limits
- Configuration Reference — every flag/var
The Storage Backend (Moon)
As of 0.7.0 Lunaris ships exactly one StoragePort implementation: Moon,
the Redis-compatible substrate. Lunaris::open(url) accepts one scheme,
moon://host:port; every retired spelling (postgres://, postgresql://,
memory://, sqlite:///path) was removed in 0.7.0 and returns
UnsupportedScheme carrying the migration link rather than half-working.
If you are on 0.6.x with a Postgres or SQLite store, migrate before you bump
the pin — the exit ramp is lunaris-migrate, built from the v0.6.2 tag. See
0.6 → 0.7.
What Moon provides
| Capability | Implementation |
|---|---|
| Vector search | Native FT.SEARCH (HNSW) |
| BM25 keyword | Native FT.SEARCH inverted index |
| RRF fusion | Native, in-substrate — a (Vector + Keyword) pair on one index is a single round trip |
| Graph traversal | Native GRAPH.QUERY (Cypher) |
| Pipeline queue | Native Streams |
| Embedding dim | Embedder-sized — the adapter creates its vector index at embedder.dim() (default 768-d). No upper cap. |
Bi-temporal as_of | Native, on the search and graph lanes (FT.SEARCH AS_OF, GRAPH.QUERY VALID_AT) |
| Historical KV read | Not supported — see STORE-07 below |
| Tenant isolation | Per-scope keyspace prefix lunaris:{scope}: + per-scope FT / GRAPH / MQ |
| Scope soft cap | ~512 scopes/node (max_scopes_recommended) |
| Recovery | AOF + base-RDB replay — see Durability |
The StorageCapabilities report the backend returns still drives
capability-gated behaviour (graph mode, native vs client RRF, queue mode). It
is no longer a portability mechanism — with one backend it is how the engine
learns what this Moon build supports, and it is what the STORE-07 refusal below
is derived from.
STORE-07 — no historical KV reads
Moon has no per-key version chain, so read_as_of cannot walk one.
supports_historical_kv_reads() returns false and the call refuses
rather than silently answering with today’s value — a wrong answer to “what did
the agent know at T?” is worse than a named error. Through 0.6.x this was the
one capability Postgres/SQLite had that Moon did not; with those backends gone
it is a flat limitation of 0.7.0, pinned by
lunaris_conformance::storage::read_as_of and the run_as_of_moon_gap test.
Time-travel over search and graph results is unaffected and native.
About the embedding dimension
Moon has no hard vector-dimension limit — FT.CREATE only requires DIM > 0.
The adapter creates its chunks (and entities / facts / communities) FT
indices at the configured embedder’s dimension: Lunaris::open(url) reads
embedder.dim() and calls MoonStorage::connect_with_dim(url, dim), so a
1536-d embedder (OpenAI text-embedding-3) works out of the box. The default
is 768-d (granite-embedding-311m-multilingual-r2); max_vector_dim in
StorageCapabilities reports whatever dimension the index was actually created
at.
Operator footgun.
FT.CREATEis idempotent and does not resize an existing index. If a Moon instance already holds a 768-dchunksindex and you reopen with a wider embedder, the old index stays and the mismatch only surfaces on the first vector write — drop the stale index (FT.DROPINDEX <name>) first. Wider vectors remain a latency trade-off (more bytes/vector, more distance-compute per query), not a capability boundary.
Moon setup
docker run -d --name lunaris-moon -p 6380:6379 \
ghcr.io/pilotspace/moon:0.8.5 \
--shards 1 --protected-mode no --appendonly yes
Two flags are load-bearing:
--shards 1is mandatory. A Lunaris ingest is one MULTI/EXEC transaction, and a sharded Moon rejects cross-shard writes — every ingest fails. The image defaults to--shards 0(auto-detect), so the flag has to be passed explicitly.--appendonly yesis what makes the store survive a restart.
There is no schema migration and no role bootstrap. Per-scope keyspaces, FT
indices, GRAPH keys, and MQ topics are created lazily on the first
atomic_write per scope, so the first write for a new scope is slightly slower
and subsequent writes hit the warm index.
Before you trust the data on disk, read Durability &
Recovery — the short version is: enable AOF
(--appendonly yes --appendfsync always) and ensure a base RDB exists via
BGREWRITEAOF (not BGSAVE).
Full production setup — memory limits, backups, health probes, systemd/launchd units — is in Running an external Moon.
See also
- Durability & Recovery
- Configuration Reference §4 — Storage URL scheme
- Multi-Agent & Scope
- Conformance
Durability & Recovery
Lunaris is stateless: every byte of durable state lives in Moon, so recovery is a Moon concern. This chapter documents the Moon-backed crash-recovery path, the bi-temporal MVCC semantics the guarantees rest on, the two live-measurement gotchas you need to know, and how to test recovery yourself.
Adapted from
docs/durability.md(kept in the repo as the canonical standalone version). Status: alpha. All claims validated against live Moon on 2026-04-23 — rerunscripts/test-recovery.pyto re-verify.
1. What survives a crash
| entity | persisted in | survives Moon crash? | survives Lunaris client crash? |
|---|---|---|---|
| Episode body (KV) | Moon HSET at lunaris:{scope}:episode:<ulid> | yes — via AOF + RDB | n/a (client-side is stateless) |
| Chunk text (KV) | Moon HSET at lunaris:{scope}:chunk:<ulid> | yes | n/a |
| Vector (768-d) | Moon chunks:<hex> HSET + FT HNSW | yes | n/a |
| BM25 index | Moon FT inverted index | yes (rebuilt from HSETs on replay) | n/a |
| Graph (entities, facts) | Moon GRAPH.* storage | yes | n/a |
| Pipeline queue (consolidate, verify) | Moon Streams | yes | n/a |
In-flight ingest that wasn’t await-ed | — | no (never committed) | no (never committed) |
Every user-visible write goes through atomic_write
(crates/lunaris-storage-moon/src/atomic.rs). The ingest umbrella splits a
single Episode into one batch of WriteOp values (KV puts + vector upserts
- optional graph writes) and ships them inside one atomic envelope. Moon
commits the envelope as a unit — partial envelopes never appear in the AOF.
This is the same single-
atomic_write-per-ingest moat enforced by the CI grep gate; see Ingesting Observations.
2. Bi-temporal MVCC semantics
Every primitive (Episode, Chunk, Entity, Fact, Relation,
Community) carries a required bt field — a bi-temporal stamp
{ valid: (Hlc, Option<Hlc>), sys: (Hlc, Option<Hlc>) } (Snodgrass
bi-temporal at the storage model: valid time = when the fact was true in
the world, system time = when Lunaris knew it). Nothing is updated in
place:
Scope of the claim. The write model below is fully bi-temporal, and as-of reads work on the search and graph lanes (
FT.SEARCH AS_OF,GRAPH.QUERY VALID_AT). Historical KV reads do not: Moon stores Lunaris rows as plain hashes with no version chain, soread_as_ofpast a 1-hour live window refuses withNotSupported→ HTTP 501 rather than answering with today’s data (crates/lunaris-storage-moon/src/as_of.rs, pinned bycrates/lunaris-conformance/tests/run_as_of_moon_gap.rs).
- An ingest inserts a row with
sys = (now, None)and the suppliedvalidrange. - A correction (verifier supersede) closes the old row’s
sysat the correction time and inserts a new row — both stay on disk. - A forget (soft, the default) closes
syson the target rows; the audit log records the close. A time-travel query withas_ofbefore the forget timestamp still sees the row.
So “what did the agent know at time T” is a storage query (read_as_of /
.as_of(ts) on the retrieval DSL, native bi-temporal on Moon), not a log
replay. Crash recovery therefore restores not
just the current state but the entire history, because the history is the
on-disk state.
3. Moon persistence model (for operators)
Moon combines AOF (append-only file) with base RDB snapshots:
- Every write is appended to
<dir>/appendonlydir/moon.aof.<N>.incr.aof. With--appendfsync alwaysthe append isfsync’d on every command — no data loss onkill -9after a successfulawait ingest(). BGREWRITEAOF(or the auto-save rules in--save "<seconds> <changes>") rotates the AOF: it writes a base RDB snapshot at<dir>/appendonlydir/moon.aof.<N+1>.base.rdb, then starts a new empty incremental atmoon.aof.<N+1>.incr.aof.- On restart, Moon loads the newest
base.rdbinto memory, then replays the matchingincr.aofon top. FT indices are rebuilt from the replayed HSETs automatically.
3.1 Launching Moon for durability
../moon/target/release/moon \
--bind 127.0.0.1 --port 6380 \
--dir /var/lib/moon \ # where AOF + RDB live
--shards 1 \
--appendonly yes \ # enable AOF
--appendfsync always \ # fsync every write (default: everysec)
--save "3600 1 300 100" # RDB auto-rewrite rules
The --save rules follow the Redis convention: seconds changes pairs —
here, rewrite if either 1 change happened in 3600 s OR 100 changes in 300 s.
Pair them with an explicit BGREWRITEAOF before planned shutdowns if you
want a clean snapshot.
3.2 The base-RDB trap
Without an existing base RDB, AOF-only state is unreplayable. If you start
Moon fresh, ingest data, and kill -9 before the first auto-save /
BGREWRITEAOF has run, the restart fails with:
Error: multi-part AOF replay failed
AOF base RDB missing at moon.aof.1.base.rdb but incr moon.aof.1.incr.aof
is 821715 bytes; refusing to replay incr against empty state
This is a deliberate Moon invariant — the AOF chain needs an anchor snapshot, it won’t silently replay against empty state.
Safe patterns:
- Let the auto-save rules run naturally —
--save "60 1"forces a base RDB within 60 s of any write, small enough for dev boxes. - Run
BGREWRITEAOFexplicitly after large bulk ingests; poll for the newmoon.aof.<N>.base.rdbto appear on disk before you consider the data “durable”. - Before a planned restart, run
BGREWRITEAOF+ wait for the file.
# Force a base RDB and wait for it before trusting durability
redis-cli -p 6380 BGREWRITEAOF
while ! ls /var/lib/moon/appendonlydir/*.base.rdb >/dev/null 2>&1; do
sleep 0.2
done
Do not use
BGSAVEfor this.BGSAVEwrites<dir>/dump.rdb, a separate artefact that does NOT participate in AOF replay. OnlyBGREWRITEAOFproduces theappendonlydir/moon.aof.<N>.base.rdbthat Moon’s recovery chain needs.
3.3 FT index lag (measurement gotcha, not a durability issue)
await kb.ingest(...) returns after the atomic envelope is ACKed. But Moon’s
FT index num_docs can lag the underlying HSETs by a few hundred
milliseconds while the index materialises. If you snapshot FT.INFO chunks
immediately after the last ingest and then crash, post-recovery num_docs
can appear higher than the pre-crash snapshot — because the index caught up
during the interim.
This is not data loss. The underlying HSETs are already fsync’d. The
workaround in tests is to sleep 1–2 s before taking the “pre-crash”
snapshot. In production you don’t need to do anything — the data is durable
either way.
3.4 WAL v3 + upgrade safety (Moon v0.7.0/v0.7.1)
The Moon v0.7.1 bump (2026-07-15) hardens the per-shard WAL to WAL v3:
a WAL record now commits durably as a whole or is discarded on replay
(atomic durable writes), and the FT term dictionary is persisted with the
WAL instead of rebuilt best-effort after a crash. The upgrade-safety fix
(#69, segment_plane_scan) matters most: v0.6.0 wrote MQ and temporal
plane records in a nested framing that v0.7’s plane scan initially skipped —
without the fix, a v0.6→v0.7 restart would silently drop MQ backlog + PEL
state and temporal history. Lunaris pins this with a dedicated harness leg:
python scripts/test-recovery.py --upgrade-replay \
--old-bin ~/.lunaris/bin/moon \
--new-bin vendor/moon/target/release/moon
The v0.7.1 patch also fixes the SQ8 code-size mis-dispatch CPU error-storm (#73 — relevant if you run Lunaris’s opt-in SQ8 quantization) and makes replica TTL expiry deterministic (#71 — relative expiries are rewritten to absolute deadlines before entering the durable log, so an AOF replay reproduces the master’s expiry instant instead of restarting the countdown).
3.5 One Storage Kernel GA (Moon v0.8.0)
The v0.8.0 bump (2026-07-16, pinned at the post-release main commit
e41aa671) graduates this to kill-9-lossless on every plane, with
upstream’s own scheduled crash-matrix CI (#352) now covering KV, vector,
graph, MQ, and temporal recovery. Two fixes matter operationally:
GraphUnion auto-merges rejected by the recall gate now back off
exponentially (#353) instead of livelocking the CPU and — through the
unflushed-segment stall guard — pausing writes after a restart; and the
pinned commit carries the DashTable split-retry fix (Moon PR #351) for a
deterministic recovery panic on hash-skewed checkpoint loads (the reason
the pin is a main SHA rather than the bare 0.8.0 tag). Disk-offload also
hardened: truthful used_memory under offload (#349) and batched spill
segments (#350).
4. Recovery procedure
4.1 Moon process crashed (kill-9, OOM, power loss)
# 1. Confirm Moon is really dead on the port
lsof -nP -iTCP:6380 -sTCP:LISTEN # expect nothing
# 2. Inspect the AOF state
ls /var/lib/moon/appendonlydir/
# Healthy: one or more moon.aof.<N>.base.rdb files + matching .incr.aof
# Dangerous: only .incr.aof files, no .base.rdb → restart will refuse
# 3. Restart with the same --dir
../moon/target/release/moon --bind 127.0.0.1 --port 6380 \
--dir /var/lib/moon --shards 1 --appendonly yes --appendfsync always \
--save "60 1" &
# 4. Wait for ready
while ! redis-cli -p 6380 PING | grep -q PONG; do sleep 0.1; done
# 5. From the Lunaris side, just reconnect
python -c "import asyncio, lunaris; asyncio.run(lunaris.open('moon://127.0.0.1:6380'))"
Expected replay cost: ~20 ms per 1 MB of AOF on darwin-arm64. A 50-doc / ~820 KB AOF replays in 0.90 s end-to-end (including FT rebuild).
4.2 Lunaris client process crashed
Nothing to recover on the client side — the process was stateless. Any
ingest that hadn’t await-ed its result was never committed. Just re-run.
4.3 Neither side’s AOF has a base RDB
If ls /var/lib/moon/appendonlydir/ shows only .incr.aof files and Moon
refuses to start, you have three options:
- Recoverable via synthetic replay. Write a tiny script that starts a
fresh Moon on a scratch dir, re-plays the
.incr.aofentries via a custom RESP client, then runsBGREWRITEAOF. (Requires reading the.incr.aofformat — seemoon/src/persistence/aof/in the Moon repo.) - Treat as data loss. Wipe the corrupt data dir, start fresh, re-ingest from source.
- File a bug on Moon. Ideally Moon would offer a
--allow-unrooted-aof-replayflag for the base-less case.
Preventing this is easier than recovering: always run BGREWRITEAOF after
your first batch of writes when seeding a new deploy.
5. Testing recovery
A ready-to-run harness ships at scripts/test-recovery.py. It covers three
failure modes:
| test | what it verifies |
|---|---|
test_moon_kill | SIGKILL Moon, restart, recover. Asserts dbsize, FT index list, chunks_num_docs, and top-10 text identity for 5 probe queries all match pre-crash. |
test_lunaris_kill | SIGKILL a child python ingest at the halfway mark. Asserts Moon state is self-consistent (no torn writes), FT.SEARCH still runs. |
test_write_after_restart | After test_moon_kill, writes a fresh doc with a unique anchor phrase and verifies it roundtrips through search. |
cd crates/lunaris-py
LUNARIS_MOON_URL="moon://127.0.0.1:6380" \
uv run --with datasets --with python-ulid --with redis \
python -u ../../scripts/test-recovery.py --docs 50
Reference numbers from the 2026-04-23 run (50 docs, darwin-arm64, release Moon):
- Moon restart + AOF replay: 0.90 s wall
- Probe-identity check: 5/5 top-10 lists byte-identical
- Post-restart write roundtrip: PASS
Evidence log: milestones/v0.1.1-bench/recovery-test.log.
6. Known limitations
- Single-shard only in this tested config. Multi-shard Moon recovery semantics (cross-shard coordinator, manifest-level replay) aren’t exercised by the harness yet.
- AOF grows unboundedly without auto-rewrite. Use
--saverules or scheduleBGREWRITEAOF— otherwise replay time grows linearly with write volume. - Pipeline workers replay idempotently (
consolidator,verifier) — they read from Moon Streams on start-up and resume from the last committed offset. No action needed on the Lunaris side.
7. Related references
.planning/architect/blueprint.md§5.4 — durability contractcrates/lunaris-storage-moon/src/atomic.rs— theWriteOpenvelope shapemoon/src/persistence/(upstream Moon repo) — AOF + RDB implementation- Ingesting Observations / The Retrieval DSL — the end-user API the recovery guarantees apply to
- The Storage Backend — Moon setup and its honest limits
If something here doesn’t match the source, the source wins.
Security & Hardening
Lunaris’ v0 security model is deliberately thin in-process and delegates
hardening to the layer in front of it: the server assumes a trusted
network perimeter, with TLS, OAuth2/OIDC, and IP filtering done at a
reverse proxy (DEPLOY-V1-01 — see
Running the HTTP Server → Deployment notes).
This page is the operator checklist for deploying inside that model.
Vulnerability reporting and the full stance rationale live in the repo-root
SECURITY.md.
What v0 auth actually is
- Opaque bearer tokens, boot-loaded.
lunaris-serverreadsLUNARIS_TOKENS_FILE(a JSON map) once at startup (crates/lunaris-server/src/lib.rs,load_tokens) and resolvesAuthorization: Bearer <token>by in-memory map lookup (middleware/auth.rs). There are no JWTs — the “claims” (tenantpartition scope,scopesverb permissions) live server-side in the tokens file, never inside the token string. - No expiry, no runtime rotation. Rotating a token = edit the tokens file, restart the server. Plan rotation as a rolling restart.
- Plaintext at rest. The tokens file is a secret; the server never
hashes it.
chmod 600and owner it to the service user. - A missing/corrupt tokens file does not stop the boot — the server
starts with an empty map (every request 401s) so
/healthzstill answers. Watch the boot warning log. - Tenant isolation is wire-proof. The partition scope comes only from
the token’s server-side
tenantentry; every public DTO carries#[serde(deny_unknown_fields)], so a request body smuggling ascope/tenantfield is rejected with 422.
Hardening checklist
Before exposing a deployment to anything beyond localhost:
- Reverse proxy in front, TLS terminated there. v0 is plain HTTP
(
DEPLOY-V1-01). Do OIDC/OAuth2, request size limits, and IP allow-listing at the proxy too. - Restrict
/metrics. It is unauthenticated by design (accepted as T-05-05-05,routes/metrics.rs; standard Prometheus convention) and its labels include your tenant roster. Network ACL, proxy-side auth, or--metrics-disabled. - Set
LUNARIS_CORS_ORIGINS. The default is*(crates/lunaris-server/src/config.rs). Harmless for non-browser clients; set an explicit origin list the moment a browser is in the picture. - Lock down the tokens file.
chmod 600, service-user owned, excluded from backups that leave the trust boundary. - Keep Moon off the public network. The server↔Moon link
(
moon://host:port) is unauthenticated RESP inside your perimeter; firewall Moon’s port to the server hosts, and treat Moon’s admin port (--admin-port, serves its own unauthenticated/metrics) the same way. - One token per client, least verbs.
scopesgrantsingest/recall/forgetper token — a recall-only consumer should hold a recall-only token (missing verb → 403). - Rate limits are per-tenant, defaults 60 rps / burst 120
(
LUNARIS_RATE_PER_SECOND/LUNARIS_RATE_BURST) — size them to your clients before load-testing convinces you the server is broken. - Logs: production JSON logging (
LUNARIS_ENV=production) includes correlation IDs; ship them somewhere with retention if you need an audit trail. There is no in-process audit log beyond tracing output.
What is deferred, on record
| Deferred | Where that’s recorded |
|---|---|
| TLS in-process, OAuth2/OIDC, managed JWT issuance | DEPLOY-V1-01, server.md → Deployment notes |
/metrics auth | T-05-05-05 accept (comment in routes/metrics.rs) |
| Token hashing / runtime rotation endpoint | v0 tokens-file design (D-07) — revisit with DEPLOY-V1-01 |
| OTLP trace export | ADR docs/decisions/2026-08-17-otlp-post-ga.md (post-GA) |
Python SDK
pip install lunaris gives you the same memory engine as the Rust crate,
behind a PyO3 0.26 binding generated from the same annotated surface — so
open / ingest / recall / forget / snapshot and the composable retrieve DSL
behave identically across all three SDKs. cargo run -p lunaris-codegen -- --check gates every PR, so the surfaces never drift.
Adapted from
docs/bindings.md(Python half) anddocs/sdk/embedder-config.md.
Install
pip install lunaris
Prebuilt wheels ship for 5 targets (BIND-PY-05): linux-x86_64
(manylinux_2_28), linux-aarch64 (manylinux_2_28), macosx-x86_64,
macosx-arm64, win-amd64. They use the abi3-py311 stable ABI, so one
wheel per target covers Python 3.11, 3.12, and 3.13.
v0.6 llama.cpp-only cutover. The candle-native embedder/reranker paths are deleted;
llamacpp(in-process llama.cpp, GGUF artifacts) is the only local inference runtime, on by default. Seedocs/sdk/embedder-config.mdanddocs/migration/0.5-to-0.6-llamacpp-only.md(the migration guide).
The bundled wheels are built with the llama.cpp inference runtime included —
the default embedder is granite-embedding-311m-multilingual-r2 (768-d,
Q4_K_M GGUF), runs in-process via llama.cpp, staged at
~/.lunaris/models/ — no Ollama, no external service required. There is
no auto-download; the MCP server stages GGUFs lazily on first recall, other
deployments download them out-of-band. An air-gapped Ollama HTTP embedder
remains available as an operator escape hatch behind --features embed-remote (resolves after the llama.cpp step).
No matching wheel? Source install (needs Rust 1.94+ and a maturin toolchain; 2–5 min):
pip install lunaris --no-binary lunaris
Quickstart
import asyncio
import lunaris
import ulid
async def main():
handle = await lunaris.open("moon://127.0.0.1:6380")
lsn = await handle.ingest({
"id": str(ulid.ULID()),
"source": "py-quickstart",
"content": "Lunaris bi-temporal memory — hello from Python.",
"metadata": {},
"t_ref": None,
"bt": {
"valid": [{"wall_ms": 0, "counter": 0, "node_id": 0}, None],
"sys": [{"wall_ms": 0, "counter": 0, "node_id": 0}, None],
},
})
print("ingested at LSN", lsn)
hits = await (
lunaris.RetrievalBuilder()
.bind(handle)
.top(5)
.execute()
)
for h in hits:
print(h)
asyncio.run(main())
lunaris.RetrievalBuilderis the pure-Python plan builder fromlunaris.dsl(the package__init__re-exports it over the raw PyO3 class, whose builder methods areNotImplementedErrorstubs).handle.recall()returns one pre-bound tohandle; a freelunaris.RetrievalBuilder()needs a.bind(handle)before.execute().
moon://host:port is the only accepted URL scheme as of 0.7.0. See
The Storage Backend.
The wire shape
Episodes, forget requests, and hits cross the FFI as plain Python dicts /
lists (pythonize round-trips them to the Rust structs). For the bare
handle.ingest(dict) path you build the bt bi-temporal stamp and the ULID
id by hand, as in the quickstart.
For multi-agent partitioning the v0.2 surface (Wave 3G) also ships the typed ergonomics:
from lunaris import Scope, EpisodeBuilder
scoped = handle.scoped(Scope("acme.agent-1")) # ScopedLunaris
lsn = await scoped.ingest(
EpisodeBuilder("notes", "Lunaris ingest via the typed builder.")
.metadata({"topic": "demo"})
)
hits = await scoped.recall("what did agent-1 note?") # scope-pinned recall
Scope("…") validates against [A-Za-z0-9_\-.]{1,128} and raises
ValueError on a bad string, so “ingest into agent A, recall from agent B” is
a construction error rather than a silent leak. EpisodeBuilder mirrors the
Rust builder; its terminal into_episode is crate-private — only
ScopedLunaris.ingest may call it. (Scope, EpisodeBuilder,
ScopedLunaris, and handle.scoped(...) are exported from the package root.)
The retrieval DSL via RetrievalBuilder
Vector, Keyword, Graph (from lunaris.dsl, re-exported at the package
root) compose via .and_(), .fuse_rrf(k), .top(n), .filter(...) /
.filter_str(s), .as_of(ms). A terminal .execute() collapses the whole
plan into a single FFI call — the plan is built in Python, executed once
in Rust:
hits = await (
handle.recall() # pre-bound RetrievalBuilder, default root Vector("chunks", 30)
.and_(lunaris.Keyword.bm25("chunks", 30))
.fuse_rrf(60) # Reciprocal Rank Fusion, k=60
.top(5)
.execute()
)
# hits is List[Hit dicts]; each carries content, source, score, raw_score,
# valid_time, sys_time, degraded (bool), rerank_applied (bool), source_op.
.execute() takes no arguments in the v0.2 Python DSL — the plan tree
collapses to the index / k / optional filter / as_of_ms knobs that
the recall_simple_execute FFI accepts; a query-text setter on the builder
is a follow-up. (Same shape on the TS side — see
TypeScript SDK.)
Time-travel is one combinator (.as_of(wall_ms) — milliseconds since the
Unix epoch):
from datetime import datetime, timezone
snap_ms = int(datetime(2024, 6, 1, tzinfo=timezone.utc).timestamp() * 1000)
hits = await handle.recall().as_of(snap_ms).execute()
See The Retrieval DSL for the full operator set.
Pipeline toggles (three surfaces)
GraphPipeline and ConsolidatorPipeline default OFF. Flip them at code,
env, or config; resolution order is code > env > config — code wins.
# code surface
handle.graph_pipeline.enable()
handle.consolidator_pipeline.disable()
# config surface — dict walked by the lunaris.open wrapper
handle = await lunaris.open(url, config={
"graph_pipeline": {"enabled": True},
"consolidator_pipeline": {"enabled": False},
})
# env surface — read at lunaris.open time
export LUNARIS_GRAPH_ENABLED=1
export LUNARIS_CONSOLIDATE_ENABLED=0
See Consolidation & Verification and The Graph Pipeline.
Embedder / reranker config
Override the default embedder/reranker from code via EmbedderConfig /
RerankerConfig (opaque handles wrapping a resolved Arc<dyn Embedder>).
import lunaris
from lunaris import EmbedderConfig, RerankerConfig
# `open` is async — call it inside an async function or `asyncio.run(...)`.
mem = await lunaris.open(
"moon://127.0.0.1:6380",
embedder=EmbedderConfig.llamacpp(), # granite-r2 Q4_K_M GGUF, in-process llama.cpp, staged default GGUF
reranker=RerankerConfig.llamacpp(), # bge-reranker-v2-m3 Q5_K_M GGUF cross-encoder
)
EmbedderConfig factories:
| Factory | Use when |
|---|---|
EmbedderConfig.llamacpp(gguf_path=None) | Default — granite-embedding-311m-multilingual-r2 (768-d), Q4_K_M GGUF, in-process llama.cpp. Loads eagerly; raises on a missing/corrupt GGUF. Staged default: ~/.lunaris/models/granite-embedding-311m-multilingual-r2.Q4_K_M.gguf. |
EmbedderConfig.noop(dim=768) | Deterministic zero-vector — tests / offline use only. |
RerankerConfig factories:
| Factory | Use when |
|---|---|
RerankerConfig.llamacpp(gguf_path=None) | Default — BAAI/bge-reranker-v2-m3 cross-encoder (Q5_K_M GGUF, sigmoid ∈ [0,1]), in-process llama.cpp. Staged default: ~/.lunaris/models/bge-reranker-v2-m3.Q5_K_M.gguf. |
RerankerConfig.noop() | Skip the cross-encoder rescoring pass — lowest latency floor. |
Notes:
- No auto-download. Point
gguf_path(orLUNARIS_EMBEDDER_GGUF/LUNARIS_RERANKER_GGUF) at a pre-staged artifact; the MCP server stages GGUFs lazily on first recall, other deployments download them out-of-band and verify against the canonical SHA-256s (cargo run -p lunaris-bench --bin stage-models -- --help). - Retired:
EmbedderConfig.native()/.native_quantized()(and the reranker equivalents) were deleted in the v0.6 llama.cpp-only cutover; the factories still exist as stubs that raise immediately with a migration hint pointing atllamacpp(gguf_path=...). Seedocs/migration/0.5-to-0.6-llamacpp-only.md. - An air-gapped Ollama HTTP embedder remains available as an operator escape
hatch behind
--features embed-remote(LUNARIS_EMBEDDER_OLLAMA_URL), resolving after the llama.cpp step. - Tier-0 wheels (built with
default-features = false, no C++ toolchain, nollamacppfeature) raise a clear “no-inference build” error fromllamacpp()— usenoop()there. - FFI cliff: you cannot implement the Rust
Embedder/Rerankertrait from Python — per-call FFI callbacks would be too slow for the hot path. Roll-your-own backends are a Rust-crate-only escape hatch; contribute a constructor tolunaris-llamacpporlunaris-embed-remote.
GIL / async notes
Every .await in the binding sits inside a
pyo3_async_runtimes::tokio::future_into_py closure — the GIL is released
across awaits (CLAUDE.md mandate; brace-balanced scan test in
lunaris-codegen/tests/emitter_shape.rs, end-to-end proof in
crates/lunaris-py/tests/test_gil_discipline.py). So:
await handle.ingest(...)/await handle.recall()...execute()are realasyncioawaitables — use them inside an event loop (asyncio.run, an ASGI handler, etc.), not from synchronous code.- A long ingest in one task does not block other Python threads — the GIL is not held while Lunaris is in Rust.
- The
lunarisextension module is not part ofcargo test --workspace(it’s acdylibthat fails to link under the workspace test runner) — test Python code withmaturin develop+pytest, or viascripts/sdk-real-evidence.sh.
Troubleshooting
- “No matching distribution found for lunaris” — pip can’t find a wheel
for your Python ABI / platform. Check the target triple
(
python -c "import sysconfig; print(sysconfig.get_platform())"); if it isn’t one of the 5 above, do a source install (pip install lunaris --no-binary lunaris, needs Rust 1.94+). - “failed to open GGUF” / missing artifact — the in-process llama.cpp
embedder needs the GGUF staged. Download it out-of-band and verify the
SHA-256 printed by
cargo run -p lunaris-bench --bin stage-models -- --help, pointgguf_path/LUNARIS_EMBEDDER_GGUFat an existing copy, or (if you run through the MCP server) let it stage the artifact lazily on first recall. As an operator escape hatch, build with--features embed-remoteand setLUNARIS_EMBEDDER_OLLAMA_URL. native()/native_quantized()raises “removed in the llama.cpp-only cutover” — working as intended; swap the call tollamacpp(gguf_path=...).conformance_fixture_episodesnot exported — correct; that helper lives behind thebindings-itCargo feature, used only by the per-driver parity tests. Production wheels ship without it.
See also
- TypeScript SDK — the parallel surface
- The Retrieval DSL
- Configuration Reference — env vars / feature flags
crates/lunaris-py/— the binding crate
TypeScript SDK
npm install @pilotspace/lunaris gives you the same memory engine as the Rust crate,
behind a napi-rs 3.x binding generated from the same annotated surface — so
open / ingest / recall / forget / snapshot and the composable retrieve DSL
behave identically across all three SDKs. cargo run -p lunaris-codegen -- --check gates every PR, so the surfaces never drift.
Adapted from
docs/bindings.md(TypeScript half).
Install
npm install @pilotspace/lunaris
Prebuilt .node binaries ship for 5 targets (BIND-TS-05): linux-x64,
linux-arm64, darwin-x64, darwin-arm64, win32-x64.
Node 20 ABI
The NAPI ABI is pinned to version 8 (Node 20 LTS) via the napi8 feature
on the Rust napi dep. The abi_pin.spec.mts test asserts
process.versions.napi >= 8 at startup, so an older runtime (Node 18, which
ships NAPI 7) fails with a readable reason instead of a cryptic
undefined symbol: napi_get_value_* at dlopen time. Use Node 20 LTS or
later.
No matching binary? Source install (needs Rust 1.94+ and @napi-rs/cli):
npm install @pilotspace/lunaris --build-from-source
Quickstart
import { open, RetrievalBuilder } from "@pilotspace/lunaris";
async function main() {
const handle = await open("moon://127.0.0.1:6380");
const lsn = await handle.ingest({
id: "01JABCDEFGHJKMNPQRSTVWXYZ0", // 26-char Crockford-base32 ULID
source: "ts-quickstart",
content: "Lunaris bi-temporal memory — hello from TypeScript.",
metadata: {},
t_ref: null,
bt: {
valid: [{ wall_ms: 0, counter: 0, node_id: 0 }, null],
sys: [{ wall_ms: 0, counter: 0, node_id: 0 }, null],
},
});
console.log("ingested at LSN", lsn);
const hits = await new RetrievalBuilder().bind(handle).top(5).execute();
for (const h of hits) console.log(h);
}
main();
RetrievalBuilder/Vector/Keyword/Graphimported fromlunarisare the pure-JS plan builders the package’s ESM entry layers on top of the raw napi-rs classes — call.bind(handle)before.execute().handle.recall()returns the raw napiRetrievalBuilder, whose builder methods are not-yet-wired stubs; reach for the imported one instead.
moon://host:port is the only accepted URL scheme as of 0.7.0. See
The Storage Backend.
The wire shape
Episodes, forget requests, and hits cross the FFI as plain JavaScript
objects (deep-converted to/from the Rust structs). For the bare
handle.ingest(obj) path you build the bt bi-temporal stamp and the
26-char Crockford-base32 ULID id by hand, as in the quickstart.
For multi-agent partitioning the v0.2 surface also ships the typed
ergonomics — Scope, EpisodeBuilder, ScopedLunaris, and lunarisScoped
are all exported:
import { Scope, EpisodeBuilder, lunarisScoped } from "@pilotspace/lunaris";
const scoped = lunarisScoped(handle, Scope.new("acme.agent-1")); // ScopedLunaris
const lsn = await scoped.ingest(
new EpisodeBuilder("notes", "Lunaris ingest via the typed builder.")
);
const hits = await scoped.recall("what did agent-1 note?"); // scope-pinned recall
Scope.new("…") validates against [A-Za-z0-9_\-.]{1,128} and throws on a
bad string; EpisodeBuilder mirrors the Rust builder (new EpisodeBuilder(source, content),
then .metadata(obj) / .tRef(iso8601)), and its terminal conversion is crate-private, so only
ScopedLunaris.ingest can mint the scoped episode. The cross-language parity
test catches any divergence between the Python and TS surfaces.
The retrieval DSL
Vector, Keyword, Graph compose; camelCase aliases (fuseRrf, asOf)
and the filter(pred) / filterStr(s) split match the JS idiom. A terminal
.execute() collapses the plan into a single FFI call:
import { Keyword } from "@pilotspace/lunaris";
const hits = await handle
.recall() // seeds a builder with default root Vector("chunks", 30)
.and(Keyword.bm25("chunks", 30))
.fuseRrf(60) // Reciprocal Rank Fusion, k=60
.top(5)
.execute(); // takes no arguments in the v0.2 TS DSL
// hits is Hit[]; each Hit carries content, source, score, rawScore,
// validTime, sysTime, degraded (boolean), rerankApplied, sourceOp.
.execute() takes no arguments — the plan tree collapses to the
index / k / optional filter / as_of_ms knobs the recallSimpleExecute
FFI accepts (mirrors the Python side); a query-text setter on the builder is a
follow-up. Time-travel is one combinator (asOf). See
The Retrieval DSL for the full operator set.
Pipeline toggles (three surfaces)
GraphPipeline and ConsolidatorPipeline default OFF. Flip them at code,
env, or config; resolution order is code > env > config — code wins.
// code surface
handle.graphPipeline.enable();
handle.consolidatorPipeline.disable();
// config surface — opts object on the ergonomic open() wrapper
const handle = await open(url, {
graphPipeline: { enabled: true },
consolidatorPipeline: { enabled: false },
});
# env surface — read at open() time
export LUNARIS_GRAPH_ENABLED=1
export LUNARIS_CONSOLIDATE_ENABLED=0
See Consolidation & Verification and The Graph Pipeline.
Embedder / reranker config
v0.6 llama.cpp-only cutover. The candle-native embedder/reranker paths are deleted;
llamacpp(in-process llama.cpp, GGUF artifacts) is the only local inference runtime, on by default. Seedocs/sdk/embedder-config.mdanddocs/migration/0.5-to-0.6-llamacpp-only.md(the migration guide).
Override the default from code via EmbedderConfig / RerankerConfig,
surfaced as a chainable withEmbedder / withReranker extension on the
Lunaris class (camelCase opts bags):
import { open, EmbedderConfig, RerankerConfig } from "@pilotspace/lunaris";
// `withEmbedder` / `withReranker` are chainable and return a NEW handle.
const mem = (await open("moon://127.0.0.1:6380"))
.withEmbedder(EmbedderConfig.llamacpp()) // granite-r2 Q4_K_M GGUF, in-process llama.cpp
.withReranker(RerankerConfig.llamacpp()); // bge-reranker-v2-m3 Q5_K_M GGUF
Factories (camelCase, mirroring the Python surface):
| Factory | Use when |
|---|---|
EmbedderConfig.llamacpp(opts?) where opts = { ggufPath?: string } | Default — granite-embedding-311m-multilingual-r2 (768-d), Q4_K_M GGUF, in-process llama.cpp. Loads eagerly; raises on a missing/corrupt GGUF. Staged default: ~/.lunaris/models/granite-embedding-311m-multilingual-r2.Q4_K_M.gguf. |
EmbedderConfig.noop(dim?) | Deterministic zero-vector — tests / offline use only. |
RerankerConfig.llamacpp(opts?) where opts = { ggufPath?: string } | Default — BAAI/bge-reranker-v2-m3 cross-encoder (Q5_K_M GGUF, sigmoid ∈ [0,1]), in-process llama.cpp. Staged default: ~/.lunaris/models/bge-reranker-v2-m3.Q5_K_M.gguf. |
RerankerConfig.noop() | Skip the cross-encoder rescoring pass — lowest latency floor. |
Notes:
- No auto-download. Point
ggufPath(orLUNARIS_EMBEDDER_GGUF/LUNARIS_RERANKER_GGUF) at a pre-staged artifact; the MCP server stages GGUFs lazily on first recall, other deployments download them out-of-band and verify against the canonical SHA-256s (cargo run -p lunaris-bench --bin stage-models -- --help). - Retired:
EmbedderConfig.native()/.nativeQuantized()(and the reranker equivalents) were deleted in the v0.6 llama.cpp-only cutover; the factories still exist as stubs that raise immediately with a migration hint pointing atllamacpp({ ggufPath }). Seedocs/migration/0.5-to-0.6-llamacpp-only.md. - An air-gapped Ollama HTTP embedder remains available as an operator escape
hatch behind
--features embed-remote(LUNARIS_EMBEDDER_OLLAMA_URL), resolving after the llama.cpp step. - Tier-0
.nodeartifacts (built withdefault-features = false, no C++ toolchain, nollamacppfeature) raise a clear “no-inference build” error fromllamacpp()— usenoop()there. - FFI cliff: you cannot implement the Rust
Embedder/Rerankertrait from TypeScript — per-call FFI callbacks would be too slow for the hot path. Roll-your-own backends are Rust-crate-only; contribute a constructor tolunaris-llamacpporlunaris-embed-remote.
Async discipline
napi-rs 3.x’s tokio_rt feature routes #[napi] pub async fn through the
shared tokio runtime, so every method that returns a Promise is a real
awaitable. The “never hold a parking_lot::RwLock across .await” invariant
is enforced on the Rust umbrella side; the TS host crate’s emitted wrappers
take no locks themselves. Practical notes:
await handle.ingest(...)/await handle.recall()...execute()are ordinary Promises —awaitthem; don’t block the event loop on them.- The
lunarisaddon is not part ofcargo test --workspace(it’s acdylib) — test TS code withnapi build+vitest, or viascripts/sdk-real-evidence.sh. conformanceFixtureEpisodes/scanKvPrefixare only exported behind thebindings-itCargo feature (per-driver parity tests) — production builds ship without them.
Troubleshooting
undefined symbol: napi_get_value_*at load time — your Node runtime is older than NAPI 8 (Node 18 or lower). Upgrade to Node 20 LTS+.- “failed to open GGUF” / missing artifact — the in-process llama.cpp
embedder needs the GGUF staged. Download it out-of-band and verify the
SHA-256 printed by
cargo run -p lunaris-bench --bin stage-models -- --help, or pointggufPath/LUNARIS_EMBEDDER_GGUFat an existing copy. If you run through the MCP server, let it stage the artifact lazily on first recall. native()/nativeQuantized()raises “removed in the llama.cpp-only cutover” — working as intended; swap the call tollamacpp({ ggufPath }).
See also
- Python SDK — the parallel surface
- The Retrieval DSL
- Configuration Reference
crates/lunaris-ts/— the binding crate
MCP Server
lunaris-mcp exposes Lunaris memory to any Model Context Protocol
(MCP) agent — Claude Code, OpenAI Codex, and anything else that speaks MCP —
over the stdio transport. The agent gets persistent, scope-isolated
memory it can write to and recall from across sessions, with the same
bi-temporal storage and atomicity guarantees as the rest of Lunaris.
MCP ≠ MemoryProtocol 0.1. This page is about the agent-facing MCP server (a stdio JSON-RPC tool surface). The MemoryProtocol chapter is a separate HTTP/SSE wire protocol for the Lunaris HTTP server. They solve different problems and are not interchangeable.
Install
No Rust toolchain is required for the npx / uvx paths — both download a
prebuilt binary for your platform on first run.
# Rust (builds from source → ~/.cargo/bin/lunaris-mcp).
# NOT `cargo install lunaris-mcp` — the crate is publish = false (it links
# lunaris-memory-service, which has a vendor/ path dep) so it is not on
# crates.io. Needs cmake + a C++ compiler for llama.cpp.
cargo install --git https://github.com/pilotspace/lunaris lunaris-mcp
# Node (no Rust toolchain)
npx -y @pilotspace/lunaris-mcp --help
# Python (no Rust toolchain)
uvx lunaris-mcp --help
Supported prebuilt platforms: linux-x64, linux-arm64, darwin-x64,
darwin-arm64, win32-x64. On any other platform, build from source with
the cargo install --git form above — plain cargo install lunaris-mcp
always fails (the crate is not on crates.io).
Registry availability. The
npx/uvxpackages (@pilotspace/lunaris-mcp,lunaris-mcp) are published as part of the npx/uvx distribution wave; until your registry shows them, the always-available paths are the prebuiltlunaris-mcp-<target>.tar.gzbinaries attached to each GitHub release (since v0.6.1) or thecargo install --gitsource build. The npm/PyPI wrappers honourLUNARIS_MCP_BIN_PATHfor air-gapped hosts.
Tool surface
20 tools are registered (all implemented) — nine durable-memory tools, four working-memory (scratchpad) tools, five curation tools, and two retention tools:
| Tool | Input | Returns |
|---|---|---|
memory.ingest | source, content, optional t_ref, metadata | { lsn } |
memory.recall | query, optional k, filters, as_of | { hits[] } |
memory.forget | target.source_prefix XOR target.episode_id, optional dry_run (defaults to true) | { status, dry_run, matched, removed } |
memory.list_scopes | (none) | { scopes[] } |
memory.remember | kind (decision|fix|preference|constraint), content, optional why, tags, dedupe_key | { lsn, was_duplicate, source } |
memory.record_decision | decision, rationale, optional alternatives, tags, dedupe_key | { lsn, was_duplicate } |
memory.record_edit | path, after, optional before, intent, dedupe_key | { lsn, was_duplicate } |
memory.status | (none) | backend capability profile + MQ queue-depth probes for the three worker topics and the never-drained __lunaris_audit__ |
memory.feedback | memory_id, sentiment (±), reason (required), optional dedupe_key | { lsn, was_duplicate, activation_applied } |
memory.verify_agenda | optional limit | { count, items[] } — episodes the staleness sweep flagged (recorded git anchor no longer matches HEAD for files they reference) |
memory.resolve | episode_id, action (keep | invalidate), optional reason, superseded_by | { status, episode_id, invalidated, agenda_removed } |
memory.dream_agenda | optional limit, min_cluster_size, max_activation | { status, total_candidates, count, clusters[] } — read-only; writes nothing |
memory.distill | kind (decision | lesson | invariant | gotcha), content, source_episode_ids, optional title, tags, dedupe_key | { status, distilled_episode_id, lsn, archived_count, was_duplicate } |
memory.profile | optional limit_per_section | { markdown, counts, total } |
memory.scratchpad_write | key, value, optional namespace | { lsn } |
memory.scratchpad_read | key, optional namespace | { found, value } |
memory.scratchpad_grep | pattern, optional namespace | { entries[] } |
memory.scratchpad_consolidate | optional namespace | { status, promotions, archives } |
memory.retention | optional max_age_ms (omit to READ), hard | { status, configured, max_age_ms, hard } |
memory.retention_enforce | optional dry_run (defaults to true) | { status, dry_run, configured, max_age_ms, hard, cutoff_ms, matched, removed } |
memory.ingest is the general capture path. memory.record_decision and
memory.record_edit are structured aliases that write intent-typed episodes
(source = "decision:<scope>" / "edit:<scope>") with optional dedupe_key
idempotency. memory.status reports the bound scope and backend capabilities
(queue_native, graph_native, rerank_native, native_rrf,
max_vector_dim, cypher_dialect, …).
The four curation tools are what separate Lunaris from a vector store with
an MCP wrapper — they let an agent maintain its memory, not just append to
it. memory.verify_agenda surfaces memories the background staleness sweep
believes have gone out of date (the git anchor recorded with the episode no
longer matches HEAD for the files it references); memory.resolve acts on one
— keep prunes the agenda row and leaves the episode live, invalidate
soft-deletes it via an MVCC tombstone so it stops appearing in
memory.recall. memory.dream_agenda is the read-only planner for
distillation: it surfaces clusters of ripe (referenced, not-yet-distilled) raw
episodes with activation stats and writes nothing, and memory.distill is
the transactional apply step that writes the distilled prose back as a durable,
highest-priority episode (source = "distilled:{kind}:<scope>",
source_priority = 95). memory.feedback records explicit ± human/agent
feedback on one memory with a required reason, writing a strong reinforcement
signal to the activation ledger that moves its recall ranking.
The roster is pinned against the real binary by
crates/lunaris-mcp/tests/server_boot.rs::server_boots_and_lists_all_tools,
which spawns the process and drives initialize → tools/list.
The four memory.scratchpad_* tools are working memory — transient,
key-addressed notes (drafts, plans, in-progress state) under a scratchpad/
namespace, separate from the durable episode log. scratchpad_write/read
are key-value put/get, scratchpad_grep lists entries by key-prefix, and
scratchpad_consolidate drains the scratchpad queue and promotes/archives
notes by activation. scratchpad_consolidate needs a native-queue backend;
Moon has one, so on 0.7.0 it is always available. (It still returns
{ status: "unsupported_backend" } if the connected substrate reports no
queue — see Storage.)
memory.forget previews by default: with dry_run omitted it scans,
returns { status: "preview", matched: N, removed: 0 }, and writes nothing.
Deleting takes an explicit "dry_run": false. This inverts the HTTP
POST /v1/forget default (dry_run: false there, for API compatibility) on
purpose — the MCP caller is a language model, so the irreversible branch must
be the one it has to ask for.
The wire DTOs are identical across MCP clients, and every request DTO carries
#[serde(deny_unknown_fields)] — no wire field can override the bound scope.
Progressive disclosure — the retrieval ladder
The server instructions (returned at MCP initialize) and every tool
description teach connecting agents to retrieve cheapest-first:
scratchpad_read/scratchpad_grep— exact or prefix key lookup; returns full verbatim values; no model load. Always first for known keys.memory.recallwith the defaultk = 5— hybrid semantic + BM25 preview pass. Hits are 200-character snippets (withepisode_id,source,score), not full episode text, and the first call in a process stages/loads the GGUF embedder.- Widen only on a miss — raise
k, addfilters.source_prefix(decision:,edit:,claude-code:), or passas_offor a bi-temporal point-in-time view.
There is intentionally no fetch-full-episode tool: widen k for more
context, or keep full-fidelity values in the scratchpad where reads are
verbatim. memory.status / memory.list_scopes are diagnostics, not
retrieval.
Scope is bound at startup
lunaris-mcp resolves one scope when it starts and never changes it from wire
payloads. Resolution order:
--scopeflag /LUNARIS_MCP_SCOPEenv var (highest priority).git remote.origin.url+ current branch → blake3 →"git_<hex16>".- Canonical cwd → blake3 →
"cwd_<hex16>".
The resolved scope is persisted to ~/.lunaris/scopes.json. To rename it,
edit the name field there and restart the host agent (which restarts the
lunaris-mcp child). See Multi-Agent & Scope for
the scope model.
Storage
Moon is the only backend, and the server resolves it in exactly this order:
| # | Source | Notes |
|---|---|---|
| 1 | --storage / LUNARIS_MCP_STORAGE | Explicit always wins. |
| 2 | ~/.lunaris/contextd-moon.url | The store a running lunaris-contextd advertises, adopted only after a loopback + RESP PING liveness probe (25 ms, LUNARIS_MOON_DISCOVERY_TIMEOUT_MS). |
| 3 | — | Refuses to boot, printing the quickstart. |
Step 2 is why an MCP server and the lunaris-hook daemon on the same machine
land in the same Moon without being configured twice — lunaris-hook has
always resolved this way, and lunaris-mcp now does too. An
advertised and probed store is not a guessed default: a stale file (contextd
crashed, its port recycled) fails the probe and is declined, which lands you in
step 3 rather than in somebody else’s Moon. The file is read once, at boot —
start contextd first, then the agent.
There is still no default. Through 0.6.x an unset value opened a per-scope
SQLite file at ~/.lunaris/<scope>.db; 0.7.0 deleted that backend, and nothing
guesses a store in its place — not SQLite, and not a hardcoded
moon://127.0.0.1:6380 either. A stdio server surfaces tool errors to its
client but not startup logs, so “starts, then fails every call” (or worse,
“starts, and quietly writes into an unrelated Moon”) was the worst outcome
available.
docker run -d --name lunaris-moon -p 6381:6379 \
ghcr.io/pilotspace/moon:0.8.5 \
--shards 1 --protected-mode no --appendonly yes
"env": { "LUNARIS_MCP_STORAGE": "moon://127.0.0.1:6381" }
--shards 1 is mandatory — an ingest is one MULTI/EXEC transaction and a
sharded Moon rejects it. All 20 tools work against Moon: native HNSW
vector search, BM25 keyword fusion, graph, queues, and search-side bi-temporal
reads. See
Running an external Moon.
Auto-launched Moon (opt-in build, development only). A source build with
cargo build -p lunaris-mcp --features embedded-moonmakeslunaris-mcplaunch an in-process Moon (rooted at./.lunaris-moon) when noLUNARIS_MCP_STORAGEoverride is set, then use it automatically. The feature is off by default and is not compiled into the publishednpx/uvx/cargo installbinaries. An explicit--storage/LUNARIS_MCP_STORAGEstill wins; a failed bring-up is now terminal (it used to fall back to SQLite — there is nothing to fall back to).
The first
memory.recallstages the GGUF embedder (~150 MB) and reranker to~/.lunaris/models/— expect ~30 s on a cold start, fast thereafter. SetLUNARIS_MCP_SKIP_STAGE=1if models are pre-staged.
Key environment variables
| Variable | Default | Description |
|---|---|---|
LUNARIS_MCP_SCOPE | derived from git/cwd | Force a specific scope name |
LUNARIS_MCP_STORAGE | (no default) | Storage URL. moon://host:port only. Unset: the server falls back to a live lunaris-contextd store advertised in ~/.lunaris/contextd-moon.url, else refuses to boot |
LUNARIS_MOON_DISCOVERY_TIMEOUT_MS | 25 | Liveness-probe budget for that discovery file (0 disables discovery) |
LUNARIS_GRAPH_ENABLED | off | Enable the graph extraction/write path (Moon graph recall) |
LUNARIS_MCP_LOG | info,rmcp=warn | tracing-style filter directive (logs to stderr only) |
LUNARIS_MCP_SKIP_STAGE | unset | Set to 1 to skip GGUF staging on first recall |
LUNARIS_MCP_BIN_PATH | unset | (npx/uvx wrappers) point at a pre-staged binary for air-gapped hosts |
stdout is the JSON-RPC transport.
lunaris-mcpwrites logs to stderr only. Anything printed to stdout (e.g. anechoin your shell profile) corrupts the MCP framing and silently disconnects the host agent.
Per-agent guides
- Claude Code —
claude mcp add, project-scoped.mcp.json, and the optional lifecycle hooks + context injection. - Codex CLI —
~/.codex/config.toml, plus hooks and thelunaris-contextdwarm sidecar.
The exhaustive guides — full hook tables, lunaris-contextd internals, and
measured timings — live in the repo:
docs/integration/claude-code.md
and
docs/integration/codex.md.
Status
The stdio transport is the supported path (Wave A). SSE transport with
Bearer auth and multi-user server mode are deferred to a later OIDC
milestone; running MCP as a feature flag on lunaris-server was evaluated and
rejected — see the
decision record.
Claude Code
Connect Lunaris to Claude Code as a
stdio MCP server. Once registered, the agent can call
the eleven memory.* tools to persist and recall
scope-isolated memory across sessions.
Register the server
Pick whichever runner you installed (see Install):
# cargo-installed binary, shared with your team via the repo's .mcp.json
claude mcp add --scope project --transport stdio lunaris \
-e LUNARIS_MCP_STORAGE=moon://127.0.0.1:6381 \
-- lunaris-mcp
# no Rust toolchain (Node)
claude mcp add --transport stdio lunaris \
-e LUNARIS_MCP_STORAGE=moon://127.0.0.1:6381 \
-- npx -y @pilotspace/lunaris-mcp
# no Rust toolchain (Python)
claude mcp add --transport stdio lunaris \
-e LUNARIS_MCP_STORAGE=moon://127.0.0.1:6381 \
-- uvx lunaris-mcp
--scope project writes a VCS-shared .mcp.json at the repo root:
{
"mcpServers": {
"lunaris": {
"command": "lunaris-mcp",
"args": []
}
}
}
Verify it is listed:
claude mcp list
Expect a lunaris entry with stdio transport.
Walkthrough
Start Claude Code in the repo (claude). The lunaris-mcp process starts as
a child; the scope is derived automatically from git remote.origin.url +
branch (or the cwd if there is no git remote). Then, inside a session:
memory.ingest source="src:notes/architecture" content="The ingest pipeline writes one atomic_write per episode. Adding a second call is a bug."
{ "lsn": "1748083200000:1" }
The LSN is "{wall_ms}:{counter}" — monotonically increasing within the
scope. Recall it back:
memory.recall query="ingest pipeline atomicity" k=3
Each hit carries episode_id, source, content (≤200 chars), score
(0–1), and ingested_at (RFC-3339). Recall against Moon is hybrid — native
HNSW vector search fused with BM25 keyword search.
LUNARIS_MCP_STORAGEhas no default (0.7.0). Set it, or runlunaris-contextdand let the server adopt the store contextd advertises (liveness-probed); with neither, it refuses to boot. See Storage.
Point at Moon
To get semantic, hybrid, and graph recall at scale, set the storage URL in
the server’s env block:
{
"mcpServers": {
"lunaris": {
"command": "lunaris-mcp",
"args": [],
"env": {
"LUNARIS_MCP_STORAGE": "moon://127.0.0.1:6381",
"LUNARIS_GRAPH_ENABLED": "1"
}
}
}
}
Run Moon with ../moon/target/release/moon --port 6381.
Hooks & context injection (optional)
Beyond the explicit tools, a Lunaris checkout can install Claude Code
lifecycle hooks that capture prompts, tool calls, compaction, and subagent
boundaries automatically, and inject recalled memory through Claude Code’s
hookSpecificOutput.additionalContext field:
scripts/setup-lunaris-agents.py --agent claude --runner local # MCP + hooks
scripts/setup-lunaris-agents.py --agent claude --runner local --hooks off # MCP only
scripts/setup-lunaris-agents.py --agent claude --runner local --dry-run # preview
The script backs up ~/.claude/settings.json before writing. Full hook
table, the lunaris-contextd warm sidecar, and measured timings are in
docs/integration/claude-code.md.
Troubleshooting
| Symptom | Fix |
|---|---|
lunaris-mcp: command not found | Add export PATH="$HOME/.cargo/bin:$PATH" to your shell profile, restart the terminal, re-run claude mcp add. |
| Connected but no tools appear | The initialize handshake failed. Run lunaris-mcp directly; any startup error prints to stderr. |
memory.recall returns empty | Nothing ingested into this scope yet — run memory.ingest first. Otherwise check that LUNARIS_MCP_STORAGE names the Moon you ingested into. |
| First recall takes ~30 s | One-time GGUF staging to ~/.lunaris/models/. Set LUNARIS_MCP_SKIP_STAGE=1 if pre-staged. |
| Silent disconnect | Something is writing to stdout (often an echo/print in .bashrc/.zshrc). It corrupts MCP framing. |
| Wrong scope | Run memory.list_scopes; set LUNARIS_MCP_SCOPE or rename the entry in ~/.lunaris/scopes.json and restart. |
Codex CLI
Connect Lunaris to the Codex CLI as a
stdio MCP server. The tool surface is identical to Claude Code —
the same eleven memory.* tools, the same wire
DTOs — only the configuration file differs.
Configure the server
Codex reads MCP server definitions from ~/.codex/config.toml (override the
directory with CODEX_HOME). Add a [mcp_servers.lunaris] table for whichever
runner you installed:
# cargo-installed binary
[mcp_servers.lunaris]
command = "lunaris-mcp"
args = []
# no Rust toolchain (Node)
[mcp_servers.lunaris]
command = "npx"
args = ["-y", "@pilotspace/lunaris-mcp"]
# no Rust toolchain (Python)
[mcp_servers.lunaris]
command = "uvx"
args = ["lunaris-mcp"]
Codex starts lunaris-mcp as a stdio child and is ready once the MCP
initialize handshake completes. Validate the config with:
codex doctor
Walkthrough
Start Codex in the repo (codex). The scope is derived from
git remote.origin.url + branch (or cwd if there is no git remote). Then:
memory.ingest source="src:notes/architecture" content="The ingest pipeline writes one atomic_write per episode."
{ "lsn": "1748083200000:1" }
memory.recall query="ingest pipeline atomicity" k=3
LUNARIS_MCP_STORAGEhas no default (0.7.0). Set it, or runlunaris-contextdand let the server adopt the store contextd advertises (liveness-probed); with neither, it refuses to boot. See Storage.
Override scope or point at Moon
Set environment variables under [mcp_servers.lunaris.env]:
[mcp_servers.lunaris]
command = "lunaris-mcp"
args = []
[mcp_servers.lunaris.env]
LUNARIS_MCP_SCOPE = "my-project"
LUNARIS_MCP_STORAGE = "moon://127.0.0.1:6381"
LUNARIS_GRAPH_ENABLED = "1"
Run Moon with ../moon/target/release/moon --port 6381.
Hooks, injection & the warm sidecar (optional)
Codex supports the same automatic capture and proactive context injection as Claude Code, built from three local binaries:
| Binary | Purpose |
|---|---|
lunaris-mcp | MCP tools for explicit memory operations |
lunaris-hook | fast async event capture into Lunaris storage |
lunaris-contextd | warm sidecar keeping model + storage handles hot for low-latency recall |
The one-command setup installs the [mcp_servers.lunaris] table, the capture
hooks (session_start, user_prompt_submit, pre/post_tool_use,
pre/post_compact, subagent_*, stop), and synchronous injection for
user_prompt_submit + post_tool_use:
scripts/setup-lunaris-agents.py --agent codex --runner local # MCP + hooks
scripts/setup-lunaris-agents.py --agent codex --runner local --hooks off # MCP only
scripts/setup-lunaris-agents.py --agent codex --runner local --dry-run # preview
It backs up ~/.codex/config.toml before writing. Injected memory arrives as
a compact <lunaris_memory_context> block; if the sidecar is down, slow, or
returns no high-confidence hits, Codex continues normally with no injected
memory. Full hook config, modes, and measured timings are in
docs/integration/codex.md.
Troubleshooting
| Symptom | Fix |
|---|---|
lunaris-mcp: command not found | Add export PATH="$HOME/.cargo/bin:$PATH" to your shell profile, restart the terminal. |
| Tools don’t appear after Codex starts | Run lunaris-mcp directly; any startup error prints to stderr. |
memory.recall returns empty | Nothing ingested into this scope yet — run memory.ingest first. Otherwise check that LUNARIS_MCP_STORAGE names the Moon you ingested into. |
| First recall takes ~30 s | One-time GGUF staging to ~/.lunaris/models/. Set LUNARIS_MCP_SKIP_STAGE=1 if pre-staged. |
| Silent disconnect | A shell-profile echo/print is writing to stdout and corrupting MCP framing. |
| Wrong scope | Run memory.list_scopes; set LUNARIS_MCP_SCOPE in [mcp_servers.lunaris.env]. |
PersonaMem: measuring against TencentDB Agent Memory
2026-08-17 — self-measured, fully reproducible from this repo.
TencentDB-Agent-Memory publishes a headline of 76% with memory / 48% without on PersonaMem, the persona-tracking benchmark where an assistant must answer multiple-choice questions about a user it has talked to across dozens of sessions — current preferences, the reasons they changed, and what a good personalized reply looks like now. We ran the same benchmark against Lunaris end to end and publish everything: numbers, per-category breakdown, harness source, and the caveats.
Results — 32k split, 589 questions, zero errors
Measured on the quality operating point (rerank ON). That is not
Lunaris’s shipped default, which is the fast path with rerank off —
see operating points.
No PersonaMem arm has yet been run on the shipped default.
| configuration | accuracy |
|---|---|
| Lunaris + claude-sonnet-5 reader (single reader — the system result) | 75.0% (442/589) |
| No-memory floor (same reader, options only) | 41.9% (247/589) |
| TencentDB-Agent-Memory (published; split/reader unstated) | 76% / 48% |
| Two-reader oracle cascade — upper bound, not a system result | 81.8% (482/589) |
Three claims, stated precisely:
- The memory lift is +33.1 points (75.0 vs 41.9 with the identical reader). Tencent’s published lift is +28 (76 vs 48). Lunaris delivers a larger lift from a lower floor. Tencent’s release does not state its split or reader model, so treat that comparison the way you should treat every cross-system benchmark table: context, not a controlled head-to-head.
- 81.8% is an oracle bound, and we will not lead with it. claude-opus-5 re-answered exactly the 147 questions the Sonnet arm got wrong (fixing 40 of them), and gold labels decided which questions went to the second reader. A deployable cascade would need a gold-free routing rule. The system result is the single-reader 75.0%.
- 75.0% is understated. 93 of the 589 questions live in a category that does not measure memory at all — see below.
What was actually measured
No shortcut path. Every conversation message is ingested through the
production write path (CodingSessionMemory::write → Lunaris::ingest:
chunk, embed, index), and every question is answered from the production
hybrid recall root — Vector ∧ BM25 → reciprocal-rank fusion →
cross-encoder rerank → top-30 — plus two retrieval features this
benchmark motivated:
- Neighbor expansion — each hit is rendered with ±2 surrounding messages, so a mid-dialogue hit arrives with the turn that prompted it.
- Per-candidate evidence retrieval — the store is queried with each answer option’s own text, and the reader sees the most similar past messages per candidate. A recycled candidate exposes its own near-duplicate; a factual claim gets its receipts.
Temporal honesty is enforced twice: the store is append-only per context and a question is asked the moment its prefix — and nothing after it — is ingested (structural), and any retrieval hit at or past the prefix boundary marks the question as an error rather than an answer (runtime). Scoring is exact letter match; there is no LLM judge and therefore no judge noise floor.
Per-category
| category | sonnet (system) | floor | cascade (oracle) |
|---|---|---|---|
| reasons behind preference updates | 90.9% | 65.7% | 97.0% |
| full preference evolution | 82.0% | 78.4% | 91.4% |
| recall user-shared facts | 78.3% | 2.3% | 83.7% |
| generalizing to new scenarios | 75.4% | 10.5% | 82.5% |
| preference-aligned recommendations | 74.5% | 21.8% | 80.0% |
| facts mentioned by the user | 58.8% | 17.6% | 64.7% |
| ⚠ suggest new ideas — does not measure memory | 46.2% | 52.7% | 52.7% |
The fact-recall rows are where a memory system earns its keep: 2.3% without memory, 78.3% with it on a single reader.
The last row is a broken benchmark question, not a Lunaris result
In suggest_new_ideas the no-memory floor matches the best memory
configuration. We spent a long time treating that as a retrieval finding.
It is not. The gold answer in that category is essentially always the
shortest option: a classifier that reads nothing at all — not the
question, not the memories, not the persona — and simply picks the
shortest candidate scores 98.9% there, against 0–15.5% in every other
category and a 25% random baseline. Gold averages 245 characters against
564 for the distractors (2.3×); every other category sits between 0.75×
and 1.17×.
The distractors are the long, persona-woven ones, so retrieved persona content makes the wrong answer look better supported. That is the entire “memory net-harms this category” effect, and it is a property of how the dataset was constructed.
We do not optimise against it. A shortness preference would score ~99% here and mean nothing, while making real suggestions worse — in production, building on what we know about a user is the desirable behaviour this category punishes. The 93 questions stay in the denominator, so our 75.0% is understated rather than inflated, and a regression test pins the dataset property in both directions so it cannot be re-diagnosed as a retrieval defect.
Full root-cause analysis: issue #141.
Reproduce it
The harness ships in this repo — dataset download, incremental ingest, both arms, per-question artifacts, and the combiner:
ARM=memory TOPK=30 NEIGHBORS=2 EVIDENCE=3 scripts/bench/pm/run_pm.sh
ARM=nomem scripts/bench/pm/run_pm.sh
Full numbers, config fingerprints, and the second-reader protocol:
scripts/bench/pm/RESULTS.md.
Harness source:
crates/lunaris-bench/src/eval/personamem/.
Migrating from Mem0
Adapted from
docs/MIGRATING-FROM-MEM0.md(kept in the repo as the standalone version).
Lunaris and Mem0 occupy adjacent niches in the agent-memory space. This page maps Mem0 concepts to their Lunaris equivalents so a team already running Mem0 can evaluate the switch with concrete code, not marketing comparisons.
TL;DR — if your agent needs sub-25 ms recall with provable all-or-nothing commits across vector + graph + keyword + audit, the Mem0 fan-out architecture cannot give you that guarantee. If your agent needs a hosted SaaS with zero infra and minute-scale recall latency, stay on Mem0. The two tools answer different questions.
At a glance
| Concern | Mem0 | Lunaris |
|---|---|---|
| Runtime | Python / hosted REST API | Rust core + Python (PyO3) + TypeScript (NAPI) bindings |
| Storage | Vector DB + graph DB + relational (3 services) | Moon (one substrate, FT.* + graph + KV native) |
| Atomicity | Best-effort per-store; no cross-store transaction | One atomic_write covers vector + KV + BM25 + audit + queue. CI gate enforces single call site |
| Bi-temporal facts | Not modeled — overwrite semantics | First-class (valid_time, sys_time) tuple per row |
| Recall latency (laptop, 1M facts) | p95 ~1.44 s (Mem0-published “selective” figure, 2026-06; wide query-dependent range) | p50 19.2–22.4 ms / p99 23.4–24.4 ms measured at 100k documents per scope (not 1M — see note below): single-shard Moon v0.8.5, Apple M4 Pro, graph OFF, rerank OFF, k=30, retrieval-only, manual bench (not CI-gated) — capacity.md. Budget p50 ≤ 25 ms / p99 ≤ 100 ms |
| Tenancy | Per-user “user_id” string | Scope newtype with regex-validated alphabet [A-Za-z0-9_\-.]{1,128}, propagated through every storage call and enforced by a per-scope Moon keyspace |
| Forgetting | Hard delete | Tombstone via bi-temporal sys_time close (audit trail preserved) |
| License | Apache 2.0 | Apache 2.0 |
| Default LLM coupling | OpenAI | None — extractor/verifier are remote-only (anthropic/openai/gemini/minimax/openai-compat) or a custom impl; both optional |
Code-side comparison
Ingest one observation
Mem0
from mem0 import Memory
m = Memory()
m.add(
messages=[{"role": "user", "content": "Alice joined Acme on 2024-04-01."}],
user_id="alice",
)
Lunaris (Python — equivalent surface)
import lunaris
mem = lunaris.Lunaris.open("moon://localhost:6380")
scope = lunaris.Scope("user.alice")
mem.scoped(scope).ingest(
lunaris.EpisodeBuilder("chat:session-1/turn-1",
"Alice joined Acme on 2024-04-01.")
)
Two notable differences:
scopeis a typed object, not a string. Wire-side payloads cannot inject a different scope past the type system — theScopedLunariswrapper threads the validatedScopethrough every storage call. (The typedScope/EpisodeBuilderSDK ergonomics land in v0.3; today the Python surface uses dicts — see the Python SDK page.)- The ingest call returns an
Lsn(Log Sequence Number) so you can wait on the audit envelope before responding to the user — useful when the agent’s reply depends on a fact being committed.
Recall
Mem0
hits = m.search(query="when did Alice join Acme?", user_id="alice")
# returns a list of dicts; no fusion control, no time-travel.
Lunaris
hits = await (
mem.scoped(scope)
.recall() # pre-bound builder, default root Vector("chunks", 30)
.and_(lunaris.Keyword.bm25("chunks", 30))
.fuse_rrf(60)
.top(5)
.execute() # plan collapses to one FFI call; no query-text arg yet
)
# hits is List[Hit]; each Hit carries score (RRF-fused), raw_score,
# content, source, valid_time, sys_time, degraded (bool).
The composable retrieval DSL means you opt into hybrid search, re-rank, graph traversal, or time-travel — each is one combinator, and the type system rejects mixing incompatible operators. See The Retrieval DSL.
Time-travel recall (no Mem0 equivalent)
Backend note (v0.6.2).
.as_of(<past timestamp>)needs a backend that keeps a KV version chain to hydrate the historical rows, and no 0.7.0 backend does: the call returnsStorageError::NotSupported(HTTP501 not_supported). Moon stores Lunaris rows as plain hashes and refuses a historical pin rather than silently answering with present-time data; the Postgres and SQLite backends that answered it were deleted in 0.7.0. The search and graph lanes stay temporal (FT.SEARCH AS_OF,GRAPH.QUERY VALID_AT).
from datetime import datetime, timezone
snapshot_ms = int(datetime(2024, 6, 1, tzinfo=timezone.utc).timestamp() * 1000)
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.as_of(snapshot_ms) # ← bi-temporal cut (ms since the Unix epoch)
.execute()
)
# Returns facts as they were known on 2024-06-01. Later updates
# (corrections, retractions) are invisible to this query.
Forget a fact (audit trail preserved)
Mem0
m.delete(memory_id="<uuid>")
# Row is gone; no record that it ever existed.
Lunaris
await mem.forget(lunaris.ForgetTarget.episode_id(episode_id))
# Closes sys_time on the row(s). Audit log records the close.
# Time-travel queries with as_of < forget_ts still see the row.
This is why GDPR / SOC2 audits prefer the bi-temporal model — you can prove “this fact was retracted at time T” without throwing away the evidence that it ever existed. See Forgetting.
Migration checklist
A team running Mem0 in production typically migrates incrementally. Suggested phases:
- Stand up Lunaris alongside Mem0. Use the
examples/quickstart-pyrecipe — 5-minute docker-compose, no infra commitment. - Mirror writes. Every
m.add(...)is also dispatched tomem.scoped(scope).ingest(...). Lunaris is now collecting bi-temporal facts in parallel. - Shadow reads. Every
m.search(...)is also issued to Lunaris’s recall DSL. Diff the result sets in your eval harness. - Cutover when the diff is acceptable. Promote Lunaris to primary; keep Mem0 as fallback for ~1 release.
- Decommission Mem0. At this point you own one Rust process
instead of three Python services. Recall p50 measures 19–22 ms on a
100k-document scope (
capacity.md— manual bench, not CI-gated); measure your own Mem0 baseline (Mem0’s published figure is a p95 ~1.44 s, selective).
When NOT to migrate
If any of these is true, stay on Mem0:
- You need a hosted SaaS with zero infra ownership.
- Recall latency in the 100–500 ms range is fine.
- You don’t need bi-temporal queries (every fact is “current”).
- You’re building a single-user, single-tenant prototype where
Scopewould be overhead. - Your stack is pure Python and adding a Rust binary to the deploy pipeline is more friction than it’s worth.
Lunaris is built for production agent platforms — internal-first, performance-bound, audit-bound. Mem0 is built for developer productivity at the prototype stage. Use the right tool.
Open questions / known gaps
- Bulk import. No first-class
m.add_many(messages)analogue yet; v0.3 will add a bulk-ingest path that amortises the embed- atomic_write call. Today you call
ingest()in a loop.
- atomic_write call. Today you call
- Mem0’s metadata-extraction prompts. Lunaris ships an
Extractor trait with remote-only backends (
ollama/cloud-api, selected viaLUNARIS_EXTRACT_PROVIDER), but the default prompt set differs from Mem0’s. If your existing Mem0 deployment depends on specific extracted fields, overrideExtractor::extractand port your prompt. - Graph queries. Mem0 OSS v3 removed graph support — it is now
Platform-only (Mem0g, Neo4j-backed, with an LLM on the read path and
open deletion bugs). Lunaris’s
Graph::anchored(entity_ids, hops)is an opt-in operator, off by default, with no LLM on the read path. Both require the entity-resolution Extractor pipeline to have populated(entity, relation)triples first.
See docs/RELEASE.md for the current v0.2.x release scope and what
lands in v0.3. The Zep and Cognee pages
cover the parallel migration stories.
Migrating from Zep
Adapted from
docs/MIGRATING-FROM-ZEP.md(kept in the repo as the standalone version).
Zep and Lunaris both model agent memory bi-temporally — they’re the closest comparison in the space. The difference is the substrate: Zep is a hosted Python service backed by Postgres + Neo4j; Lunaris is an embedded Rust core backed by Moon that runs in-process with your agent.
This page maps Zep concepts to their Lunaris equivalents so a team already running Zep can evaluate the switch with concrete code.
TL;DR — if your agent needs hosted SaaS memory with a managed Knowledge Graph and accepts 200–500 ms recall latency, Zep is production-ready and well-documented. If your agent needs sub-25 ms recall, embedded deployment, or a Rust-native stack with no external graph service, Lunaris’s single-substrate architecture is a meaningful simplification.
At a glance
| Concern | Zep | Lunaris |
|---|---|---|
| Runtime | Python service (Zep Cloud or self-hosted) | Embedded Rust core + Python (PyO3) + TypeScript (NAPI) bindings |
| Storage | Postgres + Neo4j (two services) | Moon (one substrate, FT.* + graph + KV native) |
| Bi-temporal model | Temporal Knowledge Graph — facts carry validity periods | (valid_time, sys_time) tuple per row — Snodgrass bi-temporal at the storage model. Read the scope before migrating: as-of reads work on the search and graph lanes (FT.SEARCH AS_OF, GRAPH.QUERY VALID_AT); a historical KV read has no version chain to walk on Moon, so read_as_of beyond a 1-hour live window refuses (NotSupported → HTTP 501) rather than answering with today’s data |
| Recall latency | 200–500 ms (HTTP hop + Python) | p50 ≤ 25 ms / p99 ≤ 100 ms on laptop-arm64 |
| Tenancy | user_id / session_id strings on the API | Scope newtype ([A-Za-z0-9_\-.]{1,128}) threaded through every storage call + per-scope Moon keyspace |
| Atomicity | Per-store best-effort; no cross-store transaction | One atomic_write covers vector + KV + BM25 + audit + queue. CI gate enforces single call site |
| Graph queries | Cypher via Neo4j | Moon native graph (Cypher dialect); the Graph::anchored operator lowers to it |
| Embedder coupling | OpenAI default | In-process native granite-r2 (local, 768-d) by default; Ollama HTTP escape hatch (--features embed-remote) for air-gapped/remote deployments |
| Memory consolidation | “MemGPT-style” salience-weighted episodic | ACT-R base-level activation + Leiden community detection (RFC blueprint §5.1) |
| License | Apache 2.0 | Apache 2.0 |
Where Zep and Lunaris differ in spirit
Zep is service-oriented: your agent talks to Zep over HTTP, and Zep owns Postgres + Neo4j behind a managed API. The strength is clean separation; the cost is one network hop per recall, two if you fan out to graph + vector.
Lunaris is library-oriented: your agent links the Rust crate (or imports the PyO3 / NAPI binding) and the recall happens in-process. The strength is sub-25 ms p50 and a single substrate to operate; the cost is operating Moon yourself.
Either choice can be right. Zep is the right choice if your team treats memory as someone else’s problem to operate. Lunaris is the right choice if your team treats memory as a hot-path performance contract and wants library control.
Code-side comparison
Add a conversational turn
Zep
from zep_python import ZepClient, Memory, Message
client = ZepClient(api_key="...")
memory = Memory(
messages=[Message(role="user", content="Alice joined Acme on 2024-04-01.")]
)
client.memory.add_memory(session_id="alice-session-1", memory=memory)
Lunaris
import lunaris
mem = lunaris.Lunaris.open("moon://localhost:6380")
scope = lunaris.Scope("alice-session-1")
mem.scoped(scope).ingest(
lunaris.EpisodeBuilder("chat:session-1/turn-1",
"Alice joined Acme on 2024-04-01.")
)
Zep’s session_id maps to Lunaris’s Scope. The Lunaris type
system guarantees the scope can’t be smuggled past the API — every
storage call takes &Scope, and the per-scope Moon keyspace enforces it. (The typed
Scope / EpisodeBuilder SDK ergonomics land in v0.3; today the
Python surface uses dicts — see the Python SDK page.)
Recall — semantic
Zep
result = client.memory.search_memory(
session_id="alice-session-1",
text="when did Alice join Acme?",
)
# result.facts: List[Fact]; each Fact has content, valid_at, invalid_at.
Lunaris
hits = await (
mem.scoped(scope)
.recall() # pre-bound builder, default root Vector("chunks", 30)
.top(5)
.execute() # plan collapses to one FFI call; no query-text arg yet
)
# hits is List[Hit]; each Hit has content, valid_time, sys_time, score, degraded.
Recall — hybrid (vector + BM25 + RRF fusion)
Zep does not expose hybrid retrieval as a first-class API — you get semantic search; keyword fall-back is up to you.
Lunaris
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.and_(lunaris.Keyword.bm25("chunks", 30))
.fuse_rrf(60) # Reciprocal Rank Fusion, k=60
.top(5)
.execute()
)
When your query contains a capitalized proper noun (e.g., “Acme”), the BM25 branch tends to outscore vector — RRF fusion catches that without you having to write a router.
Time-travel recall
Backend note (v0.6.2).
.as_of(<past timestamp>)needs a backend that keeps a KV version chain to hydrate the historical rows, and no 0.7.0 backend does: the call returnsStorageError::NotSupported(HTTP501 not_supported). Moon stores Lunaris rows as plain hashes and refuses a historical pin rather than silently answering with present-time data; the Postgres and SQLite backends that answered it were deleted in 0.7.0. The search and graph lanes stay temporal (FT.SEARCH AS_OF,GRAPH.QUERY VALID_AT).
Zep
# Zep's facts carry valid_at / invalid_at; you can filter post-hoc:
result = client.memory.search_memory(session_id="alice", text="...")
fresh = [f for f in result.facts if f.valid_at <= snapshot_ts and (f.invalid_at is None or f.invalid_at > snapshot_ts)]
Lunaris
snapshot_ms = int(snapshot_ts.timestamp() * 1000)
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.as_of(snapshot_ms) # ← bi-temporal cut at the storage layer (ms since epoch)
.execute()
)
The Zep approach pulls every fact then filters in Python; Lunaris pushes the temporal cut into the storage query (native bi-temporal on Moon). On 1M-fact corpora the latency difference is meaningful.
Graph traversal
Zep
# Knowledge Graph is exposed via search_memory; can't anchor traversal
# from a specific entity programmatically without dropping to Neo4j.
Lunaris
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.and_(lunaris.Graph.anchored(entity_ids=[alice_id], hops=2))
.fuse_rrf(60)
.top(10)
.execute()
)
Graph::anchored resolves to a native graph query on Moon. See
The Graph Pipeline.
Forget
Zep
client.memory.delete_memory(session_id="alice-session-1")
# Hard delete — session and its memories gone.
Lunaris
await mem.forget(lunaris.ForgetTarget.episode_id(episode_id))
# Closes sys_time on the affected rows. Audit log records the close.
# Time-travel queries with as_of < forget_ts still see the row.
This is the GDPR/SOC2-friendly shape: the fact “we retracted this on date T” is itself recorded. See Forgetting.
Migration checklist
- Stand up Lunaris alongside Zep. Use
examples/quickstart-py. No infra commitment beyond docker-compose. - Map
session_id→Scope. One-line conversion. Validate that all your session IDs match[A-Za-z0-9_\-.]{1,128}. If they don’t, replace://with.(most common compat work). - Mirror writes. Every
memory.add_memory(...)is also dispatched tomem.scoped(scope).ingest(...). - Shadow reads. Issue every
memory.search_memory(...)to Lunaris in parallel. Diff the result sets in your eval harness. - Cutover when the diff is acceptable. Promote Lunaris to primary; keep Zep as fallback for ~1 release.
- Decommission Zep. You’re now running one Rust process (your agent) + Moon. No Python service to operate, no Neo4j to back up.
When to stay on Zep
- You’re not ready to operate Moon and prefer Zep Cloud’s hosted plan.
- Your agent stack is pure Python and adding a Rust binary to your build pipeline is friction.
- You’re already invested in Zep’s MemGPT-style consolidation and the ACT-R Leiden approach is unfamiliar territory.
- You need recall latency ≤ 500 ms but not ≤ 25 ms — Zep is production-ready at that envelope and Lunaris’s edge isn’t free to operate.
Known gaps vs Zep today
- Hosted SaaS. Lunaris does not (yet) offer a managed service. Self-host via Docker / Helm today; a managed service remains on the roadmap.
- MemGPT-style salience. Lunaris’s consolidator implements ACT-R (Anderson 1996) base-level activation + Petrov 2006 O(1) incremental approximation + Leiden community detection. The numbers are different; the semantics are not strictly worse (“more recent + more frequent + more connected” → higher activation). If your evals depend on Zep’s specific recency weighting, port the eval first.
- OpenAI-default embedder. Zep ships with an opinionated embedder; Lunaris
defaults to in-process native granite-r2 (local, 768-d) — no external
service required. An Ollama HTTP escape hatch is available behind
--features embed-remotefor air-gapped or remote deployments.
See the Mem0 page for the parallel migration story from Mem0. The two pages differ because Mem0 has no bi-temporal model while Zep does — Mem0 migrations focus on the bi-temporal upgrade; Zep migrations focus on the latency + substrate simplification.
Migrating from Cognee
Adapted from
docs/MIGRATING-FROM-COGNEE.md(kept in the repo as the standalone version).
Cognee and Lunaris both treat the knowledge graph as a first-class substrate. The difference is the surface: Cognee is a Python pipeline (“Tasks → DataPoints → Pipelines”) that produces a queryable graph; Lunaris is an embedded Rust core with a composable retrieval DSL that lets you query a bi-temporal graph + vector + keyword store in a single fused call.
This page maps Cognee concepts to their Lunaris equivalents so a team already running Cognee can evaluate the switch with concrete code.
TL;DR — if your agent depends on Cognee’s pipeline plug-in ecosystem (custom extractors, custom chunkers, custom graph builders), Cognee is well-positioned; the pipeline composition is its strength. If you want sub-25 ms recall over a bi-temporal store + a retrieval DSL where vector + graph + keyword fuse in one typed query, Lunaris’s composable operator surface is the simpler model.
At a glance
| Concern | Cognee | Lunaris |
|---|---|---|
| Runtime | Python | Embedded Rust core + Python (PyO3) + TypeScript (NAPI) bindings |
| Storage | Vector DB (LanceDB / Qdrant / Weaviate / …) + Graph DB (Neo4j / FalkorDB / Memgraph / …) — pluggable | Moon (one substrate, FT.* + graph + KV native) |
| Composition model | Pipeline of Tasks operating on DataPoints | Composable retrieval DSL (vector, keyword, graph + .and / .or / .then / .fuse_rrf) |
| Bi-temporal | Not first-class (DataPoints can carry timestamps but the engine doesn’t model (valid_time, sys_time) tuples) | First-class (valid_time, sys_time) per row; .as_of(ts) is one combinator |
| Recall latency | Depends on backend (~50 ms LanceDB local, ~200 ms cloud) | p50 ≤ 25 ms / p99 ≤ 100 ms on laptop-arm64 |
| Atomicity | Per-store best-effort | One atomic_write covers vector + KV + graph + audit + queue. CI gate enforces single call site |
| Tenancy | dataset string on the API | Scope newtype ([A-Za-z0-9_\-.]{1,128}) threaded through every storage call + per-scope Moon keyspace |
| Graph query language | Cypher (via backend) | Moon native graph (Cypher dialect); the Graph::anchored(entity_ids, hops) operator lowers to it |
| Custom pipeline tasks | First-class — register Tasks, compose with await cognee.cognify() | Override Extractor trait (Phase 3); recall DSL is fixed surface |
| License | Apache 2.0 | Apache 2.0 |
Where Cognee and Lunaris differ in spirit
Cognee is pipeline-oriented: your agent’s ingest path is a
sequence of Tasks (chunk, extract, embed, link, store), and the
power is that you can compose, replace, or insert Tasks as your
domain evolves. The cost is that the surface is wide and a typo in
one Task can break the entire cognify() call.
Lunaris is operator-oriented: the ingest path is fixed
(Lunaris::ingest() chunks + embeds + writes in one
atomic_write); the power is in the retrieval DSL where you
compose vector, keyword, graph, fusion, and time-travel into a
typed query tree. The cost is that custom ingest logic means a
custom Extractor impl rather than a Task plug-in.
Both are right for different shapes. Cognee is the answer when your domain logic lives at ingest time (custom DataPoint relationships, domain-specific Tasks). Lunaris is the answer when your domain logic lives at recall time (hybrid search with custom fusion, bi-temporal audit queries, graph-anchored exploration).
Code-side comparison
Ingest
Cognee
import cognee
await cognee.add("Alice joined Acme on 2024-04-01.", dataset_name="bio")
await cognee.cognify(["bio"]) # runs the default Tasks pipeline:
# chunk → extract → embed → link → store
Lunaris
import lunaris
mem = lunaris.Lunaris.open("moon://localhost:6380")
scope = lunaris.Scope("bio")
mem.scoped(scope).ingest(
lunaris.EpisodeBuilder("chat:session-1/turn-1",
"Alice joined Acme on 2024-04-01.")
)
# Chunking + embedding + atomic write happen inside `ingest()`.
# Graph extraction is opt-in via the graph pipeline toggle.
Recall — semantic
Cognee
results = await cognee.search(
query_text="when did Alice join Acme?",
search_type=cognee.SearchType.GRAPH_COMPLETION,
)
Lunaris
hits = await (
mem.scoped(scope)
.recall() # pre-bound builder, default root Vector("chunks", 30)
.top(5)
.execute() # plan collapses to one FFI call; no query-text arg yet
)
Recall — hybrid semantic + graph
Cognee
results = await cognee.search(
query_text="who does Alice work with at Acme?",
search_type=cognee.SearchType.GRAPH_COMPLETION,
)
# search_type=GRAPH_COMPLETION runs an LLM over the graph context;
# you can't compose "vector AND graph anchored on Alice" without
# dropping to the underlying backends.
Lunaris
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.and_(lunaris.Graph.anchored(entity_ids=[alice_id], hops=2))
.fuse_rrf(60)
.top(5)
.execute()
)
The .and_ fans out vector + graph branches concurrently; .fuse_rrf
folds them with Reciprocal Rank Fusion. The whole pipeline is one
typed expression; no LLM round-trip for the fusion step. See
The Retrieval DSL and
The Graph Pipeline.
Custom extraction
This is where Cognee’s pipeline model shines. If you need a
domain-specific entity extractor, in Cognee you write a Task; in
Lunaris you implement the Extractor trait.
Cognee
from cognee.tasks.documents import classify_documents
from cognee.modules.pipelines import Pipeline
async def my_task(data_points):
# ... domain-specific logic
return data_points
pipeline = Pipeline(tasks=[classify_documents, my_task])
await pipeline.run(dataset_name="bio")
Lunaris
class MyExtractor(lunaris.Extractor):
async def extract(self, content: str) -> lunaris.ExtractionResult:
# ... domain-specific logic
return lunaris.ExtractionResult(entities=[...], relations=[...])
mem = lunaris.Lunaris.open("moon://localhost:6380").with_extractor(MyExtractor())
If you have N custom Tasks composing into a pipeline, Cognee’s
model maps cleaner — composing N Lunaris extractors requires
wrapping them in a single trait impl that fans out internally.
v0.3 RFC 0007 (FallbackExtractor /
FallbackEmbedder combinators) adds the fan-out primitive.
Time-travel recall
Backend note (v0.6.2).
.as_of(<past timestamp>)needs a backend that keeps a KV version chain to hydrate the historical rows, and no 0.7.0 backend does: the call returnsStorageError::NotSupported(HTTP501 not_supported). Moon stores Lunaris rows as plain hashes and refuses a historical pin rather than silently answering with present-time data; the Postgres and SQLite backends that answered it were deleted in 0.7.0. The search and graph lanes stay temporal (FT.SEARCH AS_OF,GRAPH.QUERY VALID_AT).
Cognee doesn’t model bi-temporal queries first-class. The closest is
filtering DataPoints by a created_at field post-search.
Lunaris
snapshot_ms = int(snapshot_ts.timestamp() * 1000)
hits = await (
mem.scoped(scope)
.recall() # default root Vector("chunks", 30)
.as_of(snapshot_ms) # ms since the Unix epoch
.execute()
)
The temporal cut happens at the storage layer (native bi-temporal on Moon). On 1M-fact corpora the latency difference matters.
Migration checklist
- Stand up Lunaris alongside Cognee. Use
examples/quickstart-py. - Map
dataset→Scope. Same[A-Za-z0-9_\-.]{1,128}alphabet constraint as the Zep migration. - Port custom Cognee Tasks to a Lunaris
Extractorimpl. This is the largest migration cost — a Cognee deployment with 5 custom Tasks becomes ~150 lines of trait impl. If you have no custom Tasks, this step is zero work. - Mirror writes. Every
cognee.add(...)+cognee.cognify(...)is also dispatched tomem.scoped(scope).ingest(...). - Shadow reads. Every
cognee.search(...)is also issued to Lunaris’s recall DSL. Diff in your eval harness. - Cutover and decommission. You now run one Rust process + Moon instead of Cognee + (vector DB) + (graph DB).
When to stay on Cognee
- Your custom Tasks are non-trivial and porting them to a single
Extractorimpl is too much migration cost. - You’re using Cognee’s pipeline plug-in ecosystem (community Tasks) and that’s load-bearing for your stack.
- You’re committed to a specific vector DB / graph DB combination that Lunaris doesn’t ship a backend for, and you don’t want to operate Moon.
- Pure Python deploy, no Rust binary in the build pipeline.
Known gaps vs Cognee today
- No Task plug-in ecosystem. Lunaris ships a fixed ingest path
(
Lunaris::ingest). Custom logic goes in theExtractortrait impl — one impl, not a chain. v0.3 RFC 0007 adds composable fallback combinators for resilience but does not introduce a pipeline DSL. - Backend matrix is one. Lunaris ships Moon and nothing else as
of 0.7.0 — no LanceDB / Qdrant / Weaviate adapter, and the
Postgres adapter was removed. The
StoragePorttrait is still the extension point: a third-party crate can implement it for any backend, but none does today. - Graph-completion search. Cognee’s
GRAPH_COMPLETIONsearch type wraps an LLM call over the graph context. Lunaris exposes the graph traversal as an operator (Graph::anchored) and leaves the LLM call to the caller. If you want one-call “graph-and-summarize”, you’d composerecall() + extractor.summarize()yourself.
See the Mem0 and Zep pages for the parallel migration stories from the other two incumbents. The trio covers the three distinct positioning conversations: Mem0 (no bi-temporal upgrade required), Zep (latency + substrate simplification), Cognee (pipeline-vs-DSL tradeoff).
MemoryProtocol 0.1 (HTTP/SSE)
Adapted from
docs/protocol/memoryprotocol-0.1.md(kept in the repo as the canonical spec).
Status: alpha · Source of truth: this document ·
Conformance harness: lunaris-conformance::protocol
(crates/lunaris-conformance/src/protocol/) ·
Reference implementation: lunaris-server (crates/lunaris-server/, axum 0.8 binary)
MemoryProtocol is the HTTP+SSE wire protocol an agent harness uses to talk to
a Lunaris memory engine. It exposes the blueprint §5.4 verbs
(POST /v1/ingest, POST /v1/recall, POST /v1/forget,
GET /v1/snapshot/:lsn, GET /v1/episode/:id) plus a Prometheus /metrics
endpoint and a no-auth /healthz probe. v0 is JSON-only over HTTP/1.1 +
HTTP/2; bincode/CBOR/MessagePack wire formats are deferred to v1.
The implementation under test for the conformance harness is lunaris-server
from this workspace. Third-party servers (Go, Python, etc.) are conformant if
and only if they satisfy §Conformance. See
Running the HTTP Server for the operational view.
Versioning
- The
/v1/path prefix is stable. v0 is the alpha; v1 is the GA. Future incompatible changes get/v2/. - Backwards-compatible additions land under
/v1/(new optional fields, new endpoints). - The conformance gate certifies any implementation against this document.
Authentication
Bearer token in the Authorization header on every /v1/* request:
Authorization: Bearer <token>
Tokens are mapped to a tenant id + scope set via the server’s --tokens-file
flag. The map shape (CONTEXT.md D-07 verbatim):
{
"<token>": { "tenant": "<id>", "scopes": ["ingest", "recall", "forget"] }
}
- Missing or malformed
Authorizationheader →401 Unauthorized. - Token not in map →
401 Unauthorized. - Token present but lacks the required scope for the route →
403 Forbidden.
Per-route scope requirements:
| Route | Required scope |
|---|---|
POST /v1/ingest | ingest |
POST /v1/recall | recall |
POST /v1/forget | forget |
GET /v1/snapshot/{lsn} | recall |
GET /v1/episode/{id} | recall |
GET /healthz | (none) |
GET /metrics | (none) |
OAuth2 / JWT / OIDC issuance is a v1 gate (managed cloud DEPLOY-V1-01).
“JWT” below is historical wording. v0 ships opaque bearer tokens — the claims (
tenant= partition scope,scopes= verb permissions) live in the server-side tokens file, never in the token. Read every “JWTtenantclaim” in this document as “thetenantclaim the server resolved for this token”. Managed JWT/OIDC issuance is the v1 gateDEPLOY-V1-01.
Rate limiting
Per-tenant rate limit applied to every /v1/* request. Defaults: 60 rps,
120 burst (configurable via --rate-per-second / --rate-burst). The
conformance harness configures 5 rps / 10 burst so the burst test fires
within hundreds of milliseconds.
- Exceeded →
429 Too Many Requestswith aRetry-After: <seconds>header.
The key extractor reads the tenant field from the validated bearer token’s
AuthClaims; un-authenticated routes are not rate-limited (in v0 there are no
un-authenticated /v1/* routes).
Verbs
POST /v1/ingest
Ingest one Episode. The server fans out chunking + embedding + atomic write
internally. Single atomic-write invariant preserved (INGEST-04); the HTTP
layer adds NO new atomic boundaries.
Required scope: ingest
Request body (application/json):
{
"id": "01JBA...", // ULID, optional (server generates if absent)
"source": "helios:fs/notes.md", // string, required
"content": "Markdown body up to ~12 KB", // string, required
"t_ref": "2026-04-21T10:30:00Z", // RFC-3339, optional (defaults to wall clock)
"metadata": { "any": "json" } // object, optional
}
Response (200 OK, application/json):
{
"lsn": { "wall_ms": 1745251800123, "counter": 0 },
"queue_lag_warn": false
}
queue_lag_warn is true when the verifier-queue depth
(StoragePort::queue_depth("__lunaris_verify__", 0)) exceeds 1000 (the
DEFAULT_VERIFY_WARN_THRESHOLD from crates/lunaris/src/recall.rs).
Best-effort: backends without queue_depth report false.
Errors: 400 (invalid Episode JSON), 401 / 403 (auth), 429 (rate),
500 (storage).
POST /v1/recall
Run a hybrid retrieval (Vector + Keyword(BM25) + RRF + bge-rerank). Two
retrieval modes per blueprint §5.4 (CONTEXT.md D-05): semantic (default)
and graph (anchored Cypher BFS, requires capabilities().graph_native OR
runtime GraphPipeline::enable()).
Required scope: recall
Request body (application/json):
{
"query": "When did Alice join Acme?", // string, required
"k": 10, // usize, default 10
"as_of": "2025-06-01T00:00:00Z", // RFC-3339, optional (server parses to Hlc)
"filter": "source LIKE 'helios:fs/%'", // v0 filter DSL, optional
"mode": "semantic" // "semantic" | "graph", default "semantic"
}
Response (default — Accept: application/json) (200 OK):
[
{
"id": [/* bytes */],
"score": 0.93,
"text": "Alice joined Acme on 2024-08-12.",
"source": "helios:fs/notes.md",
"heading_path": ["onboarding"],
"valid_from": { "wall_ms": ..., "counter": 0, "node_id": 0 },
"valid_to": null,
"degraded": false,
"rerank_applied": true,
"source_op": "Reranked"
}
]
Response (SSE — Accept: text/event-stream) (200 OK,
Content-Type: text/event-stream):
event: hit
data: { "id": [...], "score": 0.93, "degraded": false, ... }
event: hit
data: { ... }
event: done
data: {}
Each event carries event: + data: lines per W3C SSE. The stream
terminates with event: done. The degraded flag is populated from the
Phase 4 verifier-queue depth check (recall_with_degraded_check in
crates/lunaris/src/recall.rs); the SSE stream surfaces it per-Hit.
A keep-alive comment is emitted every 15 seconds while the stream is idle so reverse proxies don’t time out.
Errors: 400 (invalid request body or filter DSL parse error), 401,
403, 429, 500, 501 (graph mode requested but
!capabilities().graph_native && !graph_pipeline().is_enabled()).
POST /v1/forget
Single-target / scope / temporal-bound purge. Two-step hard-delete safety rail per Plan 04-05 D-21.
Required scope: forget
Request body (application/json):
{
"target": { "Id": "01JBA..." },
// OR { "Scope": { "BySource": "helios:fs/session-42/" } }
// OR { "Before": { "wall_ms": ..., "counter": ... } }
"hard": false, // bool, default false (soft-delete via MVCC)
"dry_run": false, // bool, default false (returns preview-only ForgetReceipt)
"confirmation_token": null // string, REQUIRED when hard=true
}
Response (200 OK):
{
"target": { "Id": "..." },
"indices_affected": ["Kv", "Vector", "Graph"],
"rows_written": 1,
"rows_deleted": 0,
"audit_lsn": { "wall_ms": ..., "counter": ... },
"preview": false
}
D-21 two-step hard-delete contract:
- POST
/v1/forgetwithdry_run: true+ the target →200 OK+ForgetReceipt { preview: true, ... }. - POST
/v1/forgetwithhard: true+ the target +confirmation_token: <stringified-receipt-from-step-1>→200 OK+ForgetReceipt { preview: false, rows_deleted: N }.
The wire shape for confirmation_token is the SERIALIZED prior
ForgetReceipt JSON. The Rust API’s ForgetConfirmation has a pub(crate)
inner field, so external HTTP callers cannot mint the typed token directly;
the server deserializes the prior receipt + calls
Lunaris::confirm_hard_forget to mint the typed token before re-issuing the
hard delete (Plan 05-01 routes/forget.rs).
Without step 1, step 2 returns 428 Precondition Required:
{ "error": "confirmation_required",
"message": "hard-delete requires confirmation_token (serialized prior ForgetReceipt JSON)" }
A malformed confirmation_token body field returns 400 invalid_confirmation_token:
{ "error": "invalid_confirmation_token",
"message": "confirmation_token must be a serialized ForgetReceipt: <parser error>" }
Every successful forget call publishes one AuditEvent::Forget to
__lunaris_audit__ (best-effort, fire-and-forget per Plan 04-05 OPS-04).
Note on real scopes (v0.2.0).
Lunaris::forgetstill routes throughScope::dev()internally for some calls, so a same-scope forget under a non-dev scope can returnrows_deleted=0with no error — seedocs/migration/0.1-to-0.2.md§10.2. v0.2.1 adds a warn; v0.3 adds a scoped overload. Verify against your deployed version.
GET /v1/snapshot/
Stream every primitive at the given Lsn as newline-delimited JSON
(application/x-ndjson).
Required scope: recall
Path param: {lsn} is the Hlc encoded as <wall_ms>.<counter> (decimal
pair), or <wall_ms>.<counter>.<node_id> (decimal triple). Examples:
/v1/snapshot/1745251800123.0, /v1/snapshot/1745251800123.5.0.
Response (200 OK, Content-Type: application/x-ndjson):
{"key":"chunk:01JBA...","value":{"id":[...],"text":"...","bt":{...}}}
{"key":"entity:01JBA...","value":{"id":[...],"label":"...","bt":{...}}}
...
One JSON object per line. Stream may be empty on a fresh backend.
Errors:
400 invalid_lsn— path param is not inwall_ms.counter[.node_id]form.404 snapshot_out_of_range—{lsn}wall_ms is strictly greater than the engine’s current wall clock. A past LSN with zero visible rows returns200+ empty NDJSON (valid empty snapshot, not “not found”).401,403,429,500.
GET /v1/episode/
Fetch a single episode by ULID from the caller’s JWT-bound scope.
Required scope: recall
Path param: {id} is a 26-character Crockford base-32 ULID string (e.g. 01HZZZZZZZZZZZZZZZZZZZZZZZ).
Response (200 OK, application/json):
The stored episode value as a JSON object (the same bytes written by POST /v1/ingest).
{
"id": "01HZZZZZZZZZZZZZZZZZZZZZZZ",
"source": "helios:fs/notes.md",
"content": "...",
"metadata": { "any": "json" }
}
Errors:
400 invalid_episode_id—{id}is not a valid 26-character Crockford base-32 ULID.404 episode_not_found— no episode with that ULID exists in the caller’s scope.401,403,429,500.
The JWT tenant claim is the exclusive scope partition key; no wire-side scope field is accepted. The KV key is constructed as lunaris:{scope}:episode:{ulid} (canonical format per lunaris_core::keyspace::episode_key).
GET /healthz
No auth, no rate-limit. Probe surface for load balancers + the conformance subprocess runner.
Response (200 OK):
{ "ok": true, "version": "0.1.0-alpha.1" }
GET /metrics
Prometheus text-format exposition. No auth required — Prometheus scrapers reach this without a Bearer token. Operators MUST front this endpoint with network-level ACL or reverse-proxy auth in production (T-05-05-05; standard Prometheus convention).
Returns 404 Not Found when lunaris-server --metrics-disabled is set at
startup (operator opt-out for embedded deployments that scrape via a sidecar).
Metrics catalogue (Plan 05-05 OPS-06; CONTEXT.md D-25 verbatim):
| Name | Type | Labels | Notes |
|---|---|---|---|
lunaris_ingest_total | counter | tenant, status | One increment per POST /v1/ingest; status ∈ {ok, error}. |
lunaris_ingest_duration_seconds | histogram | tenant | Wall-clock from request entry to response sent (includes business logic). |
lunaris_recall_total | counter | tenant, mode, status | mode ∈ {semantic, graph}; status ∈ {ok, error}. |
lunaris_recall_duration_seconds | histogram | tenant, mode | Wall-clock; same shape as ingest_duration. |
lunaris_forget_total | counter | tenant, target_kind, hard | target_kind ∈ {id, scope, before}; hard ∈ {true, false}. |
lunaris_verify_queue_depth | gauge | topic | Polled every 10 s from StoragePort::queue_depth("__lunaris_verify__", 0). |
lunaris_consolidator_queue_depth | gauge | topic | Polled every 10 s from StoragePort::queue_depth("__lunaris_consolidate__", 0). |
lunaris_error_total | counter | kind | LunarisError variant tag; cardinality cap ≤ 10. Incremented inside map_error. |
lunaris_eval_score | gauge | harness | Populated by lunaris-evals (Plan 05-06); harness ∈ {longmemeval, locomo, er-f1, …}. |
Cardinality bounds (T-05-05-02 mitigation):
tenantset membership =--tokens-fileJSON map size (operator-controlled).- All other labels are bounded by the constants above; the total time series count grows linearly with tenant count, NOT with traffic volume.
Content-Type: text/plain; version=0.0.4; charset=utf-8 (the
prometheus::TextEncoder::format_type() value). Body parses via any standard
Prometheus scraper or prometheus-client library.
Error taxonomy
| HTTP status | Server cause | LunarisError variant | JSON body shape |
|---|---|---|---|
| 400 | Bad request body / filter DSL | LunarisError::Validate(_) | { "error": "validate", "message": "..." } |
| 400 | Bad confirmation_token JSON | n/a (handler-local validation) | { "error": "invalid_confirmation_token", "message": "..." } |
| 400 | Bad RFC-3339 as_of | n/a (handler-local validation) | { "error": "invalid_request", "message": "..." } |
| 400 | Bad NDJSON snapshot Lsn | n/a (handler-local validation) | { "error": "invalid_lsn", "message": "..." } |
| 400 | Bad episode ULID path param | n/a (handler-local validation) | { "error": "invalid_episode_id", "message": "..." } |
| 401 | Missing / invalid bearer | n/a (auth middleware) | { "error": "unauthorized", "message": "..." } |
| 403 | Token lacks required scope | n/a (auth middleware) | { "error": "forbidden", "message": "..." } |
| 404 | Snapshot LSN wall_ms strictly future | n/a (handler-local validation) | { "error": "snapshot_out_of_range", "message": "..." } |
| 404 | Episode not found in caller’s scope | n/a (handler-local: read_as_of → None) | { "error": "episode_not_found", "message": "..." } |
| 404 | /metrics disabled at startup | n/a (--metrics-disabled runtime flag) | metrics disabled at startup via --metrics-disabled |
| 422 | scope / tenant field in body | n/a (#[serde(deny_unknown_fields)]) | (serde rejection) |
| 428 | Hard-delete without confirmation | LunarisError::Validate(ValidateError::ConfirmationRequired(_)) | { "error": "confirmation_required", "message": "..." } |
| 429 | Rate limit exceeded | n/a (tower-governor middleware) | (empty body; Retry-After header) |
| 500 | Storage / retrieve / extract / consolidate error | LunarisError::Storage(_) / ::Retrieve(_) / ::Extract(_) / ::Consolidate(_) (and any unmapped LunarisError) | { "error": "storage" | "retrieve" | "extract" | "consolidate" | "unknown", "message": "..." } |
| 501 | Capability not offered by the chosen backend | LunarisError::Storage(StorageError::NotSupported(_)) (via map_error) | { "error": "not_supported", "message": "..." } |
| 501 | Graph mode requested without a graph-capable backend / pipeline | n/a (handler-local capability check in routes/recall.rs) | { "error": "graph_mode_unavailable", "message": "..." } |
The LunarisError enum is defined in crates/lunaris-core/src/error.rs; the
HTTP mapping lives in
crates/lunaris-server/src/middleware/error.rs::map_error. See also
Error Taxonomy.
Conformance
An implementation is conformant if and only if:
MOON_URL=moon://localhost:6380 \
cargo test -p lunaris-conformance \
--test run_protocol_lunaris_server -- --nocapture
returns exit code 0. The harness exercises
every contract in §Verbs + §Authentication +
§Rate limiting + §Error taxonomy.
See Conformance for the full how-to (storage suite, protocol suite, AS_OF parity, third-party certification).
The memoryprotocol.dev site standup with publicly-hosted certifications is a
v1 deliverable (PROTO-V1-01).
Glossary
- Lsn —
{ wall_ms: u64, counter: u32 }. Returned byatomic_write. - Hlc — Hybrid Logical Clock;
{ wall_ms: u64, counter: u32, node_id: u16 }. Used inas_of,bt. bt— bi-temporal stamp{ valid: (Hlc, Option<Hlc>), sys: (Hlc, Option<Hlc>) }.StorageCapabilities— backend feature report; gates capability-conditional behavior (graph mode, queue mode, native RRF).- Episode — input observation; chunked + embedded server-side into one or more primitives.
- Hit — output of
recall; carries chunk text +degradedflag +valid_from/valid_to. ForgetReceipt— output offorget; carriesindices_affected,rows_written,rows_deleted,audit_lsn,preview.
Conformance
Adapted from
docs/protocol/conformance.md(kept in the repo as the standalone version).
The lunaris-conformance crate (crates/lunaris-conformance/) ships three
re-usable suites that any backend or protocol implementation can certify
against:
- Storage suite — parameterized over
Arc<dyn StoragePort>. Tests every method on the trait surface (atomic_write,vector_search,graph_traverse,scan_range,read_as_of,publish/subscribe,capabilities). Plan 05-02 STORE-05. - Protocol suite — parameterized over
(reqwest::Client, base_url, token). Tests the four MemoryProtocol verbs + SSE + auth + rate limit + retrieval modes. Plan 05-03 PROTO-06. - AS_OF behaviour — asserts that a historical pin is answered or refused
explicitly, never silently served from present time. Plan 05-02 STORE-07.
moon_declares_its_as_of_gapruns unconditionally; the pre-0.7 dual-backend differential arm went with the second backend.
Historical vs latest reads (v0.6.2). The storage suite’s
read_as_of::historical_pin_is_explicit is not capability-gated: it branches
on the backend’s own StoragePort::supports_historical_kv_reads() and
requires the matching behaviour in both directions — a backend that declares
true must not surface a row that did not exist at the pinned instant, and a
backend that declares false (Moon: plain hashes, no KV version chain) must
refuse with StorageError::NotSupported, never answer with present-time data.
“This backend can’t do as-of reads” therefore cannot be expressed as a skip.
The reference implementation under test is lunaris-server
(crates/lunaris-server/, axum 0.8 binary). The harness is library-shaped
(CONTEXT.md D-11) so any third-party StoragePort impl or HTTP server can
wire to it without duplicating test code.
Suites
| Suite | Function | Tests | What it covers |
|---|---|---|---|
| Storage | lunaris_conformance::run_full_storage_suite(storage) | 9 | atomic_write, vector_search, graph_traverse (gated), scan_range, read_as_of (latest + historical), publish/subscribe, capabilities |
| Protocol | lunaris_conformance::run_full_protocol_suite(client, url, t) | 10 | POST /v1/ingest, POST /v1/recall (default + SSE + graph mode), POST /v1/forget (id + two-step hard), GET /v1/snapshot/{lsn}, auth (401 + 403), rate-limit (429 + Retry-After) |
| AS_OF gap | run_as_of_moon_gap (test target) | 1 | STORE-07 — a historical KV pin is refused with NotSupported, not answered from present time |
The Plan 04-03 chaos / crash-recovery property test (tests/crash_recovery.rs)
ships under the same crate gated on the chaos-it Cargo feature.
Running locally
Storage suite
MOON_URL=moon://localhost:6390 \
cargo test -p lunaris-conformance --test run_storage_moon -- --nocapture
# STORE-07: the historical-KV-read gap, asserted unconditionally
MOON_URL=moon://localhost:6390 \
cargo test -p lunaris-conformance --test run_as_of_moon_gap -- --nocapture
When MOON_URL is unset → SKIPS cleanly (exit 0). When the TCP probe fails →
SKIPS with diagnostic.
Point
MOON_URLat a dedicated Moon. The suite writes and clears data.run_as_of_parity(Moon vs Postgres, field-by-field) was deleted in 0.7.0 with the Postgres backend;run_as_of_moon_gapreplaces it and needs no second backend.
Protocol suite (against lunaris-server)
# 1. Build the binary so the subprocess runner can find it.
cargo build -p lunaris-server # → target/debug/lunaris-server
# 2. Run the suite.
MOON_URL=moon://localhost:6390 \
cargo test -p lunaris-conformance \
--test run_protocol_lunaris_server -- --nocapture
The runner spawns lunaris-server --bind 127.0.0.1:0 --rate-burst 10 --rate-per-second 5 as a subprocess, parses LISTENING_ON <addr> from its
stderr (Plan 05-01 main.rs:53-57 contract), then runs the protocol suite
against the ephemeral port. Cleanup (kill child + remove temp tokens-file)
happens via RAII Drop guards regardless of test outcome.
Chaos / crash-recovery (Unix only, gated)
MOON_URL=moon://localhost:6390 \
cargo test -p lunaris-conformance \
--features chaos-it --test crash_recovery -- --nocapture
See Durability & Recovery for the full crash-recovery story.
Certifying a third-party implementation
Third-party StoragePort impl
Add lunaris-conformance as a dev-dependency:
# Cargo.toml
[dev-dependencies]
lunaris-conformance = { path = "../lunaris/crates/lunaris-conformance" }
lunaris-core = { path = "../lunaris/crates/lunaris-core" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Write a thin entry test:
async fn demo() -> Result<(), lunaris::LunarisError> {
#[tokio::test]
async fn my_storage_conformance() -> anyhow::Result<()> {
let storage = MyStorage::open("my://localhost:1234").await?;
lunaris_conformance::run_full_storage_suite(std::sync::Arc::new(storage)).await
}
Ok(())
}
Conformant if and only if exit code is 0.
Third-party MemoryProtocol server
Two paths depending on whether you can spawn the third-party server from a test or only point at an already-running endpoint.
Path A — already-running server:
async fn demo() -> Result<(), lunaris::LunarisError> {
#[tokio::test]
async fn my_server_protocol_conformance() -> anyhow::Result<()> {
let client = reqwest::Client::new();
let base = url::Url::parse("http://localhost:7000")?;
lunaris_conformance::run_full_protocol_suite(client, base, "tok-test".to_string()).await
}
Ok(())
}
The server MUST honor the MemoryProtocol 0.1
contract verbatim: in particular, the test-only token map MUST contain
tok-test (full scopes) and tok-ingest (ingest-only scope) for the auth +
scope-isolation tests to pass.
Path B — spawn from the test:
Mirror crates/lunaris-conformance/tests/run_protocol_lunaris_server.rs:
spawn the server binary, parse its bound address from stdout/stderr, run the
suite, kill the child via RAII Drop guard.
What “conformant” means
A conformant implementation:
- Returns the exact HTTP status codes and response shapes documented in MemoryProtocol 0.1 — every cell in the §Error taxonomy table is testable.
- Honors every
StorageCapabilitiesfield accurately. A backend that reportsgraph_native: trueMUST implement Cypher subset queries; a backend that reportsqueue_native: trueMUST implementpublish/subscriberound-trip semantics. - Surfaces
Hit::degraded = truewhen the verifier-queue depth check fires (when applicable to the deployment). - Issues exactly one
atomic_writeperingest/forgetcall (single-write invariant from Plans 04-04 / 04-05; INGEST-04). - Publishes one
AuditEventto__lunaris_audit__on every successfulforget(best-effort, fire-and-forget per Plan 04-05 OPS-04). - Honors the D-21 two-step hard-delete rail:
hard: truewithoutconfirmation_tokenMUST return428 Precondition Required. - Returns
429 Too Many Requestswith aRetry-Afterheader on rate-limit exhaustion.
Environment variables
| Variable | Purpose | Required for |
|---|---|---|
MOON_URL | Moon backend connect URL (e.g., moon://localhost:6380) | storage suite, protocol suite |
LUNARIS_CONFORMANCE_STRICT | 1 turns every skip decision into a hard failure | CI, where the store is provisioned by the job |
CARGO_TARGET_DIR | Override target dir for binary discovery | protocol suite when out-of-tree builds used |
CARGO_BIN_EXE_lunaris-server | Pre-resolved binary path (forward-compat) | protocol suite (rare; harness falls back) |
When MOON_URL is unset, every test SKIPS cleanly — cargo test --workspace
stays green on a fresh checkout without a store. In CI that is inverted:
integration.yml sets LUNARIS_CONFORMANCE_STRICT=1 in the one job that
provisions a Moon, so a skip there fails the board instead of reporting green
over nothing.
CI integration
The workspace’s GitHub Actions workflow (.github/workflows/integration.yml)
provisions Moon via a Docker service and runs the full conformance suite on
every push + pull request:
- run: cargo build -p lunaris-server --no-default-features # protocol-suite prereq
- run: cargo test -p lunaris-conformance --features moon-it --no-fail-fast
- run: cargo test -p lunaris-conformance --features chaos-it --test crash_recovery
All 5 invocations exit 0 when env vars unset (clean skip) OR backends
reachable + suite passes. They exit 1 when backends are reachable AND suite
assertions fire — exactly the gate behavior CI wants.
Glossary
lunaris_conformance::run_full_storage_suite— entry to the storage suite (Plan 05-02 STORE-05).lunaris_conformance::run_full_protocol_suite— entry to the protocol suite (Plan 05-03 PROTO-06).lunaris_conformance::storage::as_of_parity::run— entry to the AS_OF parity test (Plan 05-02 STORE-07).Divergence— typed enum carrying every observed mismatch in AS_OF parity (HitCount / HitOrdering / ScoreEpsilon); ScoreEpsilon is suppressed when backends disagree onrerank_native.- probe_backend — TCP-probe + 1s timeout helper (verbatim from Plan 04-03
crash_recovery.rs::probe_backend— W-3 + W-7 fixes). - B-7 stub — forward-compat convention: ship public signatures with
Ok(())bodies in scaffold tasks so dependent plans can wire to the surface before bodies land. Used by Plan 05-02 to ship the protocol module before Plan 05-03 filled it in.
Appendix: RFCs & Changelog
Deeper and internal references — design rationale, the change log, and the live-measurement / benchmark evidence behind the performance claims in this book. These live in the repository (not rendered into this book) so they can stay close to the code; the paths below are repo-relative.
Design RFCs (docs/rfcs/)
The accepted design RFCs that shape the current architecture. Where this book says “RFC 000N” it means one of these:
| RFC | Title | What it decides |
|---|---|---|
docs/rfcs/0001-scope-newtype.md | Scope newtype and ScopedLunaris<'a> typestate | Multi-agent partition key — the validated Scope alphabet, the lunaris:{scope}:{kind}:{ulid} KV format, and why the typestate makes cross-scope leaks a compile error. (Its Postgres RLS section is historical: that backend was removed in 0.7.0.) |
docs/rfcs/0004-extractor-tiers.md | ExtractorTier typestate enum and laptop-floor default swap | Historical. Described the candle / Ollama / cloud-API extractor tiers and the default-model choice; the candle tier was deleted in the v0.6 llama.cpp-only cutover (docs/decisions/2026-07-10-llamacpp-only-cutover.md) — extractor is remote-only (LUNARIS_EXTRACT_PROVIDER) or NoopExtractor. |
docs/rfcs/0006-verifier-default-swap.md | Verifier default swap: Gemma 3 27B → Gemma 3 270M | Historical. Why the verifier used to default to NoopVerifier with a candle verify-small laptop-floor build; superseded by the v0.6 llama.cpp-only cutover — the verifier is now remote-only (LUNARIS_VERIFY_PROVIDER) or NoopVerifier, no local model tiers. |
docs/rfcs/0007-fallback-combinators.md | FallbackExtractor<P, F> / FallbackEmbedder<P, F> with per-provider circuit breakers | The v0.3 resilience primitives — the fan-out / fallback combinators referenced by the Cognee migration. |
See also .planning/architect/blueprint.md — the canonical architecture
document the RFCs amend, and docs/decisions/2026-07-10-llamacpp-only-cutover.md
for the current (v0.6) inference-runtime decision.
Changelog & release scope
CHANGELOG.md— per-version change log (v0.1 → v0.2.x and beyond).docs/RELEASE.md— current release scope and what lands in the next minor.docs/migration/0.1-to-0.2.md— the 0.1 → 0.2 breaking-change guide (Scope, RLS role recipe, the documented v0.2.0 operational constraints).
Live-measurement & benchmark reports
LIVE-MEASUREMENT-REPORT.md— Lunaris ↔ Moon SDK/server contract-drift report; the live evidence behind the “sub-25 ms recall” moat (strict-replay p50 ≈ 10 ms / p99 ≈ 21 ms on the 2026-04-23 run).docs/benchmarks/v0.2.x/README.md— reproducible benchmark harness + baseline numbers (the scripts, the SQuAD/replay setup, the env vars).docs/benchmarks/v0.2.x/verifier-divergence.md— RFC 0006 §4 verifier divergence capture (the AS_OF-parityScoreEpsilonstory).milestones/v0.1.1-bench/recovery-test.log— the crash-recovery evidence log referenced from Durability & Recovery.
Audits
docs/audits/v0.2.1-unwrap-audit.md— the v0.2.1unwrap()/expect()audit (panic-surface review).tmp/v0.2-code-review.md— the v0.2 release-gate code review (RC-1 … RC-4, P-1 … P-5; closure status tracked indocs/migration/0.1-to-0.2.md§10.3).
How this fits together
- Conventions enforced in code review (Scope, HTTP DTO discipline, the
grep-pinned invariants) live in the repository
CLAUDE.md. - The generated rustdoc — built from
cargo docon every release — is the authoritative API surface; see API Reference. - Where this book disagrees with the Rust source, the source wins.