memorry
by macbyclp
README.md
# memorry

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](https://claude.com/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](#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](#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](#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](#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`](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.
```bash
mkdir -p ~/.claude/skills/memorry
cp SKILL.md ~/.claude/skills/memorry/
```
**[`commands/hatirla.md`](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.)
```bash
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
```bash
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`](https://www.npmjs.com/package/mcp-remote):
```json
{
"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:
```json
{ "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:
```powershell
$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:
```bash
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`:
```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
```bash
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:
```markdown
---
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`](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ğ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).
### 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
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues