Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OMEM — Ontological Memory

Universal persistent memory for LLM agents. Deterministic label-token search. No vectors. No probability. Facts survive between sessions.

How it's tested · Research & findings


What it does

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.


How it works

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:

  • Factslabel → 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.


Quick start

One command (recommended)

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

The installer is idempotent and does everything end to end:

  1. Checks prerequisites — Docker running, Rust toolchain (rustup).
  2. Provisions a ParadeDB (Postgres + pg_search) container, reusing an existing omem-postgres if present.
  3. Builds the binaries (omem, omem-web, omem-mcp).
  4. Asks which write-path LLM to use (Ollama or Anthropic) and writes ~/.omem/config.toml — model names live in your config, never in code.
  5. Runs migrations (omem init) — this enables the BM25 index when pg_search is present and falls back to GIN on vanilla Postgres.
  6. 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.


Manual setup

Prefer to wire it up yourself? The steps the installer automates:

1. PostgreSQL

Docker (recommended):

docker run -d --name omem-postgres \
  -e POSTGRES_DB=omem \
  -e POSTGRES_HOST_AUTH_METHOD=trust \
  -p 5432:5432 postgres:16-alpine

Homebrew:

brew install postgresql@16 && brew services start postgresql@16 && createdb omem

2. Local LLM (Ollama)

ollama pull gemma4:12b
ollama serve

Or use any OpenAI-compatible API (OpenAI, Groq, LM Studio).

3. Create config

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
EOF

4. Build and initialize

cargo build --release
./target/release/omem init

5. Write and find

omem 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 + facts

6. Connect OMEM as an MCP skill

OMEM 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 omem

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


CLI reference

Memory

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 stale

Fact aliases

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

Branch registry

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)

Agents

omem agent create ID [--spaces s1,s2] [--read p1,p2] [--write p1,p2]
omem agent list
omem agent show ID
omem agent delete ID

Example:

# 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.*"

Maintenance: stale facts

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 stale

Scheduled 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.plist

The 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>&1

Checking stale facts:

omem list --stale       # View all facts marked stale

MCP tools (for LLM agents)

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.

confirmed parameter

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""

agent_id parameter

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

Config reference

~/.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"]

Write modes

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.


Encrypted memory space

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.


Multi-device & teams

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 database

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


Architecture

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)

Storage schema

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[]

Supported LLM backends

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


Web UI

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.

Starting the server

cargo build --release --bin omem-web
./target/release/omem-web
# → omem-web listening on http://127.0.0.1:8787

Override the port with OMEM_WEB_PORT:

OMEM_WEB_PORT=9000 ./target/release/omem-web

Open http://127.0.0.1:8787 in a browser.

Dashboard

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

Facts table

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

JSON API

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}

Design decisions

  • No vectors — dual-dictionary GIN search is deterministic and fast
  • Dual-dictionary searchlabel_tokens uses 'simple' dict (exact prefix match for label vocabulary); value_tokens uses '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

About

Deterministic, vector-free persistent memory for LLM agents (Rust + Postgres/BM25, MCP)

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages