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).
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.
- Local-first: one SQLite file (
memory.db), streamable-http on127.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 thatyedek,yedeklemeandyedekleriall find the same records — see Turkish suffixes for why that matters more than it sounds. - Duplicate warning on write: if
memory_addreceives 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
titleand a fewtags, 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_healthruns an actual write test against the FTS5 index, not justPRAGMA 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.
| 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 |
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.
pip install -r requirements.txt
python server.pyOr 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"]
}
}
}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.
| 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 |
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.
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.
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:
- 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.
- Nothing fires mid-session. A SessionStart hook injects context once, and after that recall depends entirely on the agent choosing to search.
- 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 4That 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.
python tests/test_server.pyRuns 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.
- The agent distills, the server doesn't.
memory_addnever 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.
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 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ği→yedek). 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ıt →
kaydı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).
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.
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.
MIT
