Skip to content

Repository files navigation

memorry

memorry banner

A fully local, API-key-free MCP memory server for Claude Code (or any MCP client). No Ollama, no embeddings, no external LLM calls — just SQLite, FTS5 full-text search, and optional live sync to an Obsidian vault.

Built collaboratively with Claude Code (Anthropic).

Why

Most "AI memory" setups either ship your notes to a hosted vector DB or require a local embedding model. memorry doesn't do either. It's a single Python process exposing a handful of MCP tools backed by one SQLite file. The calling agent is responsible for distilling what's worth remembering into one self-contained sentence — the server just stores it verbatim and makes it searchable.

Features

  • Local-first: one SQLite file (memory.db), streamable-http on 127.0.0.1:8765, no network calls of any kind.
  • FTS5 full-text search with Turkish/English stopword filtering, Turkish character folding (ı/İi, şs, ğg, üu, öo, çc), and suffix stripping so that yedek, yedekleme and yedekleri all find the same records — see Turkish suffixes for why that matters more than it sounds.
  • Duplicate warning on write: if memory_add receives something a record in the same project already covers, the response carries a ⚠ and the matching ids. It never blocks the write — it just makes "search before you add" something the server enforces rather than something the agent has to remember.
  • Projects: every record is tagged with a project, so one server can hold memory for several unrelated things.
  • Titles and tags: each record carries a short title and a few tags, supplied by the calling agent the same way the text is. They are what make the vault readable — see Notes as documents.
  • Pinned records: mark a handful of memories as always-relevant; fetch them without a search query.
  • Live Obsidian sync: every add/update/delete mirrors to one or more Obsidian vault folders as Markdown notes with frontmatter, and each new note auto-links to the most relevant existing notes in the same project via [[wikilink]] — so the vault's Graph View reflects real semantic connections, no manual linking required.
  • Automatic backups: a consistent snapshot of the DB (via SQLite's backup API, so WAL content is never missed) is taken on every server start, before any schema work — last 30 kept.
  • Health check: memory_health runs an actual write test against the FTS5 index, not just PRAGMA integrity_check — see a real bug we hit below.
  • Ranking built for memory, not documents: bm25 alone can't tell a superseded fact from the correction that replaced it — they share almost every word. Results are re-ranked on query coverage, recency, usage and pinned state. See Ranking.
  • Maintenance support: memory_review() surfaces likely duplicates, never-retrieved records and stale ones. It proposes; it never deletes.
  • Export/import: dump to JSON, re-import elsewhere with automatic dedup.

Tools

Tool Purpose
memory_add(text, project, pinned, title, tags) Store a fact verbatim
memory_search(query, project, limit) FTS5 search, bm25-ranked
memory_list(project, limit, pinned_only) List recent (or pinned) records
memory_update(id, text, project, pinned, title, tags) Edit in place, id/created_at preserved
memory_delete(id) Remove a record
memory_stats() Record counts, per-project breakdown, DB size, usage
memory_review(project, stale_days) Maintenance candidates: likely duplicates, never-retrieved, stale. Read-only
memory_health() Integrity check + a real FTS5 write test
memory_export(project) Dump to JSON
memory_import(path) Re-import a JSON dump, deduped

Use with Claude Code

Two optional pieces ship with the server, and they do different jobs:

SKILL.md — the discipline. Search before you add, distill to one self-contained sentence, separate projects, when to reach for memory_health. Claude loads it on its own whenever memory is relevant, so you don't have to ask for it.

mkdir -p ~/.claude/skills/memorry
cp SKILL.md ~/.claude/skills/memorry/

commands/hatirla.md — the shortcut. A slash command for when you want to bank something right now without breaking your train of thought: /hatirla the deploy key rotates every 90 days. ("hatırla" is Turkish for "remember" — rename the file to change the command name.)

mkdir -p ~/.claude/commands
cp commands/hatirla.md ~/.claude/commands/

Both land in ~/.claude/, so they work from any project. Drop them in a project's own .claude/ instead if you'd rather scope them to one repo. The command file has a "customize this" note at the bottom — worth two minutes to point it at your own project names.

Setup

pip install -r requirements.txt
python server.py

Or on Windows, run start-memory.bat to launch it hidden in the background (no autostart — you start it when you want it running).

Then point your MCP client at http://127.0.0.1:8765/mcp. For MCP hosts that only support command/args-style stdio servers (some desktop clients don't speak native type: http yet), bridge it with mcp-remote:

{
  "mcpServers": {
    "memory": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:8765/mcp", "--allow-http", "--transport", "http-only"]
    }
  }
}

Obsidian sync

Copy config.example.json to config.json and list one or more vault subfolders:

{ "obsidian_dirs": ["~/Documents/Obsidian Vault/memorry"] }

Or set MEMORRY_OBSIDIAN_DIRS (path-separator delimited), which takes precedence. With neither set, Obsidian sync is simply off. Sync is best-effort either way — an unreachable vault folder never blocks a memory write.

config.json is gitignored, so your local paths stay out of the repo.

Configuration

Env var Default Purpose
MEMORRY_DIR directory of server.py Where memory.db, backups/, exports/, memorry.log live
MEMORRY_PORT 8765 Listen port
MEMORRY_OBSIDIAN_DIRS from config.json Vault folders to mirror into

Windows: keeping it running

start-memory.bat launches the server as a child of whatever called it. That's fine from a terminal, but if the caller lives inside a Windows job object — notably a Claude Code SessionStart hook — the whole process tree is killed when the caller exits, and the server dies seconds after starting.

Register a Scheduled Task instead; the Task Scheduler service spawns the process, so it escapes any job object:

$dir = "$env:USERPROFILE\Desktop\memorry"
$pyw = "$env:LOCALAPPDATA\Programs\Python\Python312\pythonw.exe"
Register-ScheduledTask -TaskName "memorry-server" `
  -Action (New-ScheduledTaskAction -Execute $pyw -Argument "`"$dir\server.py`"" -WorkingDirectory $dir) `
  -Trigger (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME") `
  -Settings (New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -StartWhenAvailable) `
  -Principal (New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited)

Then schtasks /run /tn memorry-server starts it on demand, and it comes up automatically at logon. Remove it with Unregister-ScheduledTask -TaskName memorry-server.

When it won't start

Check memorry.log in the install directory. Because pythonw.exe has no console, startup failures (port already taken, missing dependency, any traceback) are otherwise completely invisible — the log is the only place they surface. The server also refuses to start if something is already listening on the port, rather than half-initializing the database underneath the running instance.

Making the agent actually use it

A memory server is only worth what gets recalled from it. Measured over six days on a live install: 48 sessions, 58 tool calls — 1.2 per session, with several days at 0. Used properly a session should search at the start, search again mid-task, and write once at the end.

The instructions were all in place — CLAUDE.md, the skill, the tool descriptions all said "search before you add". Repeating an instruction is not a mechanism. Three things were actually wrong:

  1. The MCP tools are often deferred, so they aren't in the agent's tool list at all. An agent not already thinking about memory never loads them.
  2. Nothing fires mid-session. A SessionStart hook injects context once, and after that recall depends entirely on the agent choosing to search.
  3. Writing depends on the agent remembering to write.

The fix is the same move that fixed duplicate records: stop asking the agent to remember, and make the mechanism do it.

hooks/prompt-hook.ps1 (UserPromptSubmit) searches memory with the user's own message on every turn and injects the hits as context. Recall stops being a decision — the records are simply there. It leans on the CLI search mode, which never imports FastMCP:

python server.py --search "your query" --limit 4

That path costs ~0.15 s versus ~2.4 s for a full import — the difference between something you can run on every message and something you can't.

hooks/stop-hook.ps1 (Stop) fires at most once per session, and only when zero records were written during it, comparing against a baseline the SessionStart hook leaves behind. It asks rather than forces: "if nothing durable came up, say so and stop."

Wire them up in .claude/settings.json:

{
  "hooks": {
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "shell": "powershell",
      "command": "& \"$env:USERPROFILE\\path\\to\\memorry\\hooks\\prompt-hook.ps1\"", "timeout": 8 }] }],
    "Stop": [{ "hooks": [{ "type": "command", "shell": "powershell",
      "command": "& \"$env:USERPROFILE\\path\\to\\memorry\\hooks\\stop-hook.ps1\"", "timeout": 10 }] }]
  }
}

Both scripts resolve their own location, skip trivial prompts (short acknowledgements, slash commands), and fail silent — a hook that breaks a turn is worse than one that does nothing.

Tests

python tests/test_server.py

Runs against a throwaway database in a temp directory (MEMORRY_DIR), so it never touches your real memory. Covers CRUD, Turkish character folding, dual-vault Obsidian sync, frontmatter escaping, backup consistency, and the single-instance guard.

Design notes

  • The agent distills, the server doesn't. memory_add never summarizes or calls out to an LLM — it stores exactly what it's given. Tool descriptions instruct the calling agent to compress information into one self-contained sentence before writing, and to search first so duplicate/stale facts don't pile up.
  • Turkish-aware, not Turkish-only. The stopword list, character folding and suffix stripping cover Turkish because that's what this was built for, but everything degrades gracefully for English text.

Notes as documents

A memory record is one sentence, so for a long time each exported note was just that sentence — and the filename was its first 70 characters. That filename is also the node label in Obsidian's Graph View, which meant the graph was a cloud of unreadable half-sentences with nothing to colour by:

00050 memorry sunucusu 2026-08-12'den beri 'memorry-server' adli Wind…
00006 Kullanıcının saç rengi turuncu

Records now carry a title and tags, so the same note exports as:

---
id: 50
project: "memorry"
title: "Server now starts via a Scheduled Task"
tags:
  - proje/memorry
  - infrastructure
  - windows
---

# Server now starts via a Scheduled Task

memorry's server has started through the 'memorry-server' Scheduled Task…

## İlgili
- [[00037 Why the server kept dying]]

Pinned records render as a > [!important] callout. Both fields feed the FTS index too, so a record is findable by its topic and not only by the exact words in its sentence.

The server does not invent them. title and tags are parameters, filled in by the calling agent — the same division of labour as the record text itself. Skipping title is allowed and the note falls back to the old naming, but memory_add says so in its response.

Tags are what make colour possible: Obsidian's graph colour groups bind to a search query (tag:#infrastructure), so without tags there is nothing to group by. A worked example of a graph configuration is in docs/graph-colors.md.

Turkish suffixes

Turkish is agglutinative, so yedek (backup), yedekleme (backing up) and yedekleri (its backups) are three unrelated tokens as far as FTS5 is concerned. Character folding doesn't help — that fixes letters, not morphology. Measured on a real 44-record memory before this was added:

sunucu       -> 3 hits        yedek       -> 1 hit
sunucusu     -> 3 hits        yedekleme   -> 1 hit
sunucusunun  -> 2 hits        yedekleri   -> 0 hits   <- same topic, nothing
sunucular    -> 0 hits

Zero results doesn't look like a malfunction, which is what makes it expensive: the record is right there, the agent can't find it, and it writes a second one that now contradicts the first.

_stem() strips common inflectional suffixes and undoes consonant softening (kitabıkitap, yedeğiyedek). No dictionary, no morphological analyser, no model — a rule list and a minimum stem length.

The design leans on symmetry: the same transformation runs over the index and over the query, so over-stemming can never lose a record (at worst it merges two words and returns an extra hit), while under-stemming produces exactly the silent miss above. When in doubt, stem harder.

Known limitation: words whose stem changes by vowel drop (kayıtkaydın) still don't unify. That needs a lexicon, which this deliberately doesn't have.

A subtlety worth stating, because it bit us: symmetry guarantees a record is findable by its own text. It does not guarantee that two inflections of the same word land on the same stem — greedy stripping can take one form further than another (lisansılisa while lisans stayed put). That's why stems are capped at a fixed length: any two forms sharing a prefix then converge by construction, rather than by trusting the stripper. The cap was chosen by measurement — 5 was the only value scoring full marks on both convergence (inflections of one word meeting) and precision (unrelated words staying apart).

Ranking

Higher recall moved the bottleneck. On a 44-record memory a typical query matches a third of the database while the default limit is 5, so ranking decides what you actually see — and bm25 alone is the wrong instrument here. A superseded fact and the record correcting it share nearly every word; bm25 cannot separate them, but the timestamp can.

Candidates come from bm25, then get re-scored on:

Signal Why
relevance (bm25) Baseline term match
coverage How much of the query the record contains. FTS5 ORs the terms, so one shared word is a hit; coverage is what restores precision on multi-word queries
recency In a memory, freshness is relevance — half-life 90 days
usage Frequently retrieved records are probably pulling their weight (weak signal, low weight)
pinned The user already marked it always-relevant

Usage is tracked as hit_count / last_used_at, incremented for records actually returned by a search. It's an honest proxy, not proof of usefulness — appearing in results isn't the same as being used, and that's the only signal available without feedback from the caller.

tests/test_ranking.py is the guardrail: cases like "the corrected record must outrank the one it replaced" and "full query coverage must beat a rare single term". bm25-only scores 2/5; the shipped weights score 5/5. It constrains the shape of the weights rather than pinning exact values — 48 of 144 sampled combinations also pass, and all of them need non-zero coverage. Run python tests/test_ranking.py --tune to sweep.

A real bug we hit

While building this, memories_fts was originally an FTS5 "external content" table (content='memories', content_rowid='id'). At some point its shadow tables got corrupted in a way PRAGMA integrity_check and even FTS5's own integrity-check command both reported as fine — but any DELETE/INSERT against the index raised database disk image is malformed. The underlying memories table was untouched.

The fix was to drop the external-content linkage entirely: memories_fts is now a fully independent FTS5 table, rebuilt from memories on every server start (cheap at personal-notes scale). memory_health() exists specifically to catch this class of failure early — it does a real (harmless, rowid=-999999) write against the index rather than trusting integrity_check alone.

License

MIT

About

A fully local, API-key-free MCP memory server -- SQLite + FTS5, no LLM/embeddings, optional live Obsidian sync.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages