Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

MoatWhat it meansWhere enforced
Sub-25 ms p50 recallNo 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 ingestAll-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 + HLCBiTemporal { 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:line cross-references back into the crates; the generated API reference is built from cargo doc on 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.

MoatWhat it meansWhere enforced
Sub-25 ms p50 recallNo 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 ingestAll-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 + HLCBiTemporal { 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:

DifferentiatorWhat it gets youProof source
Composable retrieval DSLVector::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-tenancyScope::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 graphGraph::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 costThe 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 threeMoon 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_id string 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

LunarisMem0ZepCognee
Core languageRust (Py + TS bindings)PythonPython / GoPython
Recall latency contractsub-25 ms p50, measured at 100k docs/scope (manual bench — not CI-enforced)best-effortbest-effortbest-effort
Atomic ingestsingle atomic_write, all-or-nothingfan-out writesfan-out writestask pipeline
Bi-temporalyes at the storage layer (valid + sys); as-of reads on the search + graph lanes, not on KV hydratenoyespartial
Substrate count1 (Moon; Postgres and SQLite were removed in 0.7.0)vector DB + storevector DB + graph DBconfigurable, multi
Hybrid retrievaltyped DSL: vector.and(keyword).fuse_rrf().top()flagflagpipeline step
Graphopt-in operator, off by defaultMem0g (Platform-only)always-onpipeline-driven
Multi-tenancyScope newtype + per-scope Moon keyspace, FT index and graphuser_id stringsession_idnamespace
Hosted optionnot yet (roadmap)yesyes (Zep Cloud)self-host
LicenseApache 2.0Apache 2.0Apache 2.0Apache 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 + EpisodeBuilder surface in the Python / TS SDKs and per-scope ScopedLunaris::forget.
  • Ecosystem (shipped) — LangGraph / CrewAI / Letta adapters via the lunaris-integrations package (pip install lunaris-integrations[langgraph], [crewai], [letta] — published from the next v* tag; until then install it from a checkout, see integrations/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

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:

CapabilityMoon command familyWhat Lunaris does with it
TransactionsTXN.BEGIN / TXN.COMMITThe single atomic ingest commit
Vector KNNFT.SEARCHSemantic recall, auto-indexed on write
BM25 keywordsFT.SEARCH (same index)Exact-term recall, no second system
Hybrid fusionnative RRFVector + keyword fused server-side
Time travelAS_OF / VALID_AT clauses“What did the agent believe at time T?”
Property graphGRAPH.QUERY (Cypher)Opt-in relationship traversal, per-tenant graphs
Bulk forgetFT.INVALIDATE_RANGEGDPR-grade deletion over time ranges
Queuesnative queue + pub/subConsolidation 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

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:

URLBackendNeeds
moon://host:portMoona 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. See docs/decisions/2026-07-10-llamacpp-only-cutover.md (the cutover ADR) and docs/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 = falseNoopEmbedder/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. llamacpp is 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.

FeatureDefaultEffect
llamacppIn-process llama.cpp embedder (granite-r2 Q4_K_M GGUF) + reranker (bge-reranker-v2-m3 Q5_K_M GGUF)
metal / cuda / vulkanGPU offload — forwards to llama-cpp-2’s backends
embed-remoteOllama HTTP embedder escape hatch (operator-only, not the supported path); resolves after the llamacpp step
ollamaOllamaExtractor / Ollama HTTP verifier backend selector (NOT the embedder)
cloud-apiCloud-API extractor / verifier backends (pulls reqwest)

default = ["llamacpp"]. Extractor and verifier are remote-onlyLUNARIS_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 --release from crates/lunaris-py/, then import lunaris resolves to the local build. lunaris-py is a cdylib and is excluded from cargo 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) from crates/lunaris-ts/, then npm install ../../crates/lunaris-ts from your project. lunaris-ts is a cdylib and is excluded from cargo 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:

FlagEnvDefault
--bindLUNARIS_BIND0.0.0.0:8080
--storageLUNARIS_STORAGE(required)
--tokens-fileLUNARIS_TOKENS_FILE(required)
--rate-per-secondLUNARIS_RATE_PER_SECOND60
--rate-burstLUNARIS_RATE_BURST120
--shutdown-grace-secsLUNARIS_SHUTDOWN_GRACE_SECS30

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 migrate and lunaris-server bootstrap-db were Postgres-only (embedded migration set, RLS app-role provisioning) and went with the backend, along with LUNARIS_ADMIN_URL. Moon needs no schema migration and no role bootstrap — start it, point --storage at it. Indexes are created on first connect.

Next

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(...)) returns Vec<Hit> directly, and ScopedLunaris::dsl() (or the bare Lunaris::recall()) returns a RetrievalBuilder you drive with .with_root(...) + .execute(Query::text(...)). A fluent shorthand like recall().vector("chunks", 30).top(5).execute() is a planned ergonomic wrapper that is not yet on the builderRetrievalBuilder has 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_rrf collapses 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

StepRustWhat it is
OpenLunaris::open(url)moon://host:port — the only scheme (0.7.0)
ScopeScope::new("quickstart")?the validated multi-agent partition key (RFC 0001)
Bindlunaris.scoped(scope)all ops on the returned ScopedLunaris are partitioned
Ingestscoped.ingest(EpisodeBuilder::new(src, body))one atomic_write: chunk + embed + commit
Recallscoped.dsl().with_root(Vector::new("chunks", 30).top(5)).execute(Query::text(q))one read pass, returns Vec<Hit>
Forgetlunaris.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 Scope keyspace, the single atomic_write invariant.
  • 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
                                               +---------------+
  • ingest chunks 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 single StoragePort::atomic_write call. Then it publishes one message to __lunaris_consolidate__ (best-effort; the data is already durable).
  • forget is the inverse: it also commits in one atomic_write (soft delete stamps the MVCC sys end; hard delete is a KvDelete fan-out behind a two-step confirmation token), and emits one audit event.
  • recall is read-only: a RetrievalBuilder composes Vector, Keyword, Graph, and Tree (RAPTOR hierarchical) operators (plus fusion / rerank / fallback wrappers) into a plan, executes it once, and returns Vec<Hit>. The one-shot ScopedLunaris::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):

PrimitiveWhat it isProduced by
EpisodeA raw observation: chat turn, document, tool output. { id, scope, source, content, t_ref?, bt, metadata }. The only thing you write directly.you, via EpisodeBuilder
ChunkA 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)
EntityA named thing — { id, scope, name, aliases, entity_type, embedding?, bt, confidence }.the extractor (graph pipeline)
RelationA typed edge between two entities.the extractor
FactAn extracted assertion, contradiction-checked by the validator.the extractor + validator
CommunityA 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_of can 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 with StorageError::NotSupported (HTTP 501 not_supported) rather than answering with present-time data. Latest-state reads — what every recall, hydrate and forget actually issues — work everywhere, and Moon’s search/graph lanes remain temporal via FT.SEARCH AS_OF and GRAPH.QUERY VALID_AT. Closing the KV gap upstream is tracked against Moon’s TemporalKvIndex.

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 a NOSUPERUSER NOBYPASSRLS role). 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::keyspaceepisode_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:

PipelineWhat it doesEnable
GraphExtract 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
VerifySlow-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
ConsolidateACT-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:

SchemeBackendNotes
moon://host:portMoon (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:///pathRemoved in 0.7.0. StorageError::UnsupportedScheme, with the message naming the migration guide.
anything elseStorageError::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

Where this chapter disagrees with the Rust source, the source wins — the path:line references 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 methodEffect
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:

  1. Chunklunaris_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).
  2. Embedembedder.embed_batch(&[..]) in batches of INGEST_EMBED_BATCH_SIZE = 32. On a batch error, it degrades to per-chunk single-input embeds; a per-chunk failure surfaces immediately as LunarisError::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.
  3. Assemble one Vec<WriteOp> — one KvPut for the episode, plus per chunk a KvPut (chunk JSON) and a VectorUpsert (768-d embedding + {episode_id, heading_path, offset, text, source} metadata). The text field is what lets Moon’s BM25 index score chunk content.
  4. One atomic_writestorage.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 equal 1 (comments mentioning atomic_write are stripped first).
  • Graph ON — the single call site is in ingest_episode_graph_on (crates/lunaris/src/ingest.rs, the ONE atomic_write call (INGEST-04 …) comment). The extended fan-out (entities, relations, facts) extends the same WriteOp vector — it does not introduce a second atomic_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 Extractorvalidator::validateValidatedExtraction. (A NoopExtractor — installed automatically when no LUNARIS_EXTRACT_PROVIDER is configured — short-circuits this: applies() == false skips the extract call entirely, so no GraphNodes are written.)
  • The single WriteOp vector grows to include, per extracted entity, a GraphNode + a VectorUpsert into the entities index; per relation, a GraphEdge; per fact, a KvPut + a VectorUpsert into the facts index.
  • After commit, every NeedsReview item 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 every POST /v1/ingest through ScopedLunaris::ingest keyed on the JWT tenant claim.
  • 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 a WARN and falls back to NoopEmbedder. The MCP server stages GGUFs lazily on first recall. Point LUNARIS_EMBEDDER_GGUF at an existing local copy, or build with --features embed-remote and set LUNARIS_EMBEDDER_OLLAMA_URL to 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::open passes embedder.dim(), or use MoonStorage::connect_with_dim directly), so a 1536-d embedder works out of the box. Footgun: Moon’s FT.CREATE won’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 to ingest with opinionated source prefixes.

See also

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…UseReturns
A plain semantic lookup, least ceremonyscoped.recall(query)Vec<Hit>
Hybrid (vector + BM25) fusionscoped.dsl().with_root(Vector….and(Keyword…).fuse_rrf(k))builder → Vec<Hit>
Graph expansion / RAPTOR Tree descentscoped.dsl().with_root(Graph… / Tree…)builder → Vec<Hit>
Time-travel (as_of), filters, rerankscoped.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 recall names. ScopedLunaris::recall(query) (above) returns Vec<Hit> and is the canonical one-shot. ScopedLunaris::dsl() returns the builder. The bare Lunaris::recall() (no scope) is a legacy path that returns a RetrievalBuilder (not Vec<Hit>) seeded with Scope::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 seeds Scope::dev() and emits a tracing::warn! on every call (crates/lunaris/src/recall.rs:78-80). It is the v0.1 backwards-compatible path; new code uses engine.scoped(scope).dsl() (or engine.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 to MAX_K).
  • depth — BFS descent levels. 1 (default, DEFAULT_TREE_DEPTH) collects the seed communities’ direct members; 2 also expands sub-communities one level deeper, and so on. Clamped to [1, MAX_TREE_DEPTH] where MAX_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 flat Vector branch 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 communities index must be populated. RAPTOR fills it at ingest (community summary_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-k returns a single chunk. Relevance-vs-flat ranking with a production embedder is not yet benchmarked; treat .tree() as a coverage / recall lever, fused with Vector for 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 to other only if the left side yields nothing.
  • .then(other) — feed the left side’s hits as the input to other (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 a Filter. Returns a Result<Self, FilterParseError> at builder time so invalid syntax surfaces before any IO. The v0 grammar parses two predicate forms only — field = 'value' (→ Filter::Eq) and field 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 for Filter::And / Filter::Or composition).
  • RetrievalBuilder::as_of(ts) — pin the bi-temporal snapshot to an Hlc. Builder-level filter / as_of override the per-Query fields 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. (communities is 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_str parse errors are builder-time, not execute-time. Catch them with ? where you build the chain.
  • execute_raw returns un-hydrated RawHits — for bench harnesses that measure search-path latency over non-chunks indices. Production callers use execute so “every hit has chunk text” holds.

See also

How Recall Works — Memory Structure & the Millisecond Budget

This page answers two questions evaluators keep asking:

  1. How is a memory actually structured once Lunaris has ingested it?
  2. 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, TAG filter fields, and bi-temporal stamp live in a single HSET document indexed by one per-scope FT index (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 bt field records system time (when Lunaris learned the fact) and valid time (when it was true in the world) as two half-open intervals. forget and 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:

StageWhat happensWhy it’s fast
1. Query embedgranite-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 searchONE FT.SEARCH HYBRID round trip; Moon fuses vector KNN + BM25 with native RRF server-sideFusion happens inside the engine that owns both indices — not N queries glued together in app code
2a. Filters & timeTAG pre-filters (@source:{...}) and AS_OF <ms> resolve inside the same search commandFiltering before scoring; the temporal cut never becomes an app-side post-filter
3. HydrateEvery hit’s chunk row fetched concurrently (ordered fan-out, one HMGET per row); parent episodes fan out once per unique episode_idConcurrent 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-processA 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_query helper (exported by lunaris-retrieve; RETRIEVE-13) chooses Hybrid (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 plans VectorOnly and BM25 is never consulted on that path. This is pinned by the cjk_query_always_plans_vector_only unit test in planner.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:

  1. one network round trip per lane, plus app-side fusion;
  2. no shared TAG / temporal pushdown — you over-fetch, then post-filter in application code;
  3. 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

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::forget is hard-coded to Scope::dev() internally for its atomic_write / read_as_of / scan_range calls (crates/lunaris/src/forget.rs:300-303). Under any non-_dev_ scope it silently returns rows_written = 0, rows_deleted = 0 — the Moon SCAN prefix filters everything out. It emits a tracing::warn! on every call so the line above your forget(...) says so. The real per-scope routing — ScopedLunaris::forget(target) with a 403/404 cross-scope contract — is a v0.3 deliverable. See RFC 0001 §11.6 and CHANGELOG.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

RequestWhat it doesatomic_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_lsn is the __lunaris_audit__ offset.
  • Use dry_run first 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

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).

KnobWhere
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 Extractorvalidator::validateValidatedExtraction, then extends the single WriteOp vector (the INGEST-04 invariant still holds — one atomic_write per ingest call) with:

  • per entity — a GraphNode (props include id_hex) + a VectorUpsert into the entities index;
  • per relation — a GraphEdge;
  • per fact — a KvPut + a VectorUpsert into the facts index.

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(())
}
  • 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.
  • .with_k(n) caps the candidate set (DEFAULT_GRAPH_K = 30); .with_graph(name) overrides the graph key (default lunaris_graph).
  • Any Graph branch forces client-side RRF — the Moon-native one-trip fusion path only fires for Vector + 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:

BackendSelectorNotes
OllamaExtractorCargo feature ollama, used via with_extractor or LUNARIS_EXTRACT_PROVIDER=openai-compatPOSTs /api/chat (or the OpenAI-compatible endpoint) with a JSON-schema format field.
Cloud-API extractorLUNARIS_EXTRACT_PROVIDER = anthropic|openai|gemini|minimax (Cargo feature cloud-api)Single retry on transient errors then a sentinel that the validator routes to TransientAfterRetry.
NoopExtractoralways availableapplies() == 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_PROVIDER set (and no with_extractor override), Lunaris::open substitutes NoopExtractor and emits a tracing::warn! — in that state graph_pipeline().enable() is a no-op and zero GraphNodes get written. Fix by setting LUNARIS_EXTRACT_PROVIDER (e.g. minimax, or openai-compat pointed at a local Ollama/llama-server) or supplying a custom with_extractor impl.
  • communities stays empty until the consolidator’s Leiden community-detection run lands (v1) — recall over that index returns nothing meanwhile.
  • Apply with_extractor before .enable().enable() snapshots the current extractor; a swap afterwards propagates via set_extractor.
  • Cross-scope graph references are disallowed by constructionRelation.src / Relation.dst must resolve within the same scope (RFC 0001 §2.3).

See also

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.

PipelineHandleBackend envEnable envWhat it does
ConsolidateConsolidatorPipelineHandleLUNARIS_CONSOLIDATOR_BACKEND (actr/noop)LUNARIS_CONSOLIDATE_ENABLEDACT-R activation, promotion/archival, Leiden communities
VerifyVerifierPipelineHandleLUNARIS_VERIFY_PROVIDER (anthropic|openai|gemini|minimax|openai-compat, else Noop)LUNARIS_VERIFY_ENABLEDSlow-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 to Fact primitives; stale facts get archived.
  • Leiden community detection — a hand-rolled label-propagation pass (leiden_pass, crates/lunaris-consolidate/src/leiden.rs) over the graph, producing Community primitives (the communities recall index). Rustworkx is deliberately rejected — it carries unsafe blocks.

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 valueBackendNotes
(unset)NoopVerifierNo crash, no work done — the safe default
anthropic | openai | gemini | minimaxCloud-API verifier via the matching provider SDKNeeds the provider’s API key env var
openai-compatGeneric OpenAI-compatible HTTP verifierLUNARIS_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 runs NoopConsolidator / 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 via set_*.
  • Queue topics are hard-coded constants (__lunaris_consolidate__, __lunaris_verify__, crates/lunaris/src/ingest.rs:48-54). The shipped workers use the -v0 consumer 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_CONCURRENCY etc.

See also

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 with invalid scope. Rewrite the tenant entries in your server-side bearer-tokens file (LUNARIS_TOKENS_FILE) and restart (acme:agent-42acme.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.

VariableDefaultControls
LUNARIS_SCOPE_CONCURRENCY8Max concurrent event-batch tasks per scope (semaphore — a hot scope can’t saturate the embedder GPU)
LUNARIS_SCOPE_IDLE_TIMEOUT_MS1800000 (30 min)Idle-scope worker eviction — sheds dormant tasks so file descriptors don’t leak at high scope counts
LUNARIS_WORKER_DRAIN_MS5000 (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.0agent.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.

LevelHow you set itStrengthCost
Scopelunaris.scoped(Scope::new("acme.agent-a")?) — or, over HTTP, the JWT tenant claim → scopeHard 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 prefixThe source string on EpisodeBuilder::new(source, content) — or the prefix arg to MessageStream::new / the "chat:<user>/" prefix MultiTurnConversation derivesSoft, 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 idMultiTurnConversation::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, in lunaris-server, the JWT tenant claim → 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. EpisodeBuilder auto-generates a fresh ULID per episode (into_episode does self.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 StubEmbedder emits deterministic, non-semantic 768-d vectors, so cosine scores are all 0.0 and 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) two Scopes under one handle are isolated — neither agent’s recall returns the other’s chunks; (3) several source-tagged episodes under one scope all show up in a single recall, and the source prefix is preserved on Hit.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 namespace scratchpad/, / allowed), so teams carve per-agent areas like scratchpad/executor/status. This is an organizational prefix, not a security boundary; teammates are supposed to read each other’s entries (scratchpad_grep on a prefix).
  • Handoffs are immediate — write-then-read is read-your-writes consistent on every backend, and scratchpad writes never load the embedder.
  • scratchpad_consolidate promotes the team’s hot items — the ACT-R drain moves high-activation entries into durable memory (where semantic recall finds them later) and archives stale working state. Requires a native-queue backend.

Gotchas

  • No hierarchical scopes in v0.2 — flat strings only. org/team/agent tree semantics are a future RFC.
  • Cross-scope graph references are disallowed by constructionRelation.src / Relation.dst must resolve within the same scope.
  • Scope is an identifier, not a permission system — AuthZ stays in the lunaris-server middleware (the token map’s scopes array).
  • v0.1 → v0.2 had no on-the-wire compatibility. Pre-scope rows were backfilled to scope = '_legacy' (the reserved fallback literal), and metadata.tenant is 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) — in docs/migration/0.1-to-0.2.md.

See also

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 the Tree operator. 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 primitivesMessageStream, DocumentCorpus, TemporalQuery<S>, WorkingMemory. The building blocks. Each wraps an Arc<lunaris::Lunaris> handle and exposes 3–6 public methods.
  • 5 conversational wrappers (lunaris_recipes::conversational) — ChatAgentMemory, MultiTurnConversation, SlackArchive, EmailThreading, MeetingNotesMemory. Thin compositions over MessageStream (+ WorkingMemory, + an optional graph-pipeline toggle).
  • 5 documentary wrappers (lunaris_recipes::documentary) — DocumentKnowledgeBase, ResearchPaperCorpus, CodeRepoMemory, TimelineReconstruction, CustomerSupportHistory. Thin compositions over DocumentCorpus and TemporalQuery<Documents> (+ MessageStream for 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

RecipeTypeModule pathComposesReach for it when…
MessageStreamprimitivelunaris_recipes::MessageStreamLunarisyou have a stream of short messages and want recency-weighted recall
DocumentCorpusprimitivelunaris_recipes::DocumentCorpusLunarisyou have a document corpus and want hybrid RAG (Vector + BM25 + RRF)
TemporalQuery<S>primitivelunaris_recipes::TemporalQueryLunarisyou want time-travel — “what did the agent know at time T”
WorkingMemoryprimitivelunaris::WorkingMemory (re-exported by lunaris_recipes)Lunaris (+ Consolidator)you want a scope-prefixed scratchpad with optional consolidator promotion
ChatAgentMemoryconversationallunaris_recipes::conversational::ChatAgentMemoryMessageStream + WorkingMemoryone chat agent, per-user remember / recall
MultiTurnConversationconversationallunaris_recipes::conversational::MultiTurnConversationMessageStream + WorkingMemorysame as above plus a cross-session consolidate() pass
SlackArchiveconversationallunaris_recipes::conversational::SlackArchiveMessageStreama Slack workspace export, channel/user-narrowed recall
EmailThreadingconversationallunaris_recipes::conversational::EmailThreadingMessageStream + WorkingMemory (+ graph toggle)email threads, optional sender/recipient graph
MeetingNotesMemoryconversationallunaris_recipes::conversational::MeetingNotesMemoryMessageStream + WorkingMemory (+ graph toggle)meeting notes by heading, attendee-narrowed recall, optional graph
DocumentKnowledgeBasedocumentarylunaris_recipes::documentary::DocumentKnowledgeBaseDocumentCorpusa generic doc corpus with metadata filters
ResearchPaperCorpusdocumentarylunaris_recipes::documentary::ResearchPaperCorpusDocumentCorpus (+ graph toggle)papers, optional citation graph
CodeRepoMemorydocumentarylunaris_recipes::documentary::CodeRepoMemoryDocumentCorpus + TemporalQuery<Documents>commits / PRs / code, “function body as-of commit N”
TimelineReconstructiondocumentarylunaris_recipes::documentary::TimelineReconstructionDocumentCorpus + TemporalQuery<Documents>a dated event narrative, between(lo, hi) / as_of(ts)
CustomerSupportHistorydocumentarylunaris_recipes::documentary::CustomerSupportHistoryDocumentCorpus + MessageStream (+ graph toggle)tickets and chat transcripts, recall across both
HeliosScratchpad(umbrella recipe)lunaris::HeliosScratchpadWorkingMemoryan 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:

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, thread_prefix: impl Into<String>) -> Selfbinds the prefix
with_top_kfn with_top_k(self, k: usize) -> Selfbuilder knob; default 8
ingestasync 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
recallasync 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, for MultiTurnConversation, the same thread_id on remember); 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:

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Selfbinds the prefix
ingestasync fn ingest(chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError>each (content, metadata) pair → one Episode under {prefix}{ulid}
filterfn filter(self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Selfadds a Filter::Eq on a metadata field; multiple calls AND together
topfn top(self, k: usize) -> Selfcaps output; default 10
searchasync 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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Selfsource S is a phantom — no value needed
as_offn as_of(self, ts: Hlc) -> Selfsnapshot at ts
before / afterfn before(self, ts: Hlc) -> Self / fn after(self, ts: Hlc) -> Selfvalid-time bounds
betweenfn between(self, after: Hlc, before: Hlc) -> Selfrequires S: SupportsBetween; panics if after > before; range is [after, before) (lower inclusive, upper exclusive)
executeasync 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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, scope_prefix: impl Into<String>) -> Selfbinds the prefix
writeasync fn write(k: &str, v: serde_json::Value) -> Result<Lsn, LunarisError>value is JSON, not a bare string
readasync fn read(k: &str) -> Result<Option<serde_json::Value>, LunarisError>None = no hit
grepasync fn grep(pattern: &str) -> Result<Vec<(String, serde_json::Value)>, LunarisError>all pairs whose source starts with {scope_prefix}{pattern}
consolidateasync 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 yes

What it still is: the unwrapped surface. The recipe pages (DocumentKnowledgeBase, …) hand you a prebuilt Vector + Keyword(BM25) ⊕ RRF plan; this page composes the operators by hand so you can see what a plan is made of.

The three forms

FormCallUse it when…
One-shotscoped.recall(query)a plain semantic lookup is enough
DSL fusionscoped.dsl().with_root(Vector….and(Tree…).fuse_rrf(k))….execute()you want to blend flat chunks with hierarchical context
Treescoped.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.rs ingests a headed document and asserts each community carries a 768-d summary_embedding — the communities vector index is populated at ingest, via the single atomic_write in crates/lunaris-ingest/src/pipeline.rs.
  • Tree uses only vector_search + read_as_of (see crates/lunaris-retrieve/src/operators/tree.rs) — both core StoragePort methods, no Cypher and no GRAPH.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 over chunks. If you reached for recall() expecting fusion or rerank, use dsl() instead.
  • Tree returns 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 to d levels — latency scales with depth × fan-out, capped at MAX_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:

MethodSignatureForwards to
newfn new(lunaris: Arc<Lunaris>, scope: Scope, user_id: &str) -> SelfMessageStream::new + WorkingMemory::new
rememberasync fn remember(turn: impl Into<String>) -> Result<Lsn, LunarisError>MessageStream::ingest(turn, "default", "user")
recallasync 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 ChatAgentMemory per user. Construction is pure (no I/O); the first storage round-trip happens on the first remember / recall.
  • new takes &str for the user id, not impl Into<String> — the scope prefix is built immediately as "chat:<user_id>/".
  • No consolidate() here. The wrapper holds a WorkingMemory for future additive surface, but the cross-session promotion pass is MultiTurnConversation’s differentiator. See Multi-Turn Conversation.
  • Multi-agent scoping. The "chat:<user_id>/" prefix isolates one user’s turns inside this MessageStream — but MessageStream builds episodes with Scope::dev(), so that is source-prefix isolation, not a tenant wall. For RLS-grade per-agent isolation (separate agent platforms, the HTTP tenant claim, the low-level lunaris.scoped(scope) handle), see Multi-Agent & Scope → Multi-agent patterns and the runnable examples/multi-agent-rs/.
  • Embedder / backend tuning lives in the Configuration Reference — the recipe adds no knobs of its own beyond MessageStream::with_top_k on the underlying primitive (not surfaced on ChatAgentMemory).

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).

MethodSignatureForwards to
newfn new(lunaris: Arc<Lunaris>, scope: Scope, user_id: &str) -> SelfMessageStream::new + WorkingMemory::new
rememberasync fn remember(turn: impl Into<String>, thread_id: impl Into<String>) -> Result<Lsn, LunarisError>MessageStream::ingest(turn, thread_id, "user")
recallasync fn recall(query: &str) -> Result<Vec<Hit>, LunarisError>MessageStream::recall
consolidateasync 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::ConsolidatorPromotion to the __lunaris_audit__ topic.
  • Use ChatAgentMemory instead 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.

WrapperSource prefixComposesPublic surface
SlackArchiveslack:archive/MessageStreamnew / ingest_channel / recall / channel / user
EmailThreadingemail:thread/MessageStream + WorkingMemory (+ graph toggle)new / ingest / thread / recall / with_graph_pipeline
MeetingNotesMemorymeeting: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).

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Selfno prefix arg — rooted at slack:archive/
ingest_channelasync 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
recallasync fn recall(query: &str) -> Result<Vec<Hit>, LunarisError>recall across the whole archive
channelfn channel(id: impl Into<String>) -> SlackArchiveQuerynarrow to one channel (no I/O — deferred to SlackArchiveQuery::recall)
userfn user(id: impl Into<String>) -> SlackArchiveQuerynarrow 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_id chunk-payload fields are not yet emitted by the ingest pipeline, so the metadata-Eq narrow is structurally wired (it passes both backend translators) but currently matches an empty set until that payload extension lands. The archive-wide recall and the source-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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Selfno prefix arg — rooted at email:thread/
ingestasync 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
threadfn thread(root_id: impl Into<String>) -> EmailThreadingreturns a narrowed Self scoped at email:thread/<root_id>/ — the Filter::StartsWith on source does the narrowing (fully wired)
recallasync fn recall(query: &str) -> Result<Vec<Hit>, LunarisError>recall across the current scope (whole archive, or one thread on a narrowed handle)
with_graph_pipelinefn with_graph_pipeline(self, enable: bool) -> Selfflips 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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Selfno prefix arg — rooted at meeting:notes/
noteasync fn note(heading: impl Into<String>, body: impl Into<String>) -> Result<Lsn, LunarisError>one note under heading; participant defaults to "scribe"
recallasync fn recall(query: &str) -> Result<Vec<Hit>, LunarisError>recall across the meeting corpus
attendeesfn attendees(attendees: Vec<String>) -> MeetingNotesQuerynarrow to notes attributed to attendeestakes an owned Vec<String>, not &[&str]
with_graph_pipelinefn with_graph_pipeline(self, enable: bool) -> Selfsame 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, compose MessageStream yourself; 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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Selfbinds the inner DocumentCorpus to source_prefix (e.g. "kb:docs/")
ingestasync fn ingest(chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError>each (content, metadata) pair → one Episode under {prefix}{ulid}
filterfn filter(self, field: impl Into<String>, value: impl Into<serde_json::Value>) -> Selfadds a Filter::Eq on a metadata field; multiple calls AND together; consumes self
topfn top(self, k: usize) -> Selfcaps output; default 10; consumes self
searchasync 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 a DocumentKnowledgeBase after calling search.
  • filter is metadata-Eq only (Filter::Eq from lunaris-core is canonical — never build SQL WHERE strings). Multiple .filter calls AND together. For valid-time filtering use TimelineReconstruction or TemporalQuery directly.
  • No batch-ingest helperingest issues one internal atomic_write per 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).

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Selfbinds the inner corpus (e.g. "papers:")
with_graph_pipelinefn with_graph_pipeline(self, on: bool) -> Selfenable() / disable() on the graph handle; idempotent; builder-style; consumes self
ingestasync fn ingest(chunks: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError>forwards to DocumentCorpus::ingest
searchasync 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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, repo_prefix: impl Into<String>) -> Selfbinds the inner corpus (e.g. "repo:lunaris/")
ingest_commitasync 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)
recallasync 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).

TemporalQuery recalls across all Documents — CodeRepoMemory does 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

  • recall has no builderas_of is a positional Hlc argument. Use Hlc::from_parts(unix_ms as u64, 0, 0) (or capture a causal timestamp from lunaris.clock().tick()).
  • ingest_commit is one atomic_write per chunk, batched per invocation — preserves the INGEST-04 contract at the per-chunk grain.
  • Graph posture — only ResearchPaperCorpus exposes a graph toggle; CodeRepoMemory does not. For commit-graph-style traversal, drop to the Lunaris handle and compose Graph::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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, source_prefix: impl Into<String>) -> Selfbinds the inner corpus (e.g. "timeline:events/")
ingestasync fn ingest(events: Vec<(String, serde_json::Map<String, serde_json::Value>)>) -> Result<(), LunarisError>forwards to DocumentCorpus::ingest (1 primitive call)
betweenasync 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_ofasync 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 hi if you mean an inclusive upper bound. This is the single doc-worthy footgun of this recipe.
  • between panics if lo > hi — the bound check lives in TemporalQuery::between (check_between_bounds). Equal endpoints are allowed (empty-or-single-instant window).
  • No metadata filter on the recipe itselfTemporalQuery<Documents> recalls across all Documents on the handle. Isolate distinct timelines with distinct prefixes (and a metadata filter via the underlying DocumentCorpus) 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.

MethodSignatureNotes
newfn new(lunaris: Arc<Lunaris>, scope: Scope, scope: Scope) -> Selfno prefix arg — prefixes ticket: and chat: are hard-coded
with_graph_pipelinefn with_graph_pipeline(self, on: bool) -> Selfenable() / disable() on the graph handle; builder-style; consumes self; default OFF
ingest_ticketasync 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_chatasync 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)
recallasync 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

  • MessageStream directly.

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_idx is usize, not u32 — pass a plain index.
  • recall returns a concatenation, not a globally ranked list. If you need a single ranked stream across tickets and chats, re-rank the combined Vec<Hit> yourself (e.g. by Hit::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 relevant ticket: / 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:

#MethodSignature
1newfn new(lunaris: Arc<Lunaris>, scope: Scope, session_id: &str) -> Self
2writeasync fn write(path: &str, content: impl Into<String>) -> Result<Lsn, LunarisError>
3readasync fn read(path: &str) -> Result<Option<String>, LunarisError>
4editasync fn edit(path: &str, _old: &str, new: &str) -> Result<Lsn, LunarisError>
5grepasync fn grep(pattern: &str, k: usize) -> Result<Vec<Hit>, LunarisError>
6lsasync fn ls(prefix: Option<&str>) -> Result<Vec<String>, LunarisError>
7forgetasync fn forget() -> Result<ForgetReceipt, LunarisError>
8as_offn as_of(ts: Hlc) -> AsOfScratchpad<'_>
9AsOfScratchpad::readasync fn read(path: &str) -> Result<Option<String>, LunarisError>

A few load-bearing facts:

  • new is pure — no I/O. The session prefix is helios:fs/<session_id>/, frozen by convention. The first storage round-trip happens on the first write / read / grep / ls / forget. Use a Ulid (or UUIDv7) for the session id in multi-session servers — two pads with the same id co-mingle and one forget() wipes both.
  • write / read route through WorkingMemory — the content String is wrapped as serde_json::Value::String(...) on write and unwrapped on read. read returns None for “never written / already purged” (not Some("")); for large payloads that the chunker split, read falls back to a multi-chunk reconstruction path that concatenates up to 8 hits.
  • edit is a plain write of the new content. _old is accepted for Helios’s Read/Edit symmetry but unused — MVCC supersede stamps the prior version’s bt.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.
  • grep is hybrid recall (Vector + Keyword(BM25) + RRF + rerank per Lunaris::recall defaults) scoped to the helios:fs/<sid>/ prefix via Filter::StartsWith — never a SQL wildcard fragment. It surfaces Hit::degraded per 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 to ForgetTarget::Scope(ScopeSpec::BySource(session_prefix)) with default options — an MVCC supersede that stamps bt.sys[1]; rows are still physically present and return from read_as_of(ts) for any ts before the delete. There is no pad.hard_forget() — for GDPR-irreversible purge you drop to the Lunaris handle’s two-step confirm_hard_forget rail. See Forgetting.
  • as_of returns a borrowed, read-only view. AsOfScratchpad<'a> holds &CodingSessionMemory so the borrow checker stops you moving the pad while a time-travel view is alive. Its only method is read(path). There is no historical write / 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 same session_id sees every prior write / edit (the data lives in the durable backend keyspace lunaris:{scope}:{kind}:{ulid}); a pad.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.
  • CodingSessionMemory is the recipe; WorkingMemory is the primitive. If you want a JSON-valued scratchpad rather than a string-valued filesystem, use WorkingMemory directly. If you want consolidator promotion of hot notes, that is toggled per-scope on the consolidator pipeline ( lunaris.consolidator_pipeline()...) — CodingSessionMemory itself adds no consolidate method.
  • Backendmoon://host:port is 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:

  1. Cargo feature flags — chosen at build time; decide which embedder / reranker / extractor / verifier backends are compiled in.
  2. Environment variables (LUNARIS_*) — read at runtime, mostly when Lunaris::open resolves the default pipeline. CLI flags on the lunaris-server binary override the matching env var (12-factor; see CONTEXT.md D-26).
  3. Builder / pipeline toggles — programmatic switches on the Lunaris handle (graph pipeline on/off, verifier on/off, per-scope enablement).
  4. The storage URL schememoon://… 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. llamacpp is the only local embed/rerank runtime and is on by default; it needs cmake + a C++ toolchain. See docs/decisions/2026-07-10-llamacpp-only-cutover.md (the cutover ADR) and docs/migration/0.5-to-0.6-llamacpp-only.md (the migration guide).

FeatureDefaultEffect
llamacppIn-process llama.cpp embedder (LlamaCppEmbedder, granite-r2 Q4_K_M GGUF) + reranker (LlamaCppReranker, bge-reranker-v2-m3 Q5_K_M GGUF)
metalGPU offload on Apple Silicon — forwards to llama-cpp-2
cudaGPU offload via CUDA — forwards to llama-cpp-2
vulkanGPU offload via Vulkan — forwards to llama-cpp-2
embed-remoteOllama HTTP embedder escape hatch (operator-only); activated by LUNARIS_EMBEDDER_OLLAMA_URL; resolves after the llamacpp step
ollamaOllamaExtractor (with_extractor) / Ollama HTTP verifier backend selector (NOT the embedder)
cloud-apiCloud-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-onlyLUNARIS_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 = falseNoopEmbedder/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.

FeatureDefaultEffect
metal / cuda / vulkanForwarded from the umbrella — GPU offload via llama-cpp-2

lunaris-extract

FeatureDefaultEffect
ollamaOllamaExtractor — HTTP /api/chat extractor
cloud-apiCloud-API extractor (pulls reqwest)
extractor-itEnables 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

FeatureDefaultEffect
(none)No verifier backend compiled — NoopVerifier only
ollamaOllama HTTP verifier
cloud-apiCloud-API verifier (pulls reqwest)
verifier-itEnables 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-py and lunaris-ts are cdylibs and fail to link under the workspace test runner. Test them with maturin + pytest / napi build + vitest, or via scripts/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

VariableValuesDefaultControls
LUNARIS_VERIFY_PROVIDERanthropic|openai|gemini|minimax|openai-compatunsetRemote 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_BACKENDactr | noopactrACT-R consolidator vs no-op
LUNARIS_GRAPH_ENABLEDbooloffEntity / community graph extraction pipeline. (The graph’s name is the compile-time constant LUNARIS_GRAPH_NAME = "lunaris_graph" — not an env var.)
LUNARIS_RAPTOR_ENABLEDbooloffRAPTOR 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 indexproduction_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_ENABLEDbooloffSlow-path arbitration verifier pipeline
LUNARIS_CONSOLIDATE_ENABLEDbooloffConsolidation pipeline
LUNARIS_RECALL_RERANKbooloffOpt-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_INpositive int2*kCandidate-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)), then top(k)

Per-surface deltas on top of that shared root:

SurfaceFact legsActivation boostCross-encoder rerank
MCP memory.recall (+contextd)with LUNARIS_GRAPH_ENABLEDyes (LUNARIS_ACTIVATION_BOOST, default on)opt-in via LUNARIS_RECALL_RERANK
HTTP /v1/recall + SDK Lunaris::recall()with LUNARIS_GRAPH_ENABLEDyes (LUNARIS_ACTIVATION_BOOST, default on)opt-in via LUNARIS_RECALL_RERANK
Hook context injectionalways 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

VariableDefaultControls
LUNARIS_ACTIVATION_BOOSTon (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_CAPACITY10000Entries 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-service path, 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=0 removes 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 reference10s1 min10 min1 h6 h1 day7 days
boost added to score0.2040.1830.1550.1330.1110.0960.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.0 for any memory older than weighted² 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 llamacpp feature is on (default) — there is no LUNARIS_EMBEDDER_BACKEND or LUNARIS_RERANKER_BACKEND variable. 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

VariableDefaultControls
LUNARIS_EMBED_CACHE_CAPACITY2048Exact-text embedding LRU entries per Lunaris handle. Set 0 to disable.
LUNARIS_EMBEDDER_GGUF~/.lunaris/models/granite-embedding-311m-multilingual-r2.Q4_K_M.ggufPath 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.ggufPath 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_DEVICEautocpu is the runtime kill-switch forcing zero GPU layers even on a metal/cuda/vulkan build.
LUNARIS_EMBEDDER_OLLAMA_URLOperator 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_MODELembeddinggemma:300mOllama model tag for the embed-remote escape-hatch embedder
LUNARIS_EMBEDDER_OPENAI_URLSelector 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_MODELtext-embedding-3-smallModel id sent in the OpenAI-compatible /v1/embeddings request (lunaris-embed-remote/src/openai.rs)
LUNARIS_EMBEDDER_OPENAI_API_KEYOptional bearer token for that endpoint; empty/whitespace → no Authorization header (keyless llama-server/vLLM allowed). Redacted in Debug output.
LUNARIS_SUPPRESS_DEGRADED_WARNINGSilences 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_TOKENS4096llama.cpp batch-token window for the in-process embedder (lunaris/src/handle.rs); values < 16 rejected → default
LUNARIS_CONTEXT_EMBED_MAX_BATCH_TOKENS1024Same 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)

VariableDefaultControls
LUNARIS_EXTRACT_PROVIDERunsetanthropic|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_KEYShared API key for the verifier (falls back to the provider-specific env var, e.g. OPENAI_API_KEY)
LUNARIS_OPENAI_COMPAT_BASE_URLBase URL for the openai-compat provider (Ollama / llama-server / vLLM / LM Studio); keyless allowed
OPENAI_COMPAT_EXTRACT_MODELModel tag for the openai-compat extractor
OPENAI_COMPAT_VERIFY_MODELModel tag for the openai-compat verifier
OLLAMA_URLhttp://localhost:11434Endpoint honoured by OllamaExtractor / the Ollama verifier backend (Cargo feature ollama, distinct from openai-compat)
OLLAMA_EXTRACT_MODELgemma3:4bModel tag for OllamaExtractor
OLLAMA_VERIFY_MODELgemma3:27bModel 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.

VariableDefaultControls
LUNARIS_LLM_PROVIDERollama (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_MODELgemma3:4bDefault model id for those pipelines
LUNARIS_LLM_CONFIGunsetPath 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_MODELPer-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_MODELSame, for the verifier.
LUNARIS_REFLECT_MODELSame, for the reflect pipeline.
LUNARIS_OPENAI_COMPAT_API_KEYAPI 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).

VariableDefaultControls
LUNARIS_MOON_OP_TIMEOUT10 (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_MIN512Minimum 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_COMMITtrueWhether 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_MS25Liveness-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

VariableDefaultControls
LUNARIS_SCOPE_CONCURRENCY8Max concurrent message-process tasks per scope
LUNARIS_SCOPE_IDLE_TIMEOUT_MS1800000 (30 min)Idle-scope worker eviction timeout
LUNARIS_WORKER_DRAIN_MS5000 (5 s)Graceful drain window when a scope worker shuts down
LUNARIS_CONSOLIDATE_DEBOUNCE_MS60000 (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.

VariableDefaultControls
LUNARIS_EMBED_MAX_BATCH_TOKENS4096llama.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_TOKENS1024The 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_BATCH32Rows 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_SIZE16Rows per embed call in the hook’s embed-promotion worker (lunaris-hook/src/embed_promotion.rs). Minimum 1.
LUNARIS_EMBED_BATCH_WAIT_MS25How long that worker waits to accumulate a batch before flushing.
LUNARIS_EMBED_PROMOTION_ENABLEDtrueWhether hook-captured rows get embedded (promoted from keyword-only to vector-searchable) at all.
LUNARIS_EMBED_PROMOTION_WORKERtrueWhether promotion runs on a background worker. false keeps promotion enabled but inline.
LUNARIS_EMBED_DIM768Vector 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_CONCURRENCY4Max 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_GRANULARITYchunksession / 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

VariableDefaultControls
LUNARIS_SCOPES_FILE~/.lunaris/scopes.jsonPath 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_SCOPEHard override: skips cwd resolution entirely and uses this scope. Highest precedence (lunaris-hook/src/scope.rs).
LUNARIS_SCOPEThe --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.

VariableDefaultControls
LUNARIS_CONTEXTD_SOCKET~/.lunaris/codex-contextd.sockUnix 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_MOONenabledSet 0 / false to stop contextd launching its own in-process Moon; it then requires an external store.
LUNARIS_CONTEXTD_MOON_DIR~/.lunaris/contextd-moon-dataData directory for that embedded Moon. An unusable directory logs a WARN and disables the embedded path rather than failing the daemon.
LUNARIS_CLI_CONNECT_MS500How 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_LOGwarnTracing filter for lunaris-cli (stderr).
LUNARIS_CONTEXTD_AUTOSTART1Whether the Codex hook adapter starts lunaris-contextd on demand. 0 / false requires you to run it yourself.
LUNARIS_CONTEXTD_BINresolved from PATHExplicit path to the lunaris-contextd binary.
LUNARIS_HOOK_BINresolved from PATHExplicit 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.

VariableDefaultControls
LUNARIS_CONTEXT_ENABLEDonMaster switch for context injection through the adapter. Any disabling value turns injection off while leaving capture alone.
LUNARIS_CONTEXT_TIMEOUT_MS300Budget for the prompt-phase recall round trip.
LUNARIS_CONTEXT_POST_TOOL_TIMEOUT_MSLUNARIS_CONTEXT_TIMEOUT_MS (300)Budget for the post-tool-call recall.
LUNARIS_CONTEXT_DIGEST_TIMEOUT_MSLUNARIS_CONTEXT_TIMEOUT_MS (300)Budget for the SessionStart digest.
LUNARIS_CONTEXT_CAPTURE_TIMEOUT_MS120Budget for a capture write. Tighter than recall on purpose — a capture is fire-and-forget.
LUNARIS_CONTEXT_COLD_TIMEOUT_MS15000Budget for the first request after contextd starts, which pays the model-load cost.
LUNARIS_CONTEXT_CAPTURE_FASTonFire-and-forget capture path. Turning it off makes captures synchronous — useful when debugging a capture that seems to vanish.
LUNARIS_CONTEXT_CAPTURE_GATEonThe signal gate that drops low-value captures. off is the kill-switch that captures everything.
LUNARIS_HOOK_OUTPUT_TARGETcodexOutput 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.

VariableDefaultControls
LUNARIS_CONTEXT_RECALLhybridRecall plan for context injection. vector opts out of the keyword / graph legs.
LUNARIS_CONTEXT_MAX_CHARS1600Character budget for prompt-phase injected context.
LUNARIS_CONTEXT_MIN_SCORE0.55Score 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_CHARS900Same budget for post-tool-call injection (falls back to LUNARIS_CONTEXT_MAX_CHARS).
LUNARIS_CONTEXT_POST_TOOL_MIN_SCORE0.60Score 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_CHARS2000Character budget for the SessionStart digest.
LUNARIS_CONTEXT_INCLUDE_TOOLCALLSoff1 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_TOOLCALLSoffDeprecated 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_MS250Wall-clock budget for building handover context. Clamped to [10, 10000].
LUNARIS_HOOK_DROP_AFTER_MS100Emergency-drop deadline (HOOK-06): past this the hook warns and exits 0 rather than delaying the agent. Clamped to [10, 10000].
LUNARIS_TRANSCRIPT_TAIL_BYTES4194304 (4 MiB)How much of a transcript file’s tail the hook reads per turn.
LUNARIS_HOOK_INCLUDEColon-separated glob allow-list of paths the hook may capture.
LUNARIS_HOOK_EXCLUDEColon-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

VariableDefaultControls
LUNARIS_VERIFY_QUEUE_WARN_THRESHOLD1000recall_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.

VariableDefaultControls
LUNARIS_ENVproduction selects the JSON tracing subscriber; otherwise pretty. Also auto-selects JSON when stdout is not a TTY. (lunaris::init_logging() / lunaris::logging::init())
RUST_LOGinfo when unsetStandard tracing-subscriber env filter for the library / lunaris-server
LUNARIS_HOOK_LOGwarnTracing filter for the lunaris-hook binary (lunaris-hook/src/main.rs)
LUNARIS_HOOK_LOG_JSONunset1 switches hook logs to JSON lines
LUNARIS_CONTEXTD_LOGwarnTracing filter for the lunaris-contextd daemon (lunaris-hook/src/contextd.rs)
LUNARIS_MCP_LOGinfo,rmcp=warnTracing 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.

VariableDefaultControls
LUNARIS_BIND0.0.0.0:8080Listen 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_SECOND60Per-tenant sustained request rate
LUNARIS_RATE_BURST120Per-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_SECS30Graceful-shutdown drain window
LUNARIS_HTTP_TIMEOUT_SECS30Per-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_CONCURRENCY256Max 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"] }
}
  • tenant is the partition scope for the token (typed as Scope, validated against [A-Za-z0-9_\-.]{1,128}) — and the only source of truth for it. Route handlers ignore any scope / tenant field on the request body; every public DTO carries #[serde(deny_unknown_fields)], so a body that contains such a field is rejected (HTTP 422).
  • scopes is the verb-permission set — which of ingest / recall / forget this token may call. A request whose route requires a verb not in this list is 403.
  • 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.

VariableDefaultControls
LUNARIS_MCP_STORAGEunset (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_SCOPEunsetOverrides the auto-derived memory scope (git remote + branch, else cwd hash)
LUNARIS_MODELS_DIR~/.lunaris/modelsWhere 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_DIRPredecessor 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_STAGEunsetPresence-only: skip lazy GGUF staging on first recall (CI / operator override)
LUNARIS_MCP_DISABLE_CONTEXTDunsetPresence-only: serve every op Direct instead of proxying to the warm lunaris-contextd daemon
LUNARIS_MCP_CONTEXTD_CONNECT_MS500Cold-start budget for connecting to contextd’s socket before falling back to Direct
LUNARIS_MCP_CONTEXTD_BREAKER_N3Consecutive 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)

VariableDefaultControls
LUNARIS_STORE_URLunsetThe 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_RECALLhybridvector 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_MS1500Deadline on the hybrid retrieve; timeout degrades to the vector path
LUNARIS_CONTEXT_MAX_HITS / _MIN_SCORE / _MAX_CHARS5 / 0.55 / 1600Prompt-phase injection budget: max memories, min cosine score, char cap
LUNARIS_CONTEXT_POST_TOOL_MAX_HITS / _MIN_SCORE / _MAX_CHARS3 / 0.60 / 900Post-tool-call injection budget
LUNARIS_CONTEXT_DIGEST_MAX_HITS / _MAX_CHARS8 / 2000SessionStart digest budget
LUNARIS_CONTEXT_INCLUDE_TOOLCALLSoff1/true re-includes raw tool-call captures in context injection at every phase (excluded by default — substrate, not context)
LUNARIS_CONTEXT_PROMPT_INCLUDE_TOOLCALLSoffDeprecated alias for the row above; still honoured
LUNARIS_CONTEXT_EMBED_CACHE_MAX256Max entries in contextd’s query-embedding cache (cleared wholesale when full, not LRU)
LUNARIS_CONTEXT_PROFILEoffExactly 1 (not true) emits latency breadcrumbs for recall / embedding / promotion
LUNARIS_INFER_WATCHDOG_MS120000Per-inference-call timeout in contextd; a timed-out call fails only that request (recall fail-opens)
LUNARIS_INFER_WATCHDOG_TRIP2Consecutive inference timeouts that count as “wedged, not slow” — trips the wedge policy (contextd exits 70; hooks respawn it)
LUNARIS_DREAM_NUDGE_THRESHOLD5Ripe-memory count at which the SessionStart digest nudges “run /dream”; 0 disables the check
LUNARIS_SESSIONS_FILE~/.lunaris/sessions.jsonWhere session markers / session pads persist (tests + non-default homes)
LUNARIS_CODEX_CONTEXT_* / LUNARIS_CODEX_POST_TOOL_*same defaultsLowest-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_CRON and LUNARIS_DREAM_PIGGYBACK appear in the /dream skill 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)

VariableExampleUsed by
MOON_URLmoon://localhost:6390#[cfg(feature = "moon-it")] tests
LUNARIS_MOON_URLmoon://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/moonlunaris-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:

PipelineHandleEnv seedRuntime control
GraphGraphPipelineHandle (lunaris::graph_pipeline)LUNARIS_GRAPH_ENABLED (GRAPH_ENABLED_ENV_VAR).enable() / .disable()
VerifyVerifierPipelineHandle (lunaris::verify_pipeline)LUNARIS_VERIFY_ENABLED (VERIFY_ENABLED_ENV_VAR).enable() / .disable()
ConsolidateConsolidatorPipelineHandle (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:

SchemeBackendNotes
moon://host:portMoon (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:///pathRemoved in 0.7.0. StorageError::UnsupportedScheme, with the message naming docs/migration/0.6-to-0.7.md.
anything elseStorageError::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 as lunaris-memory; its library name is lunaris) — built from cargo doc with the documentation feature set (see each crate’s [package.metadata.docs.rs]).
  • Locally: cargo doc --workspace --no-deps --open (or make 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

CrateRole
lunarisUmbrella: Lunaris / ScopedLunaris handles, open() URL dispatcher, the ingest hot path, re-exports, prelude
lunaris-coreShared primitives (Episode/Chunk/Entity/Fact/Relation/Community), StoragePort trait, HLC clock, Scope newtype, keyspace, error taxonomy, circuit breaker
lunaris-ingestMarkdown chunker + batched embedder driver + the single atomic_write
lunaris-retrieveThe retrieval DSL (Vector/Keyword/Graph/Tree, combinators, RRF fusion, rerank, fallback), tower::Service-shaped
lunaris-extractEntity/relation/fact extractor (remote-only: Ollama / cloud-API providers) + validator
lunaris-consolidateACT-R consolidator (Anderson 1996; Leiden communities) — opt-in
lunaris-verifySlow-path arbitration verifier (remote-only providers) + MVCC supersede writer — opt-in
lunaris-llamacppEmbedder + 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-remoteEmbedder impl — Ollama HTTP escape hatch (--features embed-remote); resolves after the llama.cpp step
lunaris-rerankReranker trait + NoopReranker (the cross-encoder impl lives in lunaris-llamacpp)
lunaris-storage-moonStoragePort on a Redis-compatible substrate (native FT.*, GRAPH.QUERY, MQ, RRF)
lunaris-serverHTTP + SSE MemoryProtocol 0.1 server (axum)
lunaris-recipesRecipe primitives + conversational / documentary wrappers (see Cookbook)
lunaris-py / lunaris-tsPyO3 / napi-rs bindings (see SDKs)

lunaris-codegen, lunaris-conformance, and lunaris-bench are internal tooling crates — not part of the public API.

Publishing note. The umbrella crate is published as lunaris-memory (the bare lunaris name on crates.io is taken; the library name stays lunaris, so the import path is unchanged). lunaris-storage-moon depends on moondb from crates.io (version-pinned), so the workspace is crates.io-publishable; the [package.metadata.docs.rs] blocks are wired so docs.rs builds 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.

VariantWrapsMeaning
Storage(StorageError)backend / scheme / (de)serialization / IO faultsthe KV-vector-graph-queue substrate failed or was misconfigured
Extract(ExtractError)local-LLM extractor faultsonly reachable when the graph pipeline is on
Validate(ValidateError)input-validation faultsbad bi-temporal bounds, contradictions, missing confirmation token
Retrieve(RetrieveError)retrieval-operator faultsan operator in the DSL tree or its backend call failed
Consolidate(ConsolError)ACT-R consolidator faultsonly reachable when the consolidate pipeline is on

Sub-enums

StorageError

VariantNotes
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

VariantNotes
Temporalvalid_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 errorHTTP statuserror code
ValidateError::ConfirmationRequired428 Precondition Required (re-issue the dry_run + confirmation_token flow)confirmation_required
ValidateError::Temporal, ValidateError::Contradiction (and every other ValidateError)400 Bad Requestvalidate
StorageError::NotSupported (a capability the chosen backend doesn’t offer)501 Not Implementednot_supported
StorageError::UnsupportedScheme400 Bad Requestunsupported_scheme
request body carrying a scope / tenant field (forbidden by #[serde(deny_unknown_fields)])422 Unprocessable Entity(serde rejection)
missing / invalid bearer token401 Unauthorizedunauthorized
valid token without the verb the route requires403 Forbiddenforbidden
per-tenant rate limit exceeded429 Too Many Requests(empty body; Retry-After header)
every other StorageError, RetrieveError, ExtractError, ConsolError, unmapped LunarisError500 Internal Server Errorstorage | retrieve | extract | consolidate | unknown

A handful of statuses are produced by handler-local validation rather than map_error — these are not LunarisError variants:

Server causeHTTP statuserror code
mode: "graph" on a backend without graph_native and with the graph pipeline off (routes/recall.rs)501 Not Implementedgraph_mode_unavailable
malformed as_of (not RFC-3339) on /v1/recall400 Bad Requestinvalid_request
malformed confirmation_token (not a serialized ForgetReceipt) on /v1/forget400 Bad Requestinvalid_confirmation_token
malformed snapshot Lsn path segment on /v1/snapshot/{lsn}400 Bad Requestinvalid_lsn
{id} path segment on /v1/episode/{id} is not a valid Crockford base-32 ULID400 Bad Requestinvalid_episode_id
{lsn} wall_ms on /v1/snapshot/{lsn} is strictly greater than the engine’s current wall clock404 Not Foundsnapshot_out_of_range
no episode found for {id} in the caller’s JWT-bound scope on /v1/episode/{id}404 Not Foundepisode_not_found
/metrics requested while --metrics-disabled is set404 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 varDefaultMeaning
--bind / LUNARIS_BIND0.0.0.0:8080Listen 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_SECOND60Per-tenant sustained request rate
--rate-burst / LUNARIS_RATE_BURST120Per-tenant burst budget
--cors-origins / LUNARIS_CORS_ORIGINS*CORS allow-list — * or a comma-separated origin list
--shutdown-grace-secs / LUNARIS_SHUTDOWN_GRACE_SECS30Graceful-shutdown drain window (a ceiling — see Deployment notes)
--http-timeout-secs / LUNARIS_HTTP_TIMEOUT_SECS30Per-request wall-clock budget; over-budget → 408. 0 disables
--http-concurrency / LUNARIS_HTTP_CONCURRENCY256Max 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"] }
}
  • tenant is the partition scope for the token (typed and validated as a Scope) — the only source of truth for it. Route handlers consume the token-bound scope and ignore any scope / tenant field 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).
  • scopes is the verb-permission set for the token — which of ingest / recall / forget it 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 forget under real scopes. In v0.2.x, Lunaris::forget still routes through Scope::dev() internally for its atomic_write / read_as_of / scan_range calls — a forget issued under any non-_dev_ scope silently returns rows_written = 0, rows_deleted = 0 (the Moon SCAN prefix filters everything out). It emits a tracing::warn! on every call. The real per-scope routing — ScopedLunaris::forget(target) with a 403/404 cross-scope contract — is a v0.3 deliverable (RFC 0001 §11.6, CHANGELOG.md “Known issues”). See Forgetting and docs/migration/0.1-to-0.2.md §10.2.

Endpoints (operational view)

The wire spec is MemoryProtocol 0.1; the operational summary:

RouteRequired scopeWhat it does
POST /v1/ingestingestIngest one Episode; server chunks + embeds + does one atomic_write. Returns {lsn, queue_lag_warn}.
POST /v1/recallrecallHybrid retrieval (Vector + BM25 + RRF + optional rerank). Accept: application/json → array of hits; Accept: text/event-stream → SSE stream (event: hitevent: done, 15 s keep-alive). mode: "graph" needs a graph-capable backend or GraphPipeline::enable() (else 501).
POST /v1/forgetforgetSingle-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}recallStreams 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}recallFetch 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:

ProbeEndpointAsksFailure remedy
livenessProbe/healthzIs the process up and is storage reachable?Restart the pod
readinessProbe/readyzCan 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:

  1. pingStoragePort::health_check (Moon PING);
  2. canary — a KvPut + KvDelete of the fixed reserved key lunaris:__health__:canary in the reserved __health__ scope;
  3. 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-secs is cut off with 408 and the {error:"request_timeout"} envelope, and counted in lunaris_http_timeout_total. The budget covers producing the response, not streaming its body — an SSE /v1/recall stream is never severed mid-flight;
  • a request arriving while --http-concurrency requests are already in flight is shed with 503 + Retry-After: 1 and the {error:"overloaded"} envelope, counted in lunaris_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):

MetricTypeLabels
lunaris_ingest_totalcountertenant, status
lunaris_ingest_duration_secondshistogramtenant
lunaris_recall_totalcountertenant, mode, status
lunaris_recall_duration_secondshistogramtenant, mode
lunaris_forget_totalcountertenant, target_kind, hard
lunaris_verify_queue_depthgaugetopic
lunaris_consolidator_queue_depthgaugetopic
lunaris_error_totalcounterkind (cardinality cap ≤ 10)
lunaris_eval_scoregaugeharness
lunaris_http_in_flightgauge(none)
lunaris_http_shed_totalcounter(none)
lunaris_http_timeout_totalcounter(none)
lunaris_readygauge(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/SIGINT the 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 logs WARN shutdown grace window expired; abandoning in-flight requests aborted_in_flight=<n> (n from the lunaris_http_in_flight gauge) 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 lunaris build 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 ↔ LunarisError mapping lives in crates/lunaris-server/src/middleware/error.rs::map_error; the full table is in the protocol spec and Error Taxonomy.

See also

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

CapabilityImplementation
Vector searchNative FT.SEARCH (HNSW)
BM25 keywordNative FT.SEARCH inverted index
RRF fusionNative, in-substrate — a (Vector + Keyword) pair on one index is a single round trip
Graph traversalNative GRAPH.QUERY (Cypher)
Pipeline queueNative Streams
Embedding dimEmbedder-sized — the adapter creates its vector index at embedder.dim() (default 768-d). No upper cap.
Bi-temporal as_ofNative, on the search and graph lanes (FT.SEARCH AS_OF, GRAPH.QUERY VALID_AT)
Historical KV readNot supported — see STORE-07 below
Tenant isolationPer-scope keyspace prefix lunaris:{scope}: + per-scope FT / GRAPH / MQ
Scope soft cap~512 scopes/node (max_scopes_recommended)
RecoveryAOF + 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.CREATE is idempotent and does not resize an existing index. If a Moon instance already holds a 768-d chunks index 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 1 is 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 yes is 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

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 — rerun scripts/test-recovery.py to re-verify.

1. What survives a crash

entitypersisted insurvives Moon crash?survives Lunaris client crash?
Episode body (KV)Moon HSET at lunaris:{scope}:episode:<ulid>yes — via AOF + RDBn/a (client-side is stateless)
Chunk text (KV)Moon HSET at lunaris:{scope}:chunk:<ulid>yesn/a
Vector (768-d)Moon chunks:<hex> HSET + FT HNSWyesn/a
BM25 indexMoon FT inverted indexyes (rebuilt from HSETs on replay)n/a
Graph (entities, facts)Moon GRAPH.* storageyesn/a
Pipeline queue (consolidate, verify)Moon Streamsyesn/a
In-flight ingest that wasn’t await-edno (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, so read_as_of past a 1-hour live window refuses with NotSupported → HTTP 501 rather than answering with today’s data (crates/lunaris-storage-moon/src/as_of.rs, pinned by crates/lunaris-conformance/tests/run_as_of_moon_gap.rs).

  • An ingest inserts a row with sys = (now, None) and the supplied valid range.
  • A correction (verifier supersede) closes the old row’s sys at the correction time and inserts a new row — both stay on disk.
  • A forget (soft, the default) closes sys on the target rows; the audit log records the close. A time-travel query with as_of before 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:

  1. Every write is appended to <dir>/appendonlydir/moon.aof.<N>.incr.aof. With --appendfsync always the append is fsync’d on every command — no data loss on kill -9 after a successful await ingest().
  2. 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 at moon.aof.<N+1>.incr.aof.
  3. On restart, Moon loads the newest base.rdb into memory, then replays the matching incr.aof on 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:

  1. 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.
  2. Run BGREWRITEAOF explicitly after large bulk ingests; poll for the new moon.aof.<N>.base.rdb to appear on disk before you consider the data “durable”.
  3. 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 BGSAVE for this. BGSAVE writes <dir>/dump.rdb, a separate artefact that does NOT participate in AOF replay. Only BGREWRITEAOF produces the appendonlydir/moon.aof.<N>.base.rdb that 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:

  1. Recoverable via synthetic replay. Write a tiny script that starts a fresh Moon on a scratch dir, re-plays the .incr.aof entries via a custom RESP client, then runs BGREWRITEAOF. (Requires reading the .incr.aof format — see moon/src/persistence/aof/ in the Moon repo.)
  2. Treat as data loss. Wipe the corrupt data dir, start fresh, re-ingest from source.
  3. File a bug on Moon. Ideally Moon would offer a --allow-unrooted-aof-replay flag 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:

testwhat it verifies
test_moon_killSIGKILL 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_killSIGKILL a child python ingest at the halfway mark. Asserts Moon state is self-consistent (no torn writes), FT.SEARCH still runs.
test_write_after_restartAfter 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 --save rules or schedule BGREWRITEAOF — 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.
  • .planning/architect/blueprint.md §5.4 — durability contract
  • crates/lunaris-storage-moon/src/atomic.rs — the WriteOp envelope shape
  • moon/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-server reads LUNARIS_TOKENS_FILE (a JSON map) once at startup (crates/lunaris-server/src/lib.rs, load_tokens) and resolves Authorization: Bearer <token> by in-memory map lookup (middleware/auth.rs). There are no JWTs — the “claims” (tenant partition scope, scopes verb 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 600 and 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 /healthz still answers. Watch the boot warning log.
  • Tenant isolation is wire-proof. The partition scope comes only from the token’s server-side tenant entry; every public DTO carries #[serde(deny_unknown_fields)], so a request body smuggling a scope/tenant field 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. scopes grants ingest/recall/forget per 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

DeferredWhere that’s recorded
TLS in-process, OAuth2/OIDC, managed JWT issuanceDEPLOY-V1-01, server.md → Deployment notes
/metrics authT-05-05-05 accept (comment in routes/metrics.rs)
Token hashing / runtime rotation endpointv0 tokens-file design (D-07) — revisit with DEPLOY-V1-01
OTLP trace exportADR 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) and docs/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. See docs/sdk/embedder-config.md and docs/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.RetrievalBuilder is the pure-Python plan builder from lunaris.dsl (the package __init__ re-exports it over the raw PyO3 class, whose builder methods are NotImplementedError stubs). handle.recall() returns one pre-bound to handle; a free lunaris.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:

FactoryUse 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:

FactoryUse 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 (or LUNARIS_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 at llamacpp(gguf_path=...). See docs/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, no llamacpp feature) raise a clear “no-inference build” error from llamacpp() — use noop() there.
  • FFI cliff: you cannot implement the Rust Embedder / Reranker trait 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 to lunaris-llamacpp or lunaris-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 real asyncio awaitables — 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 lunaris extension module is not part of cargo test --workspace (it’s a cdylib that fails to link under the workspace test runner) — test Python code with maturin develop + pytest, or via scripts/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, point gguf_path / LUNARIS_EMBEDDER_GGUF at 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-remote and set LUNARIS_EMBEDDER_OLLAMA_URL.
  • native() / native_quantized() raises “removed in the llama.cpp-only cutover” — working as intended; swap the call to llamacpp(gguf_path=...).
  • conformance_fixture_episodes not exported — correct; that helper lives behind the bindings-it Cargo feature, used only by the per-driver parity tests. Production wheels ship without it.

See also

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 / Graph imported from lunaris are 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 napi RetrievalBuilder, 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. See docs/sdk/embedder-config.md and docs/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):

FactoryUse 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 (or LUNARIS_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 at llamacpp({ ggufPath }). See docs/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 .node artifacts (built with default-features = false, no C++ toolchain, no llamacpp feature) raise a clear “no-inference build” error from llamacpp() — use noop() there.
  • FFI cliff: you cannot implement the Rust Embedder / Reranker trait 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 to lunaris-llamacpp or lunaris-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 — await them; don’t block the event loop on them.
  • The lunaris addon is not part of cargo test --workspace (it’s a cdylib) — test TS code with napi build + vitest, or via scripts/sdk-real-evidence.sh.
  • conformanceFixtureEpisodes / scanKvPrefix are only exported behind the bindings-it Cargo 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 point ggufPath / LUNARIS_EMBEDDER_GGUF at 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 to llamacpp({ ggufPath }).

See also

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/uvx packages (@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 prebuilt lunaris-mcp-<target>.tar.gz binaries attached to each GitHub release (since v0.6.1) or the cargo install --git source build. The npm/PyPI wrappers honour LUNARIS_MCP_BIN_PATH for 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:

ToolInputReturns
memory.ingestsource, content, optional t_ref, metadata{ lsn }
memory.recallquery, optional k, filters, as_of{ hits[] }
memory.forgettarget.source_prefix XOR target.episode_id, optional dry_run (defaults to true){ status, dry_run, matched, removed }
memory.list_scopes(none){ scopes[] }
memory.rememberkind (decision|fix|preference|constraint), content, optional why, tags, dedupe_key{ lsn, was_duplicate, source }
memory.record_decisiondecision, rationale, optional alternatives, tags, dedupe_key{ lsn, was_duplicate }
memory.record_editpath, 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.feedbackmemory_id, sentiment (±), reason (required), optional dedupe_key{ lsn, was_duplicate, activation_applied }
memory.verify_agendaoptional limit{ count, items[] } — episodes the staleness sweep flagged (recorded git anchor no longer matches HEAD for files they reference)
memory.resolveepisode_id, action (keep | invalidate), optional reason, superseded_by{ status, episode_id, invalidated, agenda_removed }
memory.dream_agendaoptional limit, min_cluster_size, max_activation{ status, total_candidates, count, clusters[] }read-only; writes nothing
memory.distillkind (decision | lesson | invariant | gotcha), content, source_episode_ids, optional title, tags, dedupe_key{ status, distilled_episode_id, lsn, archived_count, was_duplicate }
memory.profileoptional limit_per_section{ markdown, counts, total }
memory.scratchpad_writekey, value, optional namespace{ lsn }
memory.scratchpad_readkey, optional namespace{ found, value }
memory.scratchpad_greppattern, optional namespace{ entries[] }
memory.scratchpad_consolidateoptional namespace{ status, promotions, archives }
memory.retentionoptional max_age_ms (omit to READ), hard{ status, configured, max_age_ms, hard }
memory.retention_enforceoptional 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 initializetools/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:

  1. scratchpad_read / scratchpad_grep — exact or prefix key lookup; returns full verbatim values; no model load. Always first for known keys.
  2. memory.recall with the default k = 5 — hybrid semantic + BM25 preview pass. Hits are 200-character snippets (with episode_id, source, score), not full episode text, and the first call in a process stages/loads the GGUF embedder.
  3. Widen only on a miss — raise k, add filters.source_prefix (decision:, edit:, claude-code:), or pass as_of for 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:

  1. --scope flag / LUNARIS_MCP_SCOPE env var (highest priority).
  2. git remote.origin.url + current branch → blake3 → "git_<hex16>".
  3. 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:

#SourceNotes
1--storage / LUNARIS_MCP_STORAGEExplicit always wins.
2~/.lunaris/contextd-moon.urlThe store a running lunaris-contextd advertises, adopted only after a loopback + RESP PING liveness probe (25 ms, LUNARIS_MOON_DISCOVERY_TIMEOUT_MS).
3Refuses 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-moon makes lunaris-mcp launch an in-process Moon (rooted at ./.lunaris-moon) when no LUNARIS_MCP_STORAGE override is set, then use it automatically. The feature is off by default and is not compiled into the published npx/uvx/cargo install binaries. An explicit --storage/LUNARIS_MCP_STORAGE still 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.recall stages the GGUF embedder (~150 MB) and reranker to ~/.lunaris/models/ — expect ~30 s on a cold start, fast thereafter. Set LUNARIS_MCP_SKIP_STAGE=1 if models are pre-staged.

Key environment variables

VariableDefaultDescription
LUNARIS_MCP_SCOPEderived from git/cwdForce 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_MS25Liveness-probe budget for that discovery file (0 disables discovery)
LUNARIS_GRAPH_ENABLEDoffEnable the graph extraction/write path (Moon graph recall)
LUNARIS_MCP_LOGinfo,rmcp=warntracing-style filter directive (logs to stderr only)
LUNARIS_MCP_SKIP_STAGEunsetSet to 1 to skip GGUF staging on first recall
LUNARIS_MCP_BIN_PATHunset(npx/uvx wrappers) point at a pre-staged binary for air-gapped hosts

stdout is the JSON-RPC transport. lunaris-mcp writes logs to stderr only. Anything printed to stdout (e.g. an echo in your shell profile) corrupts the MCP framing and silently disconnects the host agent.

Per-agent guides

  • Claude Codeclaude mcp add, project-scoped .mcp.json, and the optional lifecycle hooks + context injection.
  • Codex CLI~/.codex/config.toml, plus hooks and the lunaris-contextd warm 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_STORAGE has no default (0.7.0). Set it, or run lunaris-contextd and 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

SymptomFix
lunaris-mcp: command not foundAdd export PATH="$HOME/.cargo/bin:$PATH" to your shell profile, restart the terminal, re-run claude mcp add.
Connected but no tools appearThe initialize handshake failed. Run lunaris-mcp directly; any startup error prints to stderr.
memory.recall returns emptyNothing 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 sOne-time GGUF staging to ~/.lunaris/models/. Set LUNARIS_MCP_SKIP_STAGE=1 if pre-staged.
Silent disconnectSomething is writing to stdout (often an echo/print in .bashrc/.zshrc). It corrupts MCP framing.
Wrong scopeRun 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_STORAGE has no default (0.7.0). Set it, or run lunaris-contextd and 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:

BinaryPurpose
lunaris-mcpMCP tools for explicit memory operations
lunaris-hookfast async event capture into Lunaris storage
lunaris-contextdwarm 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

SymptomFix
lunaris-mcp: command not foundAdd export PATH="$HOME/.cargo/bin:$PATH" to your shell profile, restart the terminal.
Tools don’t appear after Codex startsRun lunaris-mcp directly; any startup error prints to stderr.
memory.recall returns emptyNothing 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 sOne-time GGUF staging to ~/.lunaris/models/. Set LUNARIS_MCP_SKIP_STAGE=1 if pre-staged.
Silent disconnectA shell-profile echo/print is writing to stdout and corrupting MCP framing.
Wrong scopeRun 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.

configurationaccuracy
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 result81.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::writeLunaris::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

categorysonnet (system)floorcascade (oracle)
reasons behind preference updates90.9%65.7%97.0%
full preference evolution82.0%78.4%91.4%
recall user-shared facts78.3%2.3%83.7%
generalizing to new scenarios75.4%10.5%82.5%
preference-aligned recommendations74.5%21.8%80.0%
facts mentioned by the user58.8%17.6%64.7%
⚠ suggest new ideas — does not measure memory46.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

ConcernMem0Lunaris
RuntimePython / hosted REST APIRust core + Python (PyO3) + TypeScript (NAPI) bindings
StorageVector DB + graph DB + relational (3 services)Moon (one substrate, FT.* + graph + KV native)
AtomicityBest-effort per-store; no cross-store transactionOne atomic_write covers vector + KV + BM25 + audit + queue. CI gate enforces single call site
Bi-temporal factsNot modeled — overwrite semanticsFirst-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
TenancyPer-user “user_id” stringScope newtype with regex-validated alphabet [A-Za-z0-9_\-.]{1,128}, propagated through every storage call and enforced by a per-scope Moon keyspace
ForgettingHard deleteTombstone via bi-temporal sys_time close (audit trail preserved)
LicenseApache 2.0Apache 2.0
Default LLM couplingOpenAINone — 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:

  1. scope is a typed object, not a string. Wire-side payloads cannot inject a different scope past the type system — the ScopedLunaris wrapper threads the validated Scope through every storage call. (The typed Scope / EpisodeBuilder SDK ergonomics land in v0.3; today the Python surface uses dicts — see the Python SDK page.)
  2. 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 returns StorageError::NotSupported (HTTP 501 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:

  1. Stand up Lunaris alongside Mem0. Use the examples/quickstart-py recipe — 5-minute docker-compose, no infra commitment.
  2. Mirror writes. Every m.add(...) is also dispatched to mem.scoped(scope).ingest(...). Lunaris is now collecting bi-temporal facts in parallel.
  3. Shadow reads. Every m.search(...) is also issued to Lunaris’s recall DSL. Diff the result sets in your eval harness.
  4. Cutover when the diff is acceptable. Promote Lunaris to primary; keep Mem0 as fallback for ~1 release.
  5. 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 Scope would 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.
  • Mem0’s metadata-extraction prompts. Lunaris ships an Extractor trait with remote-only backends (ollama / cloud-api, selected via LUNARIS_EXTRACT_PROVIDER), but the default prompt set differs from Mem0’s. If your existing Mem0 deployment depends on specific extracted fields, override Extractor::extract and 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

ConcernZepLunaris
RuntimePython service (Zep Cloud or self-hosted)Embedded Rust core + Python (PyO3) + TypeScript (NAPI) bindings
StoragePostgres + Neo4j (two services)Moon (one substrate, FT.* + graph + KV native)
Bi-temporal modelTemporal 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 latency200–500 ms (HTTP hop + Python)p50 ≤ 25 ms / p99 ≤ 100 ms on laptop-arm64
Tenancyuser_id / session_id strings on the APIScope newtype ([A-Za-z0-9_\-.]{1,128}) threaded through every storage call + per-scope Moon keyspace
AtomicityPer-store best-effort; no cross-store transactionOne atomic_write covers vector + KV + BM25 + audit + queue. CI gate enforces single call site
Graph queriesCypher via Neo4jMoon native graph (Cypher dialect); the Graph::anchored operator lowers to it
Embedder couplingOpenAI defaultIn-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 episodicACT-R base-level activation + Leiden community detection (RFC blueprint §5.1)
LicenseApache 2.0Apache 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 returns StorageError::NotSupported (HTTP 501 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

  1. Stand up Lunaris alongside Zep. Use examples/quickstart-py. No infra commitment beyond docker-compose.
  2. Map session_idScope. 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).
  3. Mirror writes. Every memory.add_memory(...) is also dispatched to mem.scoped(scope).ingest(...).
  4. Shadow reads. Issue every memory.search_memory(...) to Lunaris in parallel. Diff the result sets in your eval harness.
  5. Cutover when the diff is acceptable. Promote Lunaris to primary; keep Zep as fallback for ~1 release.
  6. 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-remote for 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

ConcernCogneeLunaris
RuntimePythonEmbedded Rust core + Python (PyO3) + TypeScript (NAPI) bindings
StorageVector DB (LanceDB / Qdrant / Weaviate / …) + Graph DB (Neo4j / FalkorDB / Memgraph / …) — pluggableMoon (one substrate, FT.* + graph + KV native)
Composition modelPipeline of Tasks operating on DataPointsComposable retrieval DSL (vector, keyword, graph + .and / .or / .then / .fuse_rrf)
Bi-temporalNot 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 latencyDepends on backend (~50 ms LanceDB local, ~200 ms cloud)p50 ≤ 25 ms / p99 ≤ 100 ms on laptop-arm64
AtomicityPer-store best-effortOne atomic_write covers vector + KV + graph + audit + queue. CI gate enforces single call site
Tenancydataset string on the APIScope newtype ([A-Za-z0-9_\-.]{1,128}) threaded through every storage call + per-scope Moon keyspace
Graph query languageCypher (via backend)Moon native graph (Cypher dialect); the Graph::anchored(entity_ids, hops) operator lowers to it
Custom pipeline tasksFirst-class — register Tasks, compose with await cognee.cognify()Override Extractor trait (Phase 3); recall DSL is fixed surface
LicenseApache 2.0Apache 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 returns StorageError::NotSupported (HTTP 501 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

  1. Stand up Lunaris alongside Cognee. Use examples/quickstart-py.
  2. Map datasetScope. Same [A-Za-z0-9_\-.]{1,128} alphabet constraint as the Zep migration.
  3. Port custom Cognee Tasks to a Lunaris Extractor impl. 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.
  4. Mirror writes. Every cognee.add(...) + cognee.cognify(...) is also dispatched to mem.scoped(scope).ingest(...).
  5. Shadow reads. Every cognee.search(...) is also issued to Lunaris’s recall DSL. Diff in your eval harness.
  6. 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 Extractor impl 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 the Extractor trait 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 StoragePort trait 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_COMPLETION search 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 compose recall() + 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 Authorization header → 401 Unauthorized.
  • Token not in map → 401 Unauthorized.
  • Token present but lacks the required scope for the route → 403 Forbidden.

Per-route scope requirements:

RouteRequired scope
POST /v1/ingestingest
POST /v1/recallrecall
POST /v1/forgetforget
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 “JWT tenant claim” in this document as “the tenant claim the server resolved for this token”. Managed JWT/OIDC issuance is the v1 gate DEPLOY-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 Requests with a Retry-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:

  1. POST /v1/forget with dry_run: true + the target → 200 OK + ForgetReceipt { preview: true, ... }.
  2. POST /v1/forget with hard: 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::forget still routes through Scope::dev() internally for some calls, so a same-scope forget under a non-dev scope can return rows_deleted=0 with no error — see docs/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 in wall_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 returns 200 + 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):

NameTypeLabelsNotes
lunaris_ingest_totalcountertenant, statusOne increment per POST /v1/ingest; status ∈ {ok, error}.
lunaris_ingest_duration_secondshistogramtenantWall-clock from request entry to response sent (includes business logic).
lunaris_recall_totalcountertenant, mode, statusmode ∈ {semantic, graph}; status ∈ {ok, error}.
lunaris_recall_duration_secondshistogramtenant, modeWall-clock; same shape as ingest_duration.
lunaris_forget_totalcountertenant, target_kind, hardtarget_kind ∈ {id, scope, before}; hard ∈ {true, false}.
lunaris_verify_queue_depthgaugetopicPolled every 10 s from StoragePort::queue_depth("__lunaris_verify__", 0).
lunaris_consolidator_queue_depthgaugetopicPolled every 10 s from StoragePort::queue_depth("__lunaris_consolidate__", 0).
lunaris_error_totalcounterkindLunarisError variant tag; cardinality cap ≤ 10. Incremented inside map_error.
lunaris_eval_scoregaugeharnessPopulated by lunaris-evals (Plan 05-06); harness ∈ {longmemeval, locomo, er-f1, …}.

Cardinality bounds (T-05-05-02 mitigation):

  • tenant set membership = --tokens-file JSON 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 statusServer causeLunarisError variantJSON body shape
400Bad request body / filter DSLLunarisError::Validate(_){ "error": "validate", "message": "..." }
400Bad confirmation_token JSONn/a (handler-local validation){ "error": "invalid_confirmation_token", "message": "..." }
400Bad RFC-3339 as_ofn/a (handler-local validation){ "error": "invalid_request", "message": "..." }
400Bad NDJSON snapshot Lsnn/a (handler-local validation){ "error": "invalid_lsn", "message": "..." }
400Bad episode ULID path paramn/a (handler-local validation){ "error": "invalid_episode_id", "message": "..." }
401Missing / invalid bearern/a (auth middleware){ "error": "unauthorized", "message": "..." }
403Token lacks required scopen/a (auth middleware){ "error": "forbidden", "message": "..." }
404Snapshot LSN wall_ms strictly futuren/a (handler-local validation){ "error": "snapshot_out_of_range", "message": "..." }
404Episode not found in caller’s scopen/a (handler-local: read_as_ofNone){ "error": "episode_not_found", "message": "..." }
404/metrics disabled at startupn/a (--metrics-disabled runtime flag)metrics disabled at startup via --metrics-disabled
422scope / tenant field in bodyn/a (#[serde(deny_unknown_fields)])(serde rejection)
428Hard-delete without confirmationLunarisError::Validate(ValidateError::ConfirmationRequired(_)){ "error": "confirmation_required", "message": "..." }
429Rate limit exceededn/a (tower-governor middleware)(empty body; Retry-After header)
500Storage / retrieve / extract / consolidate errorLunarisError::Storage(_) / ::Retrieve(_) / ::Extract(_) / ::Consolidate(_) (and any unmapped LunarisError){ "error": "storage" | "retrieve" | "extract" | "consolidate" | "unknown", "message": "..." }
501Capability not offered by the chosen backendLunarisError::Storage(StorageError::NotSupported(_)) (via map_error){ "error": "not_supported", "message": "..." }
501Graph mode requested without a graph-capable backend / pipelinen/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 by atomic_write.
  • Hlc — Hybrid Logical Clock; { wall_ms: u64, counter: u32, node_id: u16 }. Used in as_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 + degraded flag + valid_from / valid_to.
  • ForgetReceipt — output of forget; carries indices_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:

  1. 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.
  2. 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.
  3. 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_gap runs 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

SuiteFunctionTestsWhat it covers
Storagelunaris_conformance::run_full_storage_suite(storage)9atomic_write, vector_search, graph_traverse (gated), scan_range, read_as_of (latest + historical), publish/subscribe, capabilities
Protocollunaris_conformance::run_full_protocol_suite(client, url, t)10POST /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 gaprun_as_of_moon_gap (test target)1STORE-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_URL at 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_gap replaces 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:

  1. Returns the exact HTTP status codes and response shapes documented in MemoryProtocol 0.1 — every cell in the §Error taxonomy table is testable.
  2. Honors every StorageCapabilities field accurately. A backend that reports graph_native: true MUST implement Cypher subset queries; a backend that reports queue_native: true MUST implement publish / subscribe round-trip semantics.
  3. Surfaces Hit::degraded = true when the verifier-queue depth check fires (when applicable to the deployment).
  4. Issues exactly one atomic_write per ingest / forget call (single-write invariant from Plans 04-04 / 04-05; INGEST-04).
  5. Publishes one AuditEvent to __lunaris_audit__ on every successful forget (best-effort, fire-and-forget per Plan 04-05 OPS-04).
  6. Honors the D-21 two-step hard-delete rail: hard: true without confirmation_token MUST return 428 Precondition Required.
  7. Returns 429 Too Many Requests with a Retry-After header on rate-limit exhaustion.

Environment variables

VariablePurposeRequired for
MOON_URLMoon backend connect URL (e.g., moon://localhost:6380)storage suite, protocol suite
LUNARIS_CONFORMANCE_STRICT1 turns every skip decision into a hard failureCI, where the store is provisioned by the job
CARGO_TARGET_DIROverride target dir for binary discoveryprotocol suite when out-of-tree builds used
CARGO_BIN_EXE_lunaris-serverPre-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 on rerank_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:

RFCTitleWhat it decides
docs/rfcs/0001-scope-newtype.mdScope newtype and ScopedLunaris<'a> typestateMulti-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.mdExtractorTier typestate enum and laptop-floor default swapHistorical. 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.mdVerifier default swap: Gemma 3 27B → Gemma 3 270MHistorical. 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.mdFallbackExtractor<P, F> / FallbackEmbedder<P, F> with per-provider circuit breakersThe 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-parity ScoreEpsilon story).
  • 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.1 unwrap() / 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 in docs/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 doc on every release — is the authoritative API surface; see API Reference.
  • Where this book disagrees with the Rust source, the source wins.