Universal persistent memory for LLM agents. Deterministic label-token search. No vectors. No probability. Facts survive between sessions.
How it's tested · Research & findings
Every new LLM session starts fresh. OMEM gives it memory that persists.
User → LLM → memory_find("morning drinks") → "Juice, water. Quit coffee May 2026."
The LLM calls a tool. The tool finds and returns. The LLM answers.
Write path:
text → Decomposer (LLM) → (label, compact fact)
→ find_similar in DB
→ Merger (LLM) → merge or create new
→ upsert fact + append source
→ register branches + update descriptions
After decomposition, the merger decides: merge only if both texts describe the same attribute (one question answered by either). Different facts on the same topic are separate (different activities, possessions, or arrangements get separate labels). On a separate verdict against an occupied primary address, the write accretes onto the stored value (Also: …) rather than replacing it. The merger may also propose a new label for the new fact if the suggested label is unoccupied. At write time, the taxonomy records new branches with auto-generated descriptions and updates descriptions on branches whose fact count has doubled (living descriptions). The write path (decomposer and merger) runs at temperature 0, minimizing sampling variance in label assignment (LLM providers do not guarantee bit-level determinism). A merge_max_clauses guardrail rejects cross-address merges once a value carries too many appended clauses, preventing heterogeneous facts from being dragged into one address.
Read path:
query → dual-dictionary token search (label_tokens + value_tokens) → 1-3 facts → LLM answers
Queries are concept groups: space-separated keywords, where synonyms of the same
concept may be joined with | (meditation|mindfulness ritual|routine). Specific
multi-concept queries must match at least two different concepts in a fact
(min-should-match), which cuts noise without penalizing synonym expansion.
Browse cycle (on a miss):
If memory_find returns nothing, the LLM navigates the tree deterministically via
memory_list. Top-level call shows all branches with fact counts and descriptions
(information scent for choosing direction). For a candidate branch, descend with the
branch prefix; multi-prefix calls (user.finance,user.home in one call) save budget.
The answer must directly state what was asked — a fact on a merely related topic is
not an answer. When no branch fits the concept or contains the asked information,
report that the information is not in memory. Do not broaden the request.
Two storage layers:
- Facts —
label → compact fact(1-3 sentences, <10ms lookup) - Sources — full original text, append-only, retrieved on demand
No vectors. No embeddings. One concept = one label = one fact.
The taxonomy is grown from user facts, not fixed at startup. The decomposer receives the existing label space — a two-level branch prefix tree (with descriptions and topic names) plus label candidates from token overlap — and must reuse a matching label only for the same attribute a label already names; different facts on similar topics get distinct labels in the branch whose meaning matches their domain. Top-level roots are soft-enforced: new roots ask for confirmation; second-level branches register automatically with generated descriptions. Branch descriptions evolve as the branch grows (living descriptions), so top-level navigation stays accurate.
From a clone of this repo:
# macOS / Linux (and Windows via WSL or Git Bash)
./install.sh
# Windows (native PowerShell)
powershell -ExecutionPolicy Bypass -File .\install.ps1The installer is idempotent and does everything end to end:
- Checks prerequisites — Docker running, Rust toolchain (
rustup). - Provisions a ParadeDB (Postgres +
pg_search) container, reusing an existingomem-postgresif present. - Builds the binaries (
omem,omem-web,omem-mcp). - Asks which write-path LLM to use (Ollama or Anthropic) and writes
~/.omem/config.toml— model names live in your config, never in code. - Runs migrations (
omem init) — this enables the BM25 index whenpg_searchis present and falls back to GIN on vanilla Postgres. - Registers the MCP server with Claude Code at user scope
(
claude mcp add -s user omem -- …/omem-mcp).
Then: ./target/release/omem dash opens the dashboard.
Requires Docker and a Rust toolchain. Use an existing Postgres instead of the
container with OMEM_SKIP_DOCKER=1 OMEM_DATABASE_URL=… ./install.sh. Other
overrides: OMEM_PG_PORT, OMEM_CONTAINER, OMEM_LLM_PROVIDER, OMEM_LLM_MODEL.
Prefer to wire it up yourself? The steps the installer automates:
Docker (recommended):
docker run -d --name omem-postgres \
-e POSTGRES_DB=omem \
-e POSTGRES_HOST_AUTH_METHOD=trust \
-p 5432:5432 postgres:16-alpineHomebrew:
brew install postgresql@16 && brew services start postgresql@16 && createdb omemollama pull gemma4:12b
ollama serveOr use any OpenAI-compatible API (OpenAI, Groq, LM Studio).
mkdir -p ~/.omem
cat > ~/.omem/config.toml << 'EOF'
database_url = "postgres://postgres@localhost/omem"
[llm]
endpoint = "http://localhost:11434/v1"
model = "gemma4:12b"
api_key = "ollama"
write_mode = "auto" # auto | confirm | manual
stale_days = 90
confirm_merge = false
EOFcargo build --release
./target/release/omem initomem write "I drink juice and water every morning. Quit coffee in May 2026."
omem find "morning drinks"
# → [user.morning.drinks] Drinks juice and water every morning.
omem stats
omem list # top-level branches with fact counts
omem list user.health # browse a branch: sub-branches + factsOMEM ships an MCP server (omem-mcp, stdio) that gives any MCP client the
memory tools. The installer registers it automatically with Claude Code at
user scope, so if you ran ./install.sh / install.ps1 you can skip this.
Claude Code (CLI) — recommended. Register once, available in every project:
claude mcp add -s user omem -- /absolute/path/to/omem/target/release/omem-mcp
# verify (expect: omem: … - Connected)
claude mcp list
# remove later: claude mcp remove -s user omemScope -s user makes it global; use -s project to scope it to one repo
(writes a .mcp.json), or -s local for just your machine-local config.
Claude Desktop. Add to
~/Library/Application Support/Claude/claude_desktop_config.json
(macOS) / %APPDATA%\Claude\claude_desktop_config.json (Windows), then restart:
{
"mcpServers": {
"omem": { "command": "/absolute/path/to/omem/target/release/omem-mcp" }
}
}The server reads ~/.omem/config.toml for its database and (write-path) LLM, so
no env vars are needed in the client config.
Once connected, the agent has memory_find, memory_list, memory_write,
memory_deep_search, memory_expand — see MCP tools.
omem init # Initialize database (run migrations)
omem write "text" [--space NAME] [--yes] # Store text in memory
omem find "query" [--space NAME] # Find facts by keywords
omem list [PREFIX] [--space NAME] # Browse label tree: branches with counts + facts
omem list --stale | --archived # Flat list filtered by flag
omem source UUID # Print full source text
omem archive LABEL [--space NAME] # Archive fact or pattern (e.g. user.health.*)
omem restore LABEL [--space NAME] # Restore archived fact
omem delete LABEL [--space NAME] # Delete fact permanently
omem stats # Memory statistics
omem mark-stale # Mark facts unused for stale_days as staleA fact can live at up to three addresses in the label tree: one primary label
plus up to two aliases (for facts that genuinely belong to two branches, like
rent — finance and home). Invariant: one address resolves to at most one
fact. Aliases are created by the decomposer at write time and by the merger
(an absorbed label survives as an alias — merging never destroys an address).
Listings show alias rows as [address → primary] value; search matches any
address and always displays the primary label. Writing to an alias updates
the fact it points to.
The label tree is governed by a registry (ontology as data): 2nd-level
branches are created automatically on write with a generated one-line
description; creating a new top-level root always asks for confirmation.
Branch descriptions are shown in omem list and memory_list to guide
navigation.
omem branch list [--space NAME] # Registered branches with descriptions
omem branch describe NAME "text" # Set a description by hand
omem branch annotate [--space NAME] # Fill missing descriptions (one LLM pass, idempotent)omem agent create ID [--spaces s1,s2] [--read p1,p2] [--write p1,p2]
omem agent list
omem agent show ID
omem agent delete IDExample:
# Doctor agent: reads health facts, writes only checkup results
omem agent create doctor \
--spaces personal \
--read "user.health.*,user.medications.*" \
--write "user.health.checkup.*"
# Code agent: full access to project space only
omem agent create code-assistant \
--spaces project \
--read "project.*" \
--write "project.architecture.*,project.decisions.*"Facts become stale if not read for stale_days (default: 90). Stale facts are still searchable
but flagged for review; marking as stale does not delete facts — deletion is the user's choice.
Manual marking:
omem mark-stale # Mark facts unread for 90+ days as staleScheduled maintenance (macOS):
Copy the provided launchd agent and adjust paths:
cp scripts/com.omem.mark-stale.plist ~/Library/LaunchAgents/
# Edit the plist file: set correct script path + desired hour/minute
launchctl load ~/Library/LaunchAgents/com.omem.mark-stale.plistThe agent runs once daily at 09:13 UTC and logs to ~/Library/Logs/omem-mark-stale.log.
Scheduled maintenance (Linux):
Use cron instead:
# Run mark-stale daily at 09:13
13 9 * * * /path/to/omem/scripts/omem-mark-stale.sh >> $HOME/.local/share/omem/mark-stale.log 2>&1Checking stale facts:
omem list --stale # View all facts marked stale| Tool | Parameters | Description |
|---|---|---|
memory_find |
query, space?, limit?, agent_id? |
Fast label-token search. Always call this first. |
memory_list |
prefix?, space?, agent_id? |
Deterministic label-tree browsing. Use after a memory_find miss: descend branch by branch; no fitting branch means the information is not in memory. A comma-separated prefix (user.finance,user.home) opens several branches in one call. |
memory_write |
text, space?, confirmed?, agent_id? |
Decompose + merge + store. |
memory_deep_search |
query, target? |
Full-text search on sources. Use only if memory_find fails. |
memory_expand |
source_id |
Retrieve full original source text. |
In confirm or manual write mode, memory_write returns a PENDING proposal without writing.
Call again with confirmed: true to actually save.
LLM calls: memory_write(text="...", confirmed=false)
→ returns: "PENDING — [new] user.morning.drinks = "Juice and water"
Call again with confirmed=true to save."
User: "yes, save it"
LLM calls: memory_write(text="...", confirmed=true)
→ returns: "Wrote 1 fact(s): + user.morning.drinks = "Juice and water""
Pass an agent ID to apply its read/write restrictions:
memory_find(query="health", agent_id="doctor")
→ only returns facts matching doctor's read_patterns
memory_write(text="...", agent_id="code-assistant")
→ denied if Decomposer produces labels outside write_patterns
~/.omem/config.toml:
database_url = "postgres://postgres@localhost/omem"
[llm]
endpoint = "http://localhost:11434/v1" # OpenAI-compatible API endpoint
model = "gemma4:12b" # Model name
api_key = "ollama" # API key ("ollama" for local)
write_mode = "auto" # auto | confirm | manual
stale_days = 90 # Days before marking fact as stale
confirm_merge = false # Ask before merging facts (default: auto-merge at ≥90% confidence)
merge_max_clauses = 4 # Reject cross-address merges once a value carries this many
# appended clauses (Also:/Alternatively:/Now:) — anti-heterogeneity
# Judges for the omem-bench merge-quality metric (benchmark only).
# Pick at least one model that differs from llm.model so the writer
# does not grade its own merges. Override per run: --judge-model M1[,M2]
[bench]
judge_models = ["gemma4:12b"]| Mode | CLI behaviour | MCP behaviour |
|---|---|---|
auto |
Writes immediately | Writes immediately |
confirm |
Shows proposed facts, asks [y/N] |
Returns PENDING, requires confirmed=true |
manual |
Writes on explicit command (omem write) |
Returns PENDING, requires confirmed=true |
--yes flag skips the CLI prompt in confirm mode.
The encrypted space stores fact values and source bodies as
AES-256-GCM ciphertext at rest — anyone who can read the database (a Postgres
admin, another process, a leaked pg_dump, a synced data file) sees only
ciphertext. Labels stay plaintext, so navigation (memory_list) and
label-based merge keep working; value full-text search does not apply to
encrypted facts (you reach them by label).
Pair it with full-disk encryption (FileVault / LUKS) as the baseline for laptop-theft and backups; the app-level layer adds protection against readers of the live database.
# Initialize once. Default: key in the OS keystore (Keychain / Credential
# Manager / secret-service), protected by your OS login — no passphrase.
omem encrypted init
# Or derive the key from a passphrase (Argon2id) — for headless boxes:
omem encrypted init --passphrase
omem encrypted status
# Write / read — the space auto-unlocks (keystore) or prompts (passphrase):
omem write --space encrypted "Therapist is Dr. Chen; 50mg sertraline daily"
omem find --space encrypted "therapist"Key handling: the AES-256 key lives in memory only while unlocked; long-running
processes (MCP, web) drop it after an inactivity timeout. A locked reader
returns the value as [encrypted] — so the web dashboard and the MCP server
(which are not unlocked by default) never expose encrypted plaintext, and a
cloud LLM behind MCP never receives it. Passphrase mode cannot recover a
forgotten passphrase — the data is unreadable without it.
OMEM is a Postgres application and stays local-first (there is no hosted service). To share memory across your own devices — or a small self-hosted team — point them all at one self-hosted ParadeDB instance (your machine or LAN):
# On every device / for every member:
export OMEM_DATABASE_URL="postgres://user:pass@db.example.com:5432/omem?sslmode=require"
omem find "..." # reads/writes the shared databaseOMEM_DATABASE_URL overrides database_url from the config, and the connection
string carries TLS (sslmode=require) and credentials — no code changes. One
shared database is a single source of truth, so there are no sync conflicts.
For a team, scope members with agents instead of giving everyone full access:
omem agent create alice --spaces project --read 'project.*' --write 'project.*'
omem agent create coach --spaces personal --read 'user.health.*' --write 'user.health.checkup.*'Keep sensitive facts in the encrypted space — the shared/cloud database then stores only ciphertext, and the key never leaves the member's device.
Offline-first sync (a local DB per device with replication) is a possible future addition; a hosted multi-tenant cloud service is explicitly out of scope — OMEM stays local.
omem/
├── install.sh / install.ps1 one-command installer (macOS-Linux / Windows)
├── migrations/ PostgreSQL schema (facts + sources + agents + BM25)
└── src/
├── config.rs ~/.omem/config.toml loader
├── crypto.rs AES-256-GCM + Argon2 (encrypted space)
├── keyvault.rs key provider (OS keystore / passphrase)
├── llm/mod.rs OpenAI-compatible HTTP client
├── store/mod.rs PostgreSQL operations (sqlx)
├── decomposer/mod.rs text → (label, fact) via LLM
├── merge/mod.rs find-similar → merge decision via LLM
├── search/mod.rs query preprocessing helpers
├── main.rs CLI binary (omem)
└── bin/
├── mcp.rs MCP server (omem-mcp)
└── web.rs local dashboard (omem-web)
facts:
id, label, value, source_id, space,
archived, stale, confidence,
created_at, updated_at, last_read_at,
label_tokens -- tsvector, 'simple' dict, GIN index, prefix match (label:*)
value_tokens -- tsvector, 'english' dict, GIN index, stemmed match (earn/earnings)
sources:
id, body, body_tokens (GIN index for deep search), created_at
agents:
id, allowed_spaces[], read_patterns[], write_patterns[]Any OpenAI-compatible API:
| Backend | Endpoint |
|---|---|
| Ollama (local) | http://localhost:11434/v1 |
| OpenAI | https://api.openai.com/v1 |
| Groq | https://api.groq.com/openai/v1 |
| LM Studio | http://localhost:1234/v1 |
| Anthropic (via proxy) | your proxy URL |
Recommended local model: gemma4:12b (best accuracy for Decomposer and Merger).
omem-web is a self-hosted local dashboard for browsing and managing memory facts.
It requires no external services and exposes no data outside 127.0.0.1.
cargo build --release --bin omem-web
./target/release/omem-web
# → omem-web listening on http://127.0.0.1:8787Override the port with OMEM_WEB_PORT:
OMEM_WEB_PORT=9000 ./target/release/omem-webOpen http://127.0.0.1:8787 in a browser.
- Stats cards — total facts, sources, archived count, stale count.
- Space breakdown — per-space fact counts as clickable chips (click to filter).
- Stale alert — banner shown when any stale facts exist; click to jump to the stale-only view.
- Recent counters — facts updated today / this week (computed client-side).
- Mark-stale — run the stale-marking sweep from the UI with a configurable day threshold.
| Filter | Description |
|---|---|
| Space | Filter by memory space (personal / project / public / encrypted / bench) |
| Pattern | Label prefix filter (e.g. user.health) |
| Show archived | Include archived facts in results |
| Stale only | Show only stale facts |
Pagination: 50 facts per page, Prev / Next navigation.
Per-row actions (with confirmation dialog):
| Action | Description |
|---|---|
| Archive | Mark fact as archived (hidden from LLM, restorable) |
| Restore | Unarchive a previously archived fact |
| Delete | Permanently delete a fact |
| Method | Path | Description |
|---|---|---|
| GET | /api/stats |
Memory statistics (facts, sources, stale, archived, by_space) |
| GET | /api/facts |
List facts — params: space, pattern, archived, stale, limit, offset |
| GET | /api/branches |
List registered branches for a space (?space=personal) |
| POST | /api/facts/archive |
Archive a fact {"label":"…","space":"…"} |
| POST | /api/facts/restore |
Restore an archived fact |
| POST | /api/facts/delete |
Delete a fact permanently |
| POST | /api/mark-stale |
Mark stale facts {"days": 90} |
- No vectors — dual-dictionary GIN search is deterministic and fast
- Dual-dictionary search —
label_tokensuses'simple'dict (exact prefix match for label vocabulary);value_tokensuses'english'dict (stemming: earn/earnings, train/training). Labels rank first; value matches are the fallback. - Min-should-match filter — for queries with 3+ tokens where any token is ≥ 8 chars (specific, rare term), at least two tokens must co-occur anywhere in the fact. Prevents single-token false positives from noise queries while leaving short natural-language paraphrases exempt.
- Grown taxonomy — decomposer enforces attribute sameness (merge only if both texts answer the same question), not fixed categories; branches register automatically with living descriptions
- Write-time intelligence — merging happens at write time, not read time
- Append-first merge — LLM decides merge or separate; on merge, provides only the new detail (
Also: …); Rust prepends existing value. Existing information is never dropped. On separate against an occupied address, value accretes instead of replacing. - One address = one fact — guaranteed by write-time merging and occupied-address safety
- Sources are append-only — full history preserved, facts are the fast index
- Agent permissions — two-level: space access + label-pattern allow-list
- Rust — single binary, works on macOS / Linux / Windows without runtime