A small, self-contained agent memory library for Go — drop it into any agent to give it persistent, searchable long-term memory.
You feed it conversation messages; it uses an LLM to extract durable, self-contained facts, embeds and deduplicates them, and stores them in a per-scope vector index. Later you search for the facts relevant to a query.
- Library-first. Import
github.com/AccursedGalaxy/mnemedirectly. A thin HTTP + MCP server binary wrapping the same core lands later (seePLAN.md). - Self-contained. No shared code with any consumer. Its own OpenAI-compatible LLM/embedding client, its own storage.
- Single binary. Default storage is pure-Go SQLite (no cgo). Pluggable for
sqlite-vec / pgvector as you scale, behind the
Storeinterface. - Our own prompts. The extraction prompt is ours, scored by a built-in eval harness — not a vendored copy.
go get github.com/AccursedGalaxy/mnemepackage main
import (
"context"
"fmt"
"github.com/AccursedGalaxy/mneme"
)
func main() {
m, err := mneme.New() // builds LLM, embedder and store from MNEME_* env
if err != nil {
panic(err)
}
defer m.Close()
ctx := context.Background()
scope := mneme.Scope{UserID: "alice"}
// Ingest a conversation — durable facts are extracted and stored.
m.Add(ctx, []mneme.Message{
{Role: "user", Content: "I moved to Berlin and adopted a tabby cat named Pixel."},
}, scope)
// Retrieve by meaning.
hits, _ := m.Search(ctx, "what pets does the user have?", scope, 3)
for _, h := range hits {
fmt.Printf("(%.3f) %s\n", h.Score, h.Text)
}
}A runnable version is in examples/basic.
Message.Timestamp is optional, and setting it is the cheapest accuracy win
available:
m.Add(ctx, []mneme.Message{
{Role: "user", Content: "I went to the support group yesterday.",
Timestamp: time.Date(2023, 5, 8, 13, 56, 0, 0, time.UTC)},
}, scope)It anchors the extractor's date arithmetic on when the conversation happened
rather than on when you ingested it, and it lands on the resulting fact as
Fact.ObservedAt. Leave it out and the pipeline falls back to its clock, which
means "yesterday" resolves against today — so a conversation ingested months late
produces facts with confidently wrong dates that nothing downstream can detect.
Fact.CreatedAt is when the fact was written; Fact.ObservedAt is when it was
said. They are different questions and the store now keeps both.
mneme.New() builds providers from the environment (override any of them with
WithLLM / WithEmbedder / WithStore). The client speaks the
OpenAI-compatible wire format, so it works with OpenAI, OpenRouter, Ollama,
vLLM, LM Studio, etc.
| Env var | Meaning | Example |
|---|---|---|
MNEME_LLM_BASE_URL |
OpenAI-compatible base URL | https://openrouter.ai/api/v1 |
MNEME_LLM_API_KEY |
API key (may be empty for local) | sk-… |
MNEME_LLM_MODEL |
extraction model | google/gemini-2.5-flash |
MNEME_EMBED_BASE_URL |
embeddings base URL (defaults to LLM base) | — |
MNEME_EMBED_API_KEY |
embeddings key (defaults to LLM key) | — |
MNEME_EMBED_MODEL |
embedding model | text-embedding-3-small |
MNEME_DB_PATH |
SQLite file path | ./mneme.db |
Recommended extraction model: google/gemini-2.5-flash. Memory quality is
dominated by how well the extraction model captures facts, not by the embedder or
retrieval tricks. In our LoCoMo A/B (answer and judge models pinned, n=1986
questions), switching extraction from gemini-2.5-flash-lite to
gemini-2.5-flash lifted the end-to-end judge score 0.48 → 0.52, and 0.34 → 0.40
on the 1540 questions that have an answer at all (+0.057, 95% CI [+0.026,
+0.084], bootstrapped over conversations). Temporal questions carry most of the
gain: 0.24 → 0.49. Consolidation measured as a real regression on the same
harness. A stronger embedder trends slightly positive but does not separate from
noise at our sample size, so it is not worth its cost on this evidence.
Treat these as relative numbers from our harness rather than as comparable to
published LoCoMo results. gpt-5-mini and deepseek-v3.2 failed to complete a
bench run, so there is no end-to-end evidence about them either way, and
text-embedding-3-small is fine because a larger embedder did not help. The
honest headline is that mneme still answers "I don't know" to 40% of the
answerable questions, because the facts were never extracted. See
bench/RESULTS.md for the full matrix, the caveats, and the
scoring-bug history behind an earlier set of numbers (0.23 → 0.30) that this
supersedes.
type Memory interface {
Add(ctx context.Context, msgs []Message, scope Scope) ([]Fact, error)
Search(ctx context.Context, query string, scope Scope, k int) ([]Fact, error)
Get(ctx context.Context, id string) (Fact, error)
Delete(ctx context.Context, id string) error
Close() error
}Facts are isolated per Scope (UserID / AgentID / RunID) on both write and
search. By default the pipeline is additive: facts accumulate and are deduped
by hash and by the extractor's awareness of existing memories — one LLM call per
Add, no update/delete pass. Pass WithStrategy(Consolidate) to add a second
LLM call that reconciles new facts against existing ones (ADD/UPDATE/DELETE/NONE)
so changed facts update in place — see below.
By default mneme never updates or deletes a fact on its own. This has consequences you must design around:
- Garbage in, garbage forever. A wrong fact you
Addis recalled until youDeleteit by id. Be deliberate about what you feedAdd— don't blindly store unverified model output. - Stale facts coexist with new ones. If the world changes ("works at Shopify"
→ "works at Stripe"), both facts can live in the store and both can surface in
Search. There is no notion of "the current value." If you need that, dedupe or expire at the application layer, orDeletesuperseded facts yourself. - The embedding model is part of the store's identity. All vectors in one
store must come from the same embedding model — query and stored vectors share
a space. The store records its embedder's model name and dimension on the first
insert, and
Newreturns an*EmbedderMismatchErrorif you later point a different embedder at it — so an accidental swap fails loudly instead of silently degrading search. Use a fresh store when you intentionally change embedders, or passmneme.AllowEmbedderMismatch()to override the guard. Stores written before this guard existed have no recorded identity, so the first insert after upgrading pins whatever embedder is configured then — point the same embedder you originally used at a legacy store, or the guard will lock in the wrong one.
These are deliberate tradeoffs for the default strategy (simpler, one LLM call
per Add). When staleness matters more than recall, opt into consolidation:
m, _ := mneme.New(mneme.WithStrategy(mneme.Consolidate))Consolidation costs recall on our benchmark, so it stays opt-in. On LoCoMo it scores 0.31 against additive's 0.34 on answerable questions, and the reason is visible in the per-question dumps: it abstains on 47% of them where additive abstains on 40%, because it deletes facts the answerer would have used. Turn it on if your application genuinely needs "the current value" and can accept losing some history. Don't turn it on expecting a better score.
Consolidate runs a second LLM call per Add that reconciles the new facts
against the existing ones — UPDATE a changed value in place, DELETE a
contradicted one, ADD genuinely new facts, leave the rest untouched. The facts it
reconciles against are retrieved per extracted candidate, not by the
conversation, so a new fact ("moved to Berlin") surfaces the stale one it
overturns ("lives in Munich") even when that fact is unlike the rest of the turn.
WithConsolidationTopK sets how wide that reconciliation window is (default 30) —
larger widens recall on stale facts at the cost of a longer prompt. It costs the
extra LLM call (only when there are facts in scope to reconcile against) plus a
cheap per-candidate retrieval, and a malformed/failed consolidation response
safely falls back to an additive insert. Conversation text is untrusted input
that reaches this LLM call, so the write path caps the blast radius in code: at
most one UPDATE/DELETE of an existing memory is applied per extracted candidate,
which keeps a hostile or confused turn from rewriting or wiping the whole
reconciliation window in a single Add. See PLAN-v2.md §4.2.
Brute-force cosine over a single query phrasing leaves recall on the table: the
fact that answers a question often exists but sits just past the top-k cutoff
(retrieval dilution). Two opt-in Search boosters recover it, both leaving the
public Search signature unchanged:
m, _ := mneme.New(
mneme.WithReranker(&openai.LLMReranker{LLM: llm}), // over-retrieve, reorder, keep top-k
mneme.WithMultiQuery(3), // expand into 3 phrasings, union the hits
)- Rerank (
WithReranker):Searchretrieves a wider pool (DefaultRerankPoolN, ~20) instead of justk, hands it to theRerankerto reorder by relevance, then keeps the leadingk. The shippedopenai.LLMRerankerscores each candidate with one LLM call and is best-effort — a malformed response or a failed LLM call leaves the cosine order untouched, so enabling it never makesSearchdepend on the rerank model being up. It reorders only;Fact.Scorekeeps the retrieval similarity. TheRerankerinterface is tiny, so a cross-encoder or hosted rerank API can drop in later. - Multi-query (
WithMultiQuery(n)): one LLM call rewrites the query into up tondiverse phrasings; their hits are unioned (deduped by id, best score wins) before reranking. Targets multi-hop questions where one phrasing won't surface every needed fact. Expansion is best-effort: if the call or its parse fails,Searchfalls back to the original query alone.
Each booster adds an LLM call per Search, so opt in when retrieval accuracy is
worth roughly 2× the query cost. On LoCoMo they lift multi-hop questions by
+0.07 (95% CI [+0.005, +0.135]) and do nothing measurable for temporal ones,
which are the extraction model's job instead. Their effect on the answerable set
as a whole does not separate from noise at our sample size, so treat them as a
targeted fix for multi-hop retrieval rather than a general win. The bench
harness exposes both as -rerank and -multiquery n. See PLAN-v2.md §4.3.
The extraction prompt is versioned (extractionPromptV1, …) and scored by the
harness in eval/, so prompt changes are decisions backed by numbers.
go run ./cmd/eval # offline-safe fake embedder, real LLM
go run ./cmd/eval -embedder openai # real embeddings, true semantic search@k
go run ./cmd/eval -min-aggregate 0.95 # fail (exit 1) if any version regressesCurrent baseline for extractionPromptV1 (gpt-4o-mini, text-embedding-3-small,
k=3) — see eval/RESULTS.md:
| recall | precision | specificity | search@k | dedup | aggregate |
|---|---|---|---|---|---|
| 0.94 | 0.97 | 1.00 | 0.94 | 1.00 | 0.97 |
The invariant — every later prompt version must not regress the aggregate — is
enforced by the -min-aggregate gate, run by the
Eval regression gate workflow (manual + weekly,
needs the OPENROUTER_API_KEY repo secret). The workflow pins the judge to a
fixed model (openai/gpt-4o-mini) so the scoring oracle stays constant when the
extraction default changes. The gate is a regression floor (default 0.95)
rather than the exact baseline: with 18 fixtures, one fixture swinging moves the
aggregate by up to ~0.05, so the floor is set to tolerate a single noisy fixture
while still tripping on a regression that touches several. Note the gate runs on
demand and weekly, not on PRs — a prompt regression can merge and is caught by
the next scheduled run.
CI runs gofmt, go vet, go build and the
full race-enabled test suite on every push and PR — all offline, no secrets.
Mirror it locally with make ci (and make eval for the live prompt gate).
Unit tests run fully offline with deterministic fakes — no network:
go test ./...Versioned with SemVer; the git tag is the version
(go get github.com/AccursedGalaxy/mneme@vX.Y.Z). See CHANGELOG.md
for what changed and RELEASING.md for how a release is cut —
pushing a v* tag auto-publishes a GitHub release via
release.yml.
MIT — see LICENSE.