# Anamnesis documentation (/docs)
Anamnesis is a local-first, file-based memory layer for [Claude Code](https://claude.com/claude-code) that syncs automatically across all of your own machines. Everything Claude learns about your projects (conventions, architecture decisions, fixes that worked, what you did yesterday) is captured as plain markdown, indexed locally for fast retrieval, and kept in sync across your fleet over your private network. There is no cloud account to create and no service to sign up for. Your memory stays on your machines: version-controlled, human-readable, and yours. The local-first core is open source under the Apache License 2.0 (repo: [github.com/oscardvs/anamnesis](https://github.com/oscardvs/anamnesis)).
Anamnesis is pre-alpha. The local-first core, hooks, and the one-command installer are built and tested, but setup and APIs may still change. See [Status](#status) below for what works today.
## The shape of it [#the-shape-of-it]
The word *anamnesis* (ἀνάμνησις) is Greek for *recollection*: the act of calling knowledge back to mind. The system has five parts, each doing one job:
* **Markdown is the source of truth.** Notes live as plain `.md` files under `~/.anamnesis/memory/`, so they are readable, `git diff`-able, and exactly the shape current models are good at using.
* **A SQLite FTS5 index gives fast keyword recall.** It is derived from the markdown and can always be rebuilt locally. It is never synced.
* **Sync is git over a [Tailscale](https://tailscale.com) mesh.** Only markdown travels; the database file never leaves the machine, so it never corrupts.
* **Claude Code talks to it through an MCP server** (built on [FastMCP](https://gofastmcp.com)) plus lifecycle hooks (SessionStart, SessionEnd, PreCompact).
* **A Next.js dashboard** is a git-like GUI to browse, search, edit, and inspect the history of your memory across every machine.
## What is in the docs [#what-is-in-the-docs]
Start here if you are new. What Anamnesis is, how to install it, how it works day to day, how to set up cross-machine sync, the dashboard, curating your notes, and an FAQ.
How it works under the hood: the architecture, data model, recall and ranking, capture and injection via hooks, the sync protocol, the swappable reflection model, the MCP server, the dashboard, and the design decisions behind them.
The exact surface: every CLI subcommand, the MCP tools and their signatures, configuration via environment variables, and the security model.
If you just want to get running, jump to:
* [Install](./guide/install) - set up the server and run `anamnesis init`.
* [How it works](./guide/how-it-works) - the day-to-day loop of capture, recall, and sync.
* [Across machines](./guide/across-machines) - put your fleet on one tailnet and share a repo.
* [The dashboard](./guide/dashboard) - browse and edit your memory in a GUI.
For the precise surface area:
* [CLI reference](./reference/cli) - `init`, `inject`, `capture`, `sync`, `reindex`, `status`, `serve`.
* [MCP tools](./reference/mcp-tools) - `memory_search`, `memory_list`, `memory_status`, `memory_write`, `memory_sync`.
* [Configuration](./reference/configuration) - the `ANAMNESIS_*` environment variables.
* [Security](./reference/security) - the trust boundary and what is auto-approvable.
For the reasoning, see the internals:
* [Architecture](./internals/architecture) - the five layers and how Claude Code drives them.
* [Data model](./internals/data-model) - note types and the on-disk layout.
* [Recall](./internals/recall) - FTS5, BM25, and ranking.
* [Sync](./internals/sync) - the `commit -> pull --rebase -> push` cycle and conflict handling.
* [Reflection](./internals/reflection) - the deterministic default and the swappable summarization model.
* [Design decisions](./internals/design-decisions) - why files and not a knowledge graph.
## Quickstart [#quickstart]
Anamnesis runs as a local MCP server over a store at `~/.anamnesis` (markdown notes plus a SQLite index that is rebuilt locally). Install it from PyPI:
```bash
uv tool install anamnesis-memory
```
Then wire this machine up in one command. `anamnesis init` registers the MCP server at user scope, installs the SessionStart / SessionEnd / PreCompact hooks, configures the store and your sync remote, and runs a first sync. It is idempotent (it backs up `settings.json` and never duplicates a hook):
```bash
anamnesis init # interactive: confirm store dir, machine id, remote
anamnesis init --print # dry-run: show exactly what it would do
```
Working on a single machine for now? Run `anamnesis init --local-only` and add a remote later by re-running `init`.
The repo ships a project-scoped `.mcp.json` that registers the server with Claude Code (just a `command` and `args`, no `env` block) and exposes five tools: `memory_search`, `memory_list`, `memory_status` (read-only and auto-approvable), `memory_write`, and `memory_sync`. Claude Code launches MCP servers with a filtered environment, so shell exports are not inherited. `anamnesis init` handles this for you by registering the server at user scope with `ANAMNESIS_MACHINE_ID` (and `ANAMNESIS_GIT_REMOTE` when a remote is set) baked in, and by writing the same values to `~/.anamnesis/config.json`, which the server also reads as a fallback. To configure it by hand, add an `"env"` block with `ANAMNESIS_HOME`, `ANAMNESIS_MACHINE_ID`, and `ANAMNESIS_GIT_REMOTE`. See [Configuration](./reference/configuration).
A full walkthrough lives in [Install](./guide/install) and [Across machines](./guide/across-machines).
The PyPI package name is `anamnesis-memory` (the plain name was taken), but the command it installs is just `anamnesis`. The server is also listed on the official MCP Registry as `io.github.oscardvs/anamnesis`. Contributors and air-gapped machines can install from source instead: see [Install](./guide/install).
## How a memory travels [#how-a-memory-travels]
A note written on one machine becomes searchable on the others within a sync cycle. The hooks make this hands-off:
* **SessionStart** injects the most relevant notes for the current project (your global preferences, plus the project's durable notes and a couple of recent session summaries) and kicks off a background sync.
* **SessionEnd** captures a durable episodic note from the session transcript (the ask, the files touched, the outcome) and syncs it.
* **PreCompact** captures the same kind of note before the context is compacted, so nothing is lost.
The session-end summary is deterministic by default and needs no API key. The summarization model is a swappable config value (`ANAMNESIS_REFLECTION_PROVIDER`) for when a reflection model is plugged in later. See [Capture and injection](./internals/capture-and-injection) and [Reflection](./internals/reflection).
## On-disk layout [#on-disk-layout]
Memory lives in `~/.anamnesis/` (never inside the repo):
```text
~/.anamnesis/
├── memory/ # markdown notes - the source of truth (a git repo, synced)
│ └── /.md
└── index.db # SQLite FTS5 index - rebuilt locally, never synced
```
Notes carry one of three types: `procedural`, `semantic`, or `episodic`. See [Data model](./internals/data-model).
Never sync `index.db` (the SQLite file) through a cloud folder like Dropbox or iCloud. That is the exact pattern that corrupts the database. Sync the markdown via git and let each machine rebuild its own index. This is a load-bearing design rule, not a preference.
## Status [#status]
The local-first core is live end to end. Install is one line from PyPI (`uv tool install anamnesis-memory && anamnesis init`); the file-first store, the FastMCP server, the auto-capture and auto-inject hooks, git sync, and the reflection pass with its recall-gated merge are built, tested, and validated on real hardware across a real multi-machine fleet. A note written on the desktop is searchable on the laptop within a sync cycle, and the demo on the landing page is a real two-machine recording of exactly that.
The dashboard ships too: `npx anamnesis-dashboard` serves the git-like GUI over your store (browse, full-text search, history with diffs, a per-machine fleet view, inline edit), and `npx anamnesis-dashboard --install-desktop` puts a clickable launcher in your applications menu. The server is listed on the official MCP Registry as `io.github.oscardvs/anamnesis`, and the token numbers on the landing page are measured, not promised.
## License and contributing [#license-and-contributing]
Anamnesis is open source under the [Apache License 2.0](https://github.com/oscardvs/anamnesis/blob/main/LICENSE). Issues and discussion are welcome on the [GitHub repository](https://github.com/oscardvs/anamnesis).
# Use it on all your computers (/docs/guide/across-machines)
This is the whole point of Anamnesis: what Claude Code learns on your desktop is already there
when you open your laptop. This page walks you through wiring up two (or more) machines so your
memory follows you, no cloud account required.
## What you get [#what-you-get]
You write code on your desktop. Claude Code captures what it learned (your conventions, a fix that
worked, what you did today) as plain markdown notes. You close the lid, take the train to Amsterdam,
open your laptop, start a new Claude Code session, and that memory is already loaded. You make more
notes there. Back home, your desktop picks them up.
Three plain ideas make this work:
* **Your notes are a folder of markdown files** kept in a git repo at `~/.anamnesis/memory/`.
Git is the same version-control tool developers use for code; here it just moves your notes
between machines and keeps a full history.
* **They sync over Tailscale**, a free app that connects your own devices to each other on a
private network (a "tailnet") so they can reach each other directly, wherever they are. Nothing
goes through a third party's servers.
* **Only the markdown travels.** The search index (a SQLite database) is rebuilt fresh on each
machine and never synced, so it can never get corrupted in transit.
You only sync between machines **you own and are signed into**. There is no shared-with-other-people
mode. This is your memory, on your fleet.
## The shape of the setup [#the-shape-of-the-setup]
You pick one machine that is usually on (a desktop, a home server, a NAS) to hold the shared copy
of your notes. Every machine pushes its new notes there and pulls down everyone else's. That shared
copy is an empty git repo called a "bare repo" (bare just means it has no working files of its own,
it only stores history for others to sync against).
The always-on host can also be one of your everyday machines (your desktop can host the shared repo
and use it at the same time). You just need it reachable when your other machines want to sync.
## The Amsterdam walkthrough [#the-amsterdam-walkthrough]
You have a desktop at home and a laptop you take with you. Here is the full setup, start to finish.
### 1. Put every machine on the same tailnet [#1-put-every-machine-on-the-same-tailnet]
Install [Tailscale](https://tailscale.com/download) on each machine, then sign in to the **same
account** on every one of them:
```bash
tailscale up
```
Once they are all signed in, run `tailscale status` to see your machines and their names. Tailscale
gives each machine a stable name on your private network (its MagicDNS name), something like
`host.your-tailnet.ts.net`. Note the name of the machine you picked to host the shared repo; you
will use it in step 3.
```bash
tailscale status
```
### 2. Create the shared repo once, on the always-on host [#2-create-the-shared-repo-once-on-the-always-on-host]
Do this **one time only**, on the host machine (in the Amsterdam story, your home desktop):
```bash
git init --bare -b main ~/anamnesis-memory.git
```
That creates the empty shared repo your other machines will sync against. The `-b main` just names
its default branch `main`, which is what Anamnesis uses.
Then confirm your other machines can actually reach it over SSH. From your laptop, this should log
you in to the host:
```bash
ssh you@host.your-tailnet.ts.net
```
If it does not log you in, add your laptop's public SSH key to the host's
`~/.ssh/authorized_keys` and try again. (SSH is the standard secure way one machine logs in to
another; git uses it to move your notes.)
Replace `you` with your username on the host, and `host.your-tailnet.ts.net` with the MagicDNS name
from `tailscale status`. These are examples, not literal values.
### 3. Point each machine at the shared repo [#3-point-each-machine-at-the-shared-repo]
On **every** machine (desktop and laptop both), run the installer with `--remote` set to the
SSH address of the shared repo:
```bash
anamnesis init --remote 'you@host.your-tailnet.ts.net:anamnesis-memory.git'
```
`anamnesis init` is the one command that wires a machine up: it registers Anamnesis with Claude
Code, installs the session hooks that capture and load memory automatically, records your sync
remote, and runs a first sync. (For what `init` does in detail and the install steps that come
before it, see [Install and connect to Claude Code](./install).)
This page assumes the one-line install (`uv tool install anamnesis-memory`), which puts the
`anamnesis` command on your PATH. Working from a source checkout instead? Run the same commands
with a `uv run` prefix from the `server/` folder (`uv run anamnesis init ...`).
The host machine, if you also use it day to day, can point at its own copy with a plain local path
instead of an SSH address:
```bash
anamnesis init --remote "$HOME/anamnesis-memory.git"
```
**Run `init` on every machine, every time.** The hooks and Claude Code settings live in a
per-machine file (`~/.claude/settings.json`) and are **not** synced. Only your markdown notes sync.
So each machine needs its own `init` run to install its own hooks and point at its own checkout.
### 4. The first sync seeds everything [#4-the-first-sync-seeds-everything]
The first machine to sync pushes its notes up to the shared repo. The next machine to sync pulls
them all down. After that, every new session keeps things current automatically: notes written on
the desktop become searchable on the laptop within a sync cycle, and the other way around.
When `init` finishes its first sync it prints a line like this so you can see it worked:
```text
sync: pushed=True pulled=0 (synced)
init: done. Start a new Claude Code session for the MCP server and hooks to take effect.
```
Start a fresh Claude Code session (the hooks and the Anamnesis tools only take effect in a new
session) and you are done. From here it is hands-off: each session loads relevant memory at the
start and saves a note at the end, syncing as it goes.
## Starting solo, adding a remote later [#starting-solo-adding-a-remote-later]
You do not have to set up sync on day one. If you only have one machine for now, install with
`--local-only`:
```bash
anamnesis init --local-only
```
That gives you the full local experience (capture, search, the dashboard) with no remote. Your notes
are still a git repo on disk, just with nowhere to push yet. Each sync commits your changes locally
and reports `no remote configured` instead of pushing.
When you get a second machine, do the Amsterdam walkthrough above: create the bare repo on a host,
then **re-run `init` with `--remote`** on the machine that was solo:
```bash
anamnesis init --remote 'you@host.your-tailnet.ts.net:anamnesis-memory.git'
```
Re-running `init` is safe. It is idempotent: it backs up your `settings.json`, never duplicates a
hook, and simply updates your remote. Nothing you have already captured is lost; your existing notes
get pushed up on the next sync. As the README puts it, "add a remote later by re-running `init`;
nothing else changes."
## What syncing actually does [#what-syncing-actually-does]
Each sync runs the same three steps, in order: commit your local note changes, pull down everyone
else's (rebasing your changes on top), then push yours up. After pulling, it rebuilds the local
search index so the new notes are immediately searchable. This happens automatically in the
background when a session starts, and you can also trigger it yourself any time.
You never sync the database file. Only markdown moves between machines; the SQLite index is rebuilt
locally on each one. That is why a synced index can never corrupt your store.
### When two machines edit the same note [#when-two-machines-edit-the-same-note]
If the desktop and the laptop both change the **same note** before syncing, git cannot merge them
cleanly. Anamnesis does not guess and it does not silently throw one away. It keeps your local edits
in place, does not push, and tells you so:
```text
sync: pushed=False pulled=0 (conflict on rebase; kept local edits, did not push - resolve and re-sync)
```
You then resolve the conflict in that note (the usual git conflict markers) and sync again. Your
work is never dropped behind your back.
Conflicts only happen when the **same note** is edited in two places before syncing. Notes about
different projects, or new notes, never conflict. In normal day-to-day use across your own machines
this is rare.
## How each machine remembers its remote [#how-each-machine-remembers-its-remote]
The shared-repo address is different on each machine (your laptop reaches the host over SSH; the
host may use a local path). So the remote is stored **per machine**, not in your synced notes.
`anamnesis init` writes it to `~/.anamnesis/config.json`, which lives outside the synced notes
folder and never travels. Both Claude Code's background sync and the dashboard read it from there,
which is how they can push without you re-typing the address.
If you ever need to change the remote, just re-run `init --remote` with the new address.
## Quick reference [#quick-reference]
```bash
# once, on the always-on host
git init --bare -b main ~/anamnesis-memory.git
# on every machine, every time
tailscale up
anamnesis init --remote 'you@host.your-tailnet.ts.net:anamnesis-memory.git'
# starting solo, no remote yet
anamnesis init --local-only
# see what init would do, without changing anything
anamnesis init --print
```
## Where to go next [#where-to-go-next]
* [Install and connect to Claude Code](./install) - the install steps and what `anamnesis init` sets up.
* [How it works](./how-it-works) - the moving parts in plain terms.
* [Sync internals](../internals/sync) - the git-over-Tailscale design in detail.
# Keep your memory tidy (/docs/guide/curating)
Anamnesis remembers things for you automatically, but it is still your memory. Over time you will want to fix
a note that came out wrong, delete one you no longer need, decide which notes follow you to your other
machines, and let Anamnesis fold a pile of small session notes into a few durable ones. This page covers all
of that in plain terms, with the exact commands and the one safety habit worth learning.
## The two kinds of notes: portable and machine-local [#the-two-kinds-of-notes-portable-and-machine-local]
Every note has a **scope**, which is just a setting that decides whether the note travels to your other
machines.
* **Portable** notes are the default. They are synced: they get committed to git and show up on every machine
you have connected. Use this for anything you would want to remember no matter where you are working:
decisions, preferences, how-tos, and the distilled notes Anamnesis writes for you.
* **Machine-local** notes stay on the one machine where they were created. They are never pushed and never
appear anywhere else. Use this for things that only make sense on this computer: a local file path, a quirk
of this laptop's setup, a scratch note you do not want cluttering your other machines.
Under the hood this is simply where the file lives. Portable notes live in a folder that git syncs;
machine-local notes live in a separate folder that is never synced. You do not have to manage the files
yourself, but it explains the behavior: marking a note machine-local is how you keep it on one machine, and
marking it portable is how you share it everywhere.
If you are not sure, leave a note **portable**. Portable is the default, and a note you can see everywhere
is more useful than one stranded on a single machine. Reach for machine-local only when you have a clear
reason to keep something off your other computers.
You pick the scope in the dashboard's note editor (the **Scope** control, which toggles between `portable`
and `machine-local`). New notes default to `portable`.
## Editing and deleting notes in the dashboard [#editing-and-deleting-notes-in-the-dashboard]
The dashboard is the friendly way to tidy up. Open a note and you can rewrite its title and body, change its
type, retag it, move it to a different project, and flip its scope. There is a Write/Preview toggle so you can
see how the Markdown will read before you save.
What happens when you save or delete a **portable** note:
* **Editing** rewrites the note's Markdown file, records the change in git history (so you can always look
back at older versions), and rebuilds the search index so the edit is searchable right away.
* **Deleting** removes the note's Markdown file, records the removal in git history, and rebuilds the search
index.
* If your edit changes a note's **type** or **scope**, the file moves to its new folder. The dashboard
removes the old file and records that too, so history stays clean.
For **machine-local** notes, the dashboard makes the change on this machine only and does **not** record it in
git, because machine-local notes are never part of the synced history. It still rebuilds the search index so
the change shows up immediately.
In short: editing and deleting are safe and reversible through the dashboard. Because every portable change is
committed to git, you have a full history to fall back on, and you can browse it from the dashboard's history
view.
Saving an edit or deleting a portable note commits the change **locally** but does not push it to your other
machines on its own. The change travels on the next sync. If you want it everywhere immediately, run a sync
(see [Across machines](./across-machines)).
## Reflection: turning many small notes into a few good ones [#reflection-turning-many-small-notes-into-a-few-good-ones]
Every time a Claude Code session ends, Anamnesis can write a short note about what happened (these are called
**episodic** notes, meaning "one note per session"). After a while you accumulate a lot of them, and the same
facts and preferences show up again and again across sessions.
**Reflection** is the cleanup pass for that. It reads a project's session notes, finds the things that keep
recurring, and writes a small number of durable notes: lasting facts, decisions, and preferences (called
**semantic** notes) and repeatable how-tos (called **procedural** notes). It merges repeated points into one
entry and drops the one-off chatter. The session notes it used are then marked as already reflected, so the
next reflection does not chew on them again.
The distilled notes are written as **portable** notes (so they sync everywhere), tagged `reflection` so you
can see where they came from, and given a lower confidence on purpose so they are easy to spot and review.
Reflection only adds notes; it never edits or deletes your existing ones. You stay in charge: review the new
notes in the dashboard afterward and keep, fix, or delete them.
### Reflection needs an LLM provider [#reflection-needs-an-llm-provider]
Reflection asks a language model to do the distilling, so it only runs if you have told Anamnesis which model
to use. "LLM" just means a large language model: the same kind of AI that powers Claude. You configure this
with environment variables on the machine where you will run reflection:
* `ANAMNESIS_REFLECTION_PROVIDER` - a label for your provider (for example `deepseek` or `openai`). It is only
used to tag the notes; if you leave it unset it defaults to `heuristic`.
* `ANAMNESIS_REFLECTION_MODEL` - the model name to call.
* `ANAMNESIS_REFLECTION_BASE_URL` - the base URL of an OpenAI-compatible chat completions endpoint. Anamnesis
posts to this URL with `/chat/completions` appended.
* `ANAMNESIS_REFLECTION_API_KEY` - your API key. For convenience, `DEEPSEEK_API_KEY` or `OPENAI_API_KEY` are
also accepted if this dedicated key is not set.
The **model**, **base URL**, and **key** are the three that matter: if any of them is missing, reflection has
no provider to call. When you try to apply it without all three, Anamnesis tells you exactly that and writes
nothing:
```text
reflect: no reflection provider configured (set ANAMNESIS_REFLECTION_PROVIDER + model/base-url/key)
```
Reflection only looks at a project once it has enough un-reflected session notes to be worth the effort. The
default threshold is **5** notes per project. You can change it with the `ANAMNESIS_REFLECT_MIN_EPISODICS`
environment variable. Projects under the threshold are simply skipped.
### Try it first as a dry run [#try-it-first-as-a-dry-run]
Reflection is a dry run by default: without `--apply` it tells you what it *would* do and writes nothing. This
is the safe way to see whether you have enough to distill, and it does not need a provider configured:
```bash
anamnesis reflect
```
You will see one line per project that has enough notes, for example:
```text
reflect: my-project: 7 episodic(s) would be distilled (dry-run; pass --apply)
```
You can scope the dry run (or any reflect run) to a single project:
```bash
anamnesis reflect --project my-project
```
## The safe way to reflect [#the-safe-way-to-reflect]
This is the one habit worth getting right. It is simple, and following it avoids the only way reflection can
disappoint you.
**Run reflection from your own terminal, and use plain `anamnesis reflect --apply`.**
```bash
anamnesis reflect --apply
```
When it finishes you will see something like:
```text
reflect: my-project: distilled 7 episodic(s) -> 3 note(s)
reflect: wrote 3 note(s); synced (pushed=True pulled=0)
```
Two reasons this is the safe path:
1. **Run it from your own terminal.** The provider settings above (your API key in particular) are set in your
shell environment. Reflection can only reach the model from a place where those settings are loaded, which
is your own terminal. That is also the natural place to read the output.
2. **Use `--apply`, not `--apply --no-sync`.** Plain `anamnesis reflect --apply` writes the new notes **and
then syncs**, and the sync commits them to git in the same step. That commit is what makes the new notes
permanent and safe. (If you have no remote configured, the sync still commits the notes locally, which is
what protects them.)
Avoid the `--no-sync` flag when you reflect. With `--no-sync`, reflection writes the new notes and rebuilds
the search index, but it **does not commit them to git**. They sit on disk as uncommitted changes. If a
routine sync runs before you commit them yourself (for example, the sync that happens automatically when
your next Claude Code session ends), that sync rebases against your other machines first, and an uncommitted
note can be discarded in the process. The freshly distilled notes are then gone. This is a real way to lose
work, and it is entirely avoided by letting `anamnesis reflect --apply` commit for you.
If you have a reason to use `--no-sync` (for example you want to inspect the result before it goes anywhere),
commit it yourself right away so a later sync cannot wipe it. From your memory folder (the synced `memory/`
directory under your Anamnesis home, which defaults to `~/.anamnesis/memory`):
```bash
git add -A && git commit -m "reflect: distilled notes"
```
When you are ready to share the result with your other machines, run a normal sync:
```bash
anamnesis sync
```
### A note about the dashboard's Reflect button [#a-note-about-the-dashboards-reflect-button]
The dashboard also has a Reflect action, and a review screen that lists the distilled notes so you can **Keep**
or **Delete** each one. This is a comfortable way to review reflections in the GUI.
There is one thing to know. Behind the scenes the dashboard's Reflect button runs the distilling step with
`--no-sync`, so the freshly distilled notes are written to disk but are **not committed on their own**. They
become permanent in one of two ordinary ways:
* When you **Keep** or **Delete** a reflected note in the review screen, the dashboard commits that change to
git for you (Keep commits the note with a `reviewed` tag; Delete commits its removal).
* The next sync also commits whatever is on disk.
So the dashboard is safe as long as you actually go review the results. The risk is the same one as above: if
you run Reflect in the dashboard, leave the new notes unreviewed, and a sync runs in the background first, the
uncommitted notes can be lost. The simplest mental model stays the one in the previous section: when you want
reflected notes that are committed and on their way to your other machines, run `anamnesis reflect --apply`
from your terminal, or review them promptly in the dashboard.
## A quick tidy-up routine [#a-quick-tidy-up-routine]
A calm, low-effort habit:
1. Edit or delete any notes that are wrong or no longer useful, in the dashboard.
2. Decide scope as you go: leave it portable unless a note truly belongs to one machine.
3. When a project has built up session notes, run `anamnesis reflect` to preview, then
`anamnesis reflect --apply` to distill and commit them.
4. Review the new distilled notes in the dashboard. Keep the good ones, fix or delete the rest.
5. Run `anamnesis sync` to make sure everything is on your other machines.
## Related [#related]
* [Browse your memory](./dashboard) - searching, the memory map, history, and the note editor.
* [Across machines](./across-machines) - how syncing works and how to run a sync.
* [What happens when you use it](./how-it-works) - session capture and auto-injection.
# Browse your memory (/docs/guide/dashboard)
The dashboard is a visual window into everything Anamnesis remembers. Claude Code writes
plain markdown notes as it works, and the dashboard lets you read, search, edit, and
explore them in your browser. Nothing here owns your data: the markdown files on your
machine are the real memory, and the dashboard is just a friendly view over them.
This page walks through launching it and everything you can do once it is open.
## Launch it [#launch-it]
One command, no repo clone needed:
```bash
npx anamnesis-dashboard
```
or, if you installed the core already:
```bash
anamnesis dashboard
```
Either serves the dashboard and opens your browser at:
```text
http://localhost:3000
```
The first run downloads the package (a minute at most); after that it starts in seconds.
Working from a clone of the repo instead? `cd dashboard && npm install && npm run dev`
gives you the same thing with live reload.
## Put it in your applications menu [#put-it-in-your-applications-menu]
One more command gives you a clickable launcher (Linux and macOS):
```bash
npx anamnesis-dashboard --install-desktop
```
Click **Anamnesis** in your app menu: it starts the dashboard and opens your
browser, and if one is already running it brings that up instead. Remove the
entry again with `npx anamnesis-dashboard --uninstall-desktop`.
You need Node 20 or newer. Browsing works with just the store on disk; edits and sync also
need `git` and the `anamnesis` command line (`uv tool install anamnesis-memory`). The
dashboard reads the same store your other tools use (markdown plus a derived search index),
so it does not need its own copy of anything.
### Where it reads from [#where-it-reads-from]
By default the dashboard reads the store at `~/.anamnesis`. If your memory lives elsewhere,
point it there when you launch:
```bash
npx anamnesis-dashboard --store /path/to/your/store
```
(or set `ANAMNESIS_HOME` in the environment; the flag wins when both are given).
A few other settings exist for advanced setups (for example which command line binary to
call for writes, or which git remote to sync against). You rarely need them; the defaults
work for a standard install. The full list of environment variables lives in the dashboard
README.
### Run it as a real app (optional) [#run-it-as-a-real-app-optional]
For day-to-day use you can also build it once and serve it, instead of running the dev
server:
```bash
npm run build
npm run start
```
This still opens at `http://localhost:3000`. There is also a packaged desktop app you can
build, and a phone-and-laptop option over your private network, both covered at the end of
this page.
## Get your bearings [#get-your-bearings]
The dashboard has one layout: a sidebar on the left and a top bar across the top.
The **sidebar** holds a **New note** button and links to the main views:
* **Overview** (the home screen) - the 3D memory map and your most recent notes.
* **Browse** - the searchable list of every note.
* **Review** - a reflection checkpoint for notes Claude Code has proposed.
* **History** - the timeline of every change to your memory.
* **Machines** - every computer that has synced into your memory.
Below those links the sidebar lists your **projects** with a count next to each. Click a
project to jump straight to Browse filtered to just that project.
The **top bar** holds a **Search memory...** button, a small sync status pill, a sync
button, and a light/dark theme toggle. On a narrow screen the sidebar collapses into a
menu you open from the top bar.
## Find what you need: search and the command palette [#find-what-you-need-search-and-the-command-palette]
Two ways to find a note.
**Full-text search.** Open the **Browse** view from the sidebar. It lists every note and
searches the full text (titles, bodies, and tags), ranking the best matches first. You can
also click a project in the sidebar to filter Browse down to that project.
**The command palette.** Press `Cmd-K` (or `Ctrl-K` on Windows and Linux) from anywhere. A
search box pops up near the top of the screen. Start typing and it searches your memory as
you go, showing up to twelve matches. When the box is empty it instead lists quick jumps
under a "Go to" heading, so you can hop to Overview, Browse memory, History, Machines, or
start a new note without touching the mouse.
```text
Search memory or jump to a view...
```
Press `Esc` to close it. The **Search memory...** button in the top bar (it shows a small
`⌘K` hint) opens the same palette if you would rather click than press a key.
## Explore the memory map [#explore-the-memory-map]
The **Overview** page (the home screen, the `/` route) shows a 3D **memory map**: a gentle,
rotating cloud of your notes that you can drag to spin and scroll to zoom.
* Each small point is a note. Its color tells you its type (semantic, procedural, or
episodic).
* The larger glowing points are **hubs**, one per project, that the notes cluster around.
Each hub is labelled with its project name and is sized by how many notes it holds. Notes
that share tags are linked with faint lines.
* It drifts slowly on its own. You do not have to do anything to watch it move; it pauses
the idle spin while you are dragging, hovering, or inspecting a note.
**Filter by type.** In the top-left corner are three chips, one each for **semantic**,
**procedural**, and **episodic**, with a count on each. Click a chip to dim and hide that
type, and click again to bring it back.
**Click a point to open a detail card.** Clicking a note brings up a card on the right with
its type, its title, its project, a short excerpt, and up to four of its tags, plus an
**Open note** button to read the full thing. Clicking a hub shows the project, how many
notes are anchored to it, and a **Browse region** button that filters Browse to that
project. Close the card with the X in its corner.
**Move around.** Three controls sit in the bottom-left corner: **zoom out**, **Reset**, and
**zoom in**. Reset returns the view to its default angle and zoom.
The map is a way to wander and rediscover, not a precise diagram. If you want to find one
specific thing, search is faster. If you want to get a feel for what has accumulated and
stumble onto forgotten notes, the map is the place.
The 3D map needs WebGL. If your browser or machine cannot run WebGL, the map area shows a
short message instead, and your memory is still fully browsable from the lists and search.
## Read a note [#read-a-note]
Click any note (from Browse, the map, search results, or the recent list on Overview) to
open it. You see the note rendered as clean markdown, along with all of its metadata: its
title, type, project, tags, and timestamps. From a note you can jump to **Edit** it or to
its **History**, using the buttons at the top of the note.
## See the history and diffs [#see-the-history-and-diffs]
Anamnesis keeps every version of every note, because the memory folder is a git repository
(git is a tool that records the full history of changes to files). The dashboard turns that
history into something readable.
* The **History** view (in the sidebar) shows a single timeline of every change across all
of your memory, newest first, as a commit graph.
* Each note also has its own **History** page, reachable from the note, showing just that
note's versions.
* Click any point in the history to see the **diff**: the exact lines that were added and
removed in that change, in red and green.
This means you can always answer "what did this note used to say, and when did it change?"
without leaving the browser.
## See your machines [#see-your-machines]
If you use Anamnesis on more than one computer, memory syncs between them. The **Machines**
view lists every machine that has contributed to your memory, with the last time each one
synced and a small status badge. It is worked out from who authored each change, so you do
not configure anything: a machine shows up here once it has synced in. This is also where
sync conflicts surface if two machines edited the same note and the most recent write won.
## Create and edit notes [#create-and-edit-notes]
You can write memory by hand, not just let Claude Code do it.
**New note.** Click **New note** at the top of the sidebar (or pick it from the command
palette). Fill in:
* a **title** (a short, recall-friendly line; this is the only required field),
* a **type**: `procedural`, `semantic`, or `episodic` (the default is `semantic`),
* a **project** (the default is `global`; the field suggests projects you already use),
* a **scope**: `portable` or `machine-local` (the default is `portable`),
* **tags** (comma separated),
* and the **body** in markdown.
The body has **Write** and **Preview** tabs, so you can flip to a rendered preview of your
markdown before saving. When you finish, click **Create note**.
**Edit a note.** Open any note and click **Edit**. It is the same form, pre-filled, and the
button reads **Save changes**.
When you save, the dashboard writes the markdown file in the exact store format, records it
as a git commit on your machine, and refreshes the search index so the note shows up
immediately. A small toast confirms the save and shows the commit it created.
Saving commits your change locally but does not reach your other machines on its own. That
is deliberate: saving never does surprise network activity. To share a note across
machines, use the Sync button below.
## Sync across machines [#sync-across-machines]
Syncing is one explicit click. In the top bar there is a small status pill (showing whether
your memory is up to date) and a circular **Sync now** button next to it. Hover the button
and its tooltip reads "Sync now (pull + push)".
Click it and the dashboard pulls in changes from your other machines and pushes your local
changes out, in one step.
* If it works, a toast tells you what happened, for example "pushed local edits" or
"pulled 3".
* If two machines changed the same note, the most recent change wins, and the leftover
conflict is surfaced for you to resolve in the memory repo and sync again.
* If it cannot reach the command line tool, it tells you the sync failed ("Could not reach
the anamnesis CLI.").
The status pill refreshes itself roughly every twenty seconds, so it reflects reality
without you clicking.
Sync only does anything if a git remote is configured for your store. Without one, saving
still works and still keeps full local history; there is just nowhere to push to yet.
Setting up cross-machine sync is covered in the sync guide.
## Use it on your phone (interim self-host) [#use-it-on-your-phone-interim-self-host]
You can run one always-on copy of the dashboard on a machine you keep on (a "hub") and reach
it from your phone and laptop as an installable app over your private Tailscale network (a
mesh that privately connects your own devices). At a high level:
1. Build the server on the hub: `npm install && npm run build`.
2. Run it as a background service and publish it on your tailnet with
`tailscale serve --bg 3000` (this needs HTTPS certificates enabled in your Tailscale
admin console).
3. On iPhone, open the tailnet URL in Safari and use Share, then Add to Home Screen. On a
laptop, open it in Chrome or Edge and use Install app from the address bar.
The server binds to `127.0.0.1` only, so it is never on your local network or the public
internet; the only off-machine access is through your authenticated tailnet. Do not use
`tailscale funnel`, which would expose it publicly. The full step-by-step, including the
systemd service and how to update it, is in `dashboard/deploy/README.md`.
This phone-and-laptop setup is an interim, self-hosted convenience, not a polished product.
Treat it as a way to glance at your memory on the go, and expect to tend the service
yourself (updating it with `git pull && npm run build` and restarting it).
## A desktop app (optional) [#a-desktop-app-optional]
If you prefer a standalone app over a browser tab, you can build one:
```bash
npm run desktop:build
```
On Linux this produces an `.AppImage` and a `.deb` under `dist/`. It runs its own local
server against this machine's store and works offline for reading. macOS and Windows builds
are produced by a GitHub Actions workflow and ship unsigned and unverified for now, until
that hardware is available to verify them.
## Where to go next [#where-to-go-next]
* [Install and connect to Claude Code](./install) - getting Anamnesis set up in the first place.
* [Sync internals](../internals/sync) - how cross-machine sync works under the hood.
# FAQ and troubleshooting (/docs/guide/faq)
This page collects the questions people hit most in their first hour with Anamnesis, plus the
handful of gotchas that have a clear fix. Each answer tells you what to type and what you should
see. If something here does not match what you are seeing, the [How it works](./how-it-works) and
[Install](./install) pages have the longer story.
A few words you will see below, defined once:
* **MCP server**: the small program Claude Code talks to so it can read and write your memory. You
do not run it by hand; Claude Code starts it for you.
* **Hooks**: little actions Claude Code runs automatically at the start and end of a session (to
pull in your relevant memory, and to save a note about what you just did).
* **The store**: the folder your memory lives in, by default `~/.anamnesis`. It holds your notes as
markdown files plus a search index.
## Setup and first run [#setup-and-first-run]
### Nothing happens after I install. Does Claude Code see my memory? [#nothing-happens-after-i-install-does-claude-code-see-my-memory]
Two things have to be true before Claude Code can use Anamnesis:
1. You ran the one-command setup, `anamnesis init`, which connects everything up.
2. You started a **new** Claude Code session afterwards (see the next question).
The fastest way to confirm the setup itself worked is to ask for status from the command line:
```bash
uv run anamnesis status
```
You should see something like:
```
store: /home/you/.anamnesis
notes: 12 by_type={'episodic': 7, 'semantic': 3, 'procedural': 2} by_scope={'portable': 12}
sync: initialized=True remote=you@host.your-tailnet.ts.net:anamnesis-memory.git head=a1b2c3d dirty=False (ok)
```
If `notes` is `0` and you are brand new, that is fine: memory fills in as you work. If `sync` says
`initialized=False`, re-run `anamnesis init` (it is safe to run again).
### Do I have to restart Claude Code after running `anamnesis init`? [#do-i-have-to-restart-claude-code-after-running-anamnesis-init]
Yes. Claude Code reads its MCP servers and hooks when a session starts, so any session that was
already open will not pick up Anamnesis. Quit Claude Code and start it again (open a new session).
This is the single most common "it is not working" cause. If you just ran `anamnesis init`, close
your current Claude Code session and open a fresh one before testing.
After restarting, you can confirm the memory tools are loaded by asking Claude something like "what
is in my memory about this project?" or by checking that the `memory_search`, `memory_list`, and
`memory_status` tools appear in Claude Code's tool list.
### Why do my `export ANAMNESIS_...` shell variables get ignored? [#why-do-my-export-anamnesis_-shell-variables-get-ignored]
Because Claude Code does **not** start the MCP server with your shell's environment. It launches it
with a filtered environment, so anything you `export` in your terminal (or put in `.bashrc` /
`.zshrc`) is invisible to the server. This trips up almost everyone once.
There are two correct places to set Anamnesis configuration:
1. **Let `anamnesis init` do it.** The recommended path. `init` writes your settings to a small
per-machine file at `~/.anamnesis/config.json`, and both the MCP server and the dashboard read
it. You do not have to touch any environment variables.
2. **Set them in the MCP config's `"env"` block.** If you are wiring things up by hand, put the
variables inside the `"env"` block of the server entry in your `.mcp.json`, not in your shell:
```json
{
"mcpServers": {
"anamnesis": {
"command": "uv",
"args": ["run", "--project", "server", "anamnesis"],
"env": {
"ANAMNESIS_HOME": "/home/you/.anamnesis",
"ANAMNESIS_MACHINE_ID": "desktop-amsterdam",
"ANAMNESIS_GIT_REMOTE": "you@host.your-tailnet.ts.net:anamnesis-memory.git"
}
}
}
}
```
The only three variables that matter for normal use are:
| Variable | Default | What it does |
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------- |
| `ANAMNESIS_HOME` | `~/.anamnesis` | Where your notes and search index live. |
| `ANAMNESIS_MACHINE_ID` | this computer's hostname | The name stamped on notes you write, so you can tell which machine wrote what. |
| `ANAMNESIS_GIT_REMOTE` | unset | The sync address of your shared repo. Unset means commit locally only, no sync. |
Keep your real `ANAMNESIS_GIT_REMOTE` out of any config you commit to a public repo. Use the
per-machine `~/.anamnesis/config.json` that `init` writes, or a user-scoped MCP config.
### I want to see what `init` will do before it changes anything. [#i-want-to-see-what-init-will-do-before-it-changes-anything]
Run it with `--print`. This is a dry run: it shows the full plan (which MCP entry, which hooks,
which store directory, which remote) and writes nothing.
```bash
uv run anamnesis init --print
```
When you are happy, run it for real:
```bash
uv run anamnesis init # interactive: confirm store dir, machine id, remote
```
`init` is safe to run more than once. It backs up your Claude Code `settings.json` before changing
it and never installs a hook twice, so re-running to fix a setting or add a remote later does no
harm.
### I am only on one machine. Do I still set up sync? [#i-am-only-on-one-machine-do-i-still-set-up-sync]
No. Run:
```bash
uv run anamnesis init --local-only
```
Everything works locally and your notes are still version-controlled on disk. When you add a second
machine later, just re-run `init` with a remote and nothing else changes:
```bash
uv run anamnesis init --remote 'you@host.your-tailnet.ts.net:anamnesis-memory.git'
```
## The typing\_extensions crash [#the-typing_extensions-crash]
### `anamnesis init` crashes with an error about `typing_extensions`. What is wrong? [#anamnesis-init-crashes-with-an-error-about-typing_extensions-what-is-wrong]
This is a known, fully understood issue with a simple fix. One of Anamnesis's dependencies
(`python-ulid`) imports `typing_extensions` even on modern Python, but does not always declare it,
so a minimal install can be missing it and crash on startup.
It only happens in two situations:
* You are on a **stale checkout** of the repo from before the fix landed.
* You installed the broken **0.0.1** package.
The fix is to use version **0.1.0 or newer**, which declares `typing-extensions>=4` as a dependency,
and to install with **Python 3.12**:
```bash
cd server
uv venv --python 3.12
uv pip install -e ".[mcp,dev]"
```
If you are on a stale checkout, pull the latest first:
```bash
git pull
```
To confirm you are on a fixed version, check the package version:
```bash
uv run anamnesis --help # should run without a typing_extensions error
```
The published package name is `anamnesis-memory`, but the command it installs is still `anamnesis`.
The one-line install (`uv tool install anamnesis-memory && anamnesis init`) is the normal path and
always gets you a fixed version; the from-source steps above only matter for repo checkouts.
## Reflection and provider settings [#reflection-and-provider-settings]
### I ran `anamnesis reflect` and it wrote nothing. Why? [#i-ran-anamnesis-reflect-and-it-wrote-nothing-why]
Reflection is the optional pass that reads your past session notes for a project and distills them
into durable, reusable notes. It uses a language model, and **it writes nothing unless you have
configured a provider for it.** Two things to know:
1. **By default `reflect` is a dry run.** Without `--apply`, it only reports what it would do:
```bash
uv run anamnesis reflect --project my-app
```
```
reflect: my-app: 7 episodic(s) would be distilled (dry-run; pass --apply)
```
2. **`--apply` needs a provider configured.** If you pass `--apply` but no model is set up, it tells
you and exits without writing:
```bash
uv run anamnesis reflect --project my-app --apply
```
```
reflect: no reflection provider configured (set ANAMNESIS_REFLECTION_PROVIDER + model/base-url/key)
```
To enable it, set all of: the provider, the model id, the OpenAI-compatible base URL, and an API
key. These go in your environment when you run the command by hand (this is a manual command, not
the MCP server). For example, for an OpenAI-compatible endpoint:
```bash
export ANAMNESIS_REFLECTION_PROVIDER=deepseek
export ANAMNESIS_REFLECTION_MODEL=deepseek-chat
export ANAMNESIS_REFLECTION_BASE_URL=https://api.deepseek.com
export ANAMNESIS_REFLECTION_API_KEY=sk-...
uv run anamnesis reflect --project my-app --apply
```
A project also has to have enough material to be worth distilling: at least 5 un-reflected session
notes by default (set `ANAMNESIS_REFLECT_MIN_EPISODICS` to change it). Projects under that threshold
are quietly skipped.
You never need a provider for everyday memory. The note Anamnesis saves at the end of each session
is built deterministically with no network call and no API key. Reflection is an extra, opt-in
"clean up and summarize" step.
### Does my session-end summary cost money or need an API key? [#does-my-session-end-summary-cost-money-or-need-an-api-key]
No. By default the end-of-session note is built deterministically (the heuristic builder), entirely
on your machine, with no API key and no network call. The summarization model is a swappable config
value for people who want to plug a model in later, but it is off until you set one.
## Sync and conflicts [#sync-and-conflicts]
### Two machines edited the same note. Which one wins? [#two-machines-edited-the-same-note-which-one-wins]
Neither is silently overwritten. Anamnesis syncs your memory as a git repository, and on a genuine
conflict (the same note changed differently on two machines) it does the safe thing: it **keeps your
local edits, does not push, and tells you** to resolve it. It never auto-merges or drops a side.
You will see this in the sync output:
```bash
uv run anamnesis sync
```
```
sync: pushed=False pulled=0 conflicted=True head=a1b2c3d (conflict on rebase; kept local edits, did not push - resolve and re-sync)
```
When this happens, Anamnesis has already aborted the failed rebase for you, so your repo is back in a
clean state with your local edits intact (nothing is half-applied and there is no rebase to finish).
To bring the two sides together, pull the remote in, resolve the conflicting note, commit, and sync
again:
```bash
cd ~/.anamnesis/memory
git status # confirm the tree is clean, your edits are present
git pull --no-rebase origin main # merge the remote in; git marks the conflicting note
# open the conflicting note, keep the wording you want, remove the <<<< ==== >>>> markers, then:
git add -A
git commit
uv run anamnesis sync # pushes the merged result to your other machines
```
In practice conflicts are rare because each machine usually writes its own session notes (each note
has its own file), but when they do happen your work is never thrown away.
### Should I sync the search index or the database file across machines? [#should-i-sync-the-search-index-or-the-database-file-across-machines]
No, and Anamnesis will not do it for you. **Only the markdown notes sync.** The search index
(`index.db`) lives outside the synced folder and is rebuilt locally on every machine after each
pull. This is deliberate: syncing a live database file through git or a cloud folder is exactly what
corrupts it. So a note written on your desktop becomes searchable on your laptop within one sync
cycle, and the database never travels.
The layout on disk makes this clear:
```
~/.anamnesis/
├── memory/ # markdown notes - the source of truth (this is the git repo that syncs)
│ └── /.md
└── index.db # search index - rebuilt locally, never synced
```
If your search results look stale on one machine, you can rebuild the index without touching git:
```bash
uv run anamnesis reindex
```
```
reindex: indexed 12 note(s)
```
## A few more quick answers [#a-few-more-quick-answers]
### What is the difference between `sync` and `--no-sync`? [#what-is-the-difference-between-sync-and---no-sync]
`anamnesis sync` does the full cross-machine round trip: commit your local changes, pull the remote
with rebase, push, and rebuild the index. Several commands also accept a `--no-sync` flag (for
example `capture`, `reflect`, `migrate`). With `--no-sync`, the command writes its changes and
rebuilds the local index, but it does **not** commit or push.
`--no-sync` does not commit, so its output is uncommitted working-tree changes. If a background sync
runs before you commit, those changes can be lost. If you use `--no-sync`, either re-run with a sync
afterward or commit the changes in `~/.anamnesis/memory` yourself.
### How do I check sync status at a glance? [#how-do-i-check-sync-status-at-a-glance]
```bash
uv run anamnesis status
```
`initialized=True` means the store is a git repo, `remote=` shows your sync address (or `None` for
local-only), `dirty=True` means there are uncommitted changes, and `head` is the current commit.
### Where do I go next? [#where-do-i-go-next]
* [Install and connect to Claude Code](./install) - the full setup walkthrough.
* [Across machines](./across-machines) - setting up Tailscale and the shared repo.
* [How it works](./how-it-works) - the plain-language tour of the moving parts.
* [Curating your memory](./curating) - reflect, dedup, and keeping notes tidy.
# What happens when you use it (/docs/guide/how-it-works)
The short version: once Anamnesis is set up, you keep using Claude Code exactly as you
always have. Memory happens around you. At the start of a session Anamnesis hands Claude
the notes that matter for the project you are in; during the session Claude can look
things up and save what it learns; when the session ends, what you did gets written down
as a short note; and all of it quietly syncs to your other machines.
You usually do nothing. This page walks through each moment so you know what to expect
and what the occasional visible bits mean.
A "note" is just a small markdown file: a title, a short body, and a few tags. You can
read every one of them by hand, search them in the [dashboard](./dashboard), or let
Claude pull them up for you. Nothing is hidden in a binary blob.
## The shape of a session [#the-shape-of-a-session]
The three things that happen automatically are **inject** (at the start), **capture**
(at the end), and **sync** (quietly, in the background). You can ignore all three. The
rest of this page explains them in case you are curious or something looks unexpected.
## At the start: relevant memory is injected [#at-the-start-relevant-memory-is-injected]
The moment a session starts, resumes, or you run `/clear`, Anamnesis selects the notes
that are most relevant to the project you are working in and hands them to Claude as
context. You see them as a block at the top of the conversation that begins with this
exact heading:
```
# Anamnesis memory (auto-injected)
```
That is your cue that Claude started this session already knowing the relevant history,
instead of from zero.
### What gets picked [#what-gets-picked]
Anamnesis does not dump your whole memory into the session. It chooses up to eight
project notes, plus all of your global notes (preferences that apply everywhere). The
selection favors:
* your **durable** notes for this project (verified how-tos, decisions, facts, and
conventions), most-recently-updated first, and
* up to **two** recent session summaries, so Claude has a "here is what you last did"
thread to pick up from.
Notes you have superseded are left out (they are still browsable in the dashboard), and
session summaries that have already been folded into durable notes are dropped so the
same thing is not said twice.
### What the block looks like [#what-the-block-looks-like]
A realistic injected block for a project might look like this:
```md
# Anamnesis memory (auto-injected)
## [semantic] Prefer pnpm over npm in this repo
_project: github.com/you/storefront | origin: desktop_
The lockfile is pnpm-lock.yaml. Use `pnpm install` / `pnpm add`, never npm,
or CI fails on a mismatched lockfile.
## [procedural] How to run the integration tests locally
_project: github.com/you/storefront | origin: desktop_
Start the test database with `docker compose up -d db`, then
`pnpm test:integration`. The suite expects DATABASE_URL on localhost:5433.
## [episodic] add a Stripe webhook handler for checkout.session.completed
_project: github.com/you/storefront | origin: laptop_
**Ask:** add a Stripe webhook handler for checkout.session.completed
**Branch:** feat/checkout-webhook
**Files touched (2):**
- src/app/api/webhooks/stripe/route.ts
- src/lib/orders.ts
**Outcome:** Handler verifies the signature and marks the order paid. Still
need to add a test for the duplicate-event case.
```
The `[semantic]`, `[procedural]`, and `[episodic]` tags are the three kinds of note,
explained [below](#the-three-kinds-of-note). The `_project: ... | origin: ..._` line
tells you which project the note belongs to and which of your machines first wrote it.
If a note was not written by you by hand (for example a session summary, or a note from
a reflection model), the metadata line also shows its source and a confidence score, for
example `source: session-end (confidence 0.8)`. Notes you wrote yourself show no such
tag.
If there are no relevant notes yet (a brand-new project, or a fresh install), the block
is simply not shown. That is normal, not an error. It fills in as you work.
## During the session: Claude can recall and save [#during-the-session-claude-can-recall-and-save]
While you work, Claude can reach into your memory on its own through a small set of
tools. You do not call these; Claude does, when it helps. Read-only lookups are safe to
auto-approve, so they happen without interrupting you:
* **search** your notes by keyword (for example, "how did we set up auth here?"),
* **list** your notes for a project,
* **check status** (how many notes you have, where the store lives, sync state).
When Claude learns something worth keeping, it can also **save a note** and **run a
sync**. Saving and syncing change your store, so by default Claude asks before doing
them, the same way it asks before editing a file.
If you ever see a tool-approval prompt, these are the five tools, named exactly as they
appear: `memory_search`, `memory_list`, and `memory_status` are the read-only lookups
(safe to auto-approve), while `memory_write` (save a note) and `memory_sync` (sync now)
are the two that change your store.
You can also just ask in plain English. Some examples of things that work:
```text
Remember that we deploy this project with `make release`, not from CI.
```
```text
What did I decide about the database migration strategy last week?
```
```text
Save a note: the staging API key lives in 1Password under "storefront-staging".
```
Claude turns the first and third into saved notes and answers the middle one by searching
what you already have.
A note can be **portable** (the default; it syncs to your other machines) or
**machine-local** (it stays only on the machine where it was written and never syncs).
If something is specific to one laptop, you can ask for it to be kept machine-local.
## The three kinds of note [#the-three-kinds-of-note]
Every note is one of three kinds. You rarely have to think about which is which (Claude
picks), but it helps to recognize them in the injected block and the dashboard:
A short record of a session: the ask, the branch, files touched, and how it turned
out. These are usually written automatically when a session ends.
Stable facts, conventions, and the way you like things done. "Use pnpm here." "The
staging URL is X." These tend to stay true over time.
Verified steps, decisions, and fixes that worked. "Run the tests like this." "We
chose Postgres over SQLite because Y."
## At the end: your session is captured [#at-the-end-your-session-is-captured]
When a session ends, Anamnesis reads the transcript and writes a single short "what
happened" note (an episodic note) so the next session, on any of your machines, can pick
up where you left off. That note records the ask you opened with, the git branch, the
files that were edited, and the last outcome. It is the kind of note you saw in the
injected block above.
It also captures the same kind of note **before the context window fills up and gets
compacted**, so a long session does not lose its history partway through.
Anamnesis skips sessions that are not worth remembering. If you only ran a slash command,
or barely did anything, no note is written, and you will simply not see one. Nothing to
clean up.
By default the summary is written **deterministically** from the transcript, so it needs
no API key and costs nothing: it is the ask, the branch, the files, and the outcome,
quoted from your session. A smarter summarizer (a reflection model) is a swappable
option you can turn on later, but it is **not** on by default.
## In the background: sync keeps your machines in step [#in-the-background-sync-keeps-your-machines-in-step]
When a session starts, Anamnesis also kicks off a sync in the background (it does not
block you), and it syncs again after capturing the end-of-session note. A sync commits
your local notes, pulls anything new from your other machines, and pushes yours up, then
rebuilds the local search index so the new notes are immediately searchable.
The practical effect: a note written on your desktop is on your laptop by the next
session, with no manual step.
If you edited the *same* note differently on two machines, sync will not silently throw
one away. It surfaces the clash as a normal git conflict for you to resolve, and keeps
your local edits in the meantime. This is rare in day-to-day use. The deep dive is in
[How sync works](../internals/sync).
Only your markdown notes travel between machines. The search index (a SQLite database) is
rebuilt fresh on each machine and is never synced, which is what keeps it from ever
corrupting.
## So what do you actually do? [#so-what-do-you-actually-do]
Most of the time, nothing. You open Claude Code and work. The honest summary:
| Moment | What happens | What you do |
| ---------------------------- | ---------------------------------------------------- | ------------------------------------------------- |
| Session start | Relevant memory is injected; background sync starts | Nothing (you may see the auto-injected block) |
| While working | Claude searches and lists notes as needed | Nothing, or ask it to remember / recall something |
| Claude wants to save or sync | It asks first | Approve, the same as approving a file edit |
| Session end / context fill | A short note of what happened is captured and synced | Nothing |
The one habit worth building is occasional curating: glancing at the dashboard now and
then to fix or retire a note that has gone stale. That is covered in
[Curating your memory](./curating).
## Where to go next [#where-to-go-next]
* [Install and set up](./install) - get Anamnesis running and wire up the hooks that
make all of the above automatic.
* [Across machines](./across-machines) - put your machines on one Tailscale network and
point them at a shared repo.
* [The dashboard](./dashboard) - browse, search, and edit every note by hand.
* [Curating your memory](./curating) - keep notes accurate over time.
* [How injection and capture work](../internals/capture-and-injection) - the internals,
if you want them.
# What is Anamnesis? (/docs/guide)
Anamnesis is a memory for [Claude Code](https://claude.com/claude-code). It remembers what you and Claude
worked out together, keeps it after you close the session, and carries it to the other computers you own. The
name is Greek: *anamnesis* means recollection, the act of calling knowledge back to mind.
This page explains the problem it solves, what you actually get, and why you would want it. It stays at the
level of "what you do" and "what you see". If you want to know how the parts fit together under the hood, that
lives in the [internals](../internals/architecture) section.
## The problem [#the-problem]
Claude Code is great in the moment, but its memory has two holes.
**It forgets between sessions.** You spend an afternoon teaching it your project: how the code is laid out,
which fix finally worked, the convention you always want followed. You close the terminal. Tomorrow you open a
new session and it is back to zero. You explain the same things again.
**It does not follow you across machines.** Maybe the desktop session learned all of that. But you pick up
your laptop on the train, and that knowledge never made the trip. Each machine starts fresh, and the work you
did on one never reaches the other.
People try to patch the second hole by syncing Claude Code's local database through a cloud folder like
Dropbox or iCloud. That tends to corrupt the database, because two machines writing the same file at once is
exactly what those folders are bad at. So the common workaround quietly makes things worse.
Anamnesis is for syncing across *your own* machines, the desktop and laptop and home server you already own.
It is not a shared team brain or a cloud account. Your memory stays on your hardware.
## The promise [#the-promise]
A memory that persists and follows you.
What Claude learns in one session is still there in the next one. What it learns on your desktop is already on
your laptop the next time you sit down there. You stop re-explaining your project, and you stop losing context
just because you switched machines.
## What you actually get [#what-you-actually-get]
In plain terms, Anamnesis gives you three things.
### 1. Plain notes that you own [#1-plain-notes-that-you-own]
Every durable thing Claude learns becomes a plain markdown note: a conventions file for a project, a record of
an architecture decision, a short summary of what a session accomplished. Markdown is just text. You can open
it in any editor, read it, fix a typo, or delete it. Nothing is locked inside a proprietary format or a vendor
cloud. The notes live in a folder on your computer at `~/.anamnesis`.
Because the notes are version-controlled (they live in a git repository), you also get history for free: who
wrote what, on which machine, and when. Nothing is silently overwritten.
### 2. Sync across your own computers, over your own private network [#2-sync-across-your-own-computers-over-your-own-private-network]
Your notes stay in step across every machine you own. A note written during your desktop session is searchable
in your laptop session within a sync cycle. The sync runs over a private network you set up between your own
devices using [Tailscale](https://tailscale.com) (a tool that links your machines into one private mesh, with
no public servers in the middle). You do not paste API keys into a third party, and your notes are not stored
in someone else's cloud.
If you only have one machine right now, that is fine. You can run it single-machine and add a second computer
later without redoing anything. See [Working across machines](./across-machines) for the setup.
### 3. A dashboard to browse it all [#3-a-dashboard-to-browse-it-all]
There is a small web dashboard, like a friendly viewer for your memory, where you can:
* read and full-text search every note,
* edit a note's markdown and see the full history of each one,
* see your whole fleet of machines: which machine wrote what, and when it last synced.
Edits you make in the dashboard write straight back to the markdown, so the dashboard and Claude always see the
same notes. See the [Dashboard tour](./dashboard) for what each screen does.
## How it feels day to day [#how-it-feels-day-to-day]
You mostly do nothing, and that is the point. Once it is set up, a normal session looks like this.
* **At the start of a session**, Claude is quietly handed the notes that matter for what you are working on:
your global preferences, the project's durable notes, and a couple of recent session summaries. You did not
have to remind it.
* **At the end of a session** (and before Claude compacts a long conversation), a short note is captured from
what happened: what you asked for, which files were touched, how it turned out. It is synced so it reaches
your other machines by your next session.
This automatic behavior is driven by Claude Code's lifecycle hooks (small actions Claude Code runs at the
start and end of a session). You can read more in [What happens when you use it](./how-it-works).
You can also ask Claude directly, in plain language, things like "what do we know about this project?" or
"remember that we always use the configs-over-scripts approach here." Anamnesis is exposed to Claude Code as a
set of memory tools, so it can search, list, write, and sync your memory on your behalf. The read-only ones
(searching and listing) are safe to let it run without asking each time.
For example, a question to Claude might look like this in your terminal:
```text
> what do we know about this project so far?
I'll check the project memory.
(searching memory... 3 notes found)
From your notes:
- Conventions: configs over scripts; no Co-Authored-By trailers in commits.
- Decision: file-first memory, markdown is the source of truth (not a graph DB).
- Last session: fixed keyword recall so natural-language queries score above 0%.
```
You did not have to paste any of that in. It came from notes earlier sessions wrote, possibly on a different
machine.
## Why you would want it [#why-you-would-want-it]
* **Stop repeating yourself.** Conventions, decisions, and hard-won fixes survive past the session that
produced them.
* **Switch machines without losing your place.** What the desktop learned is already on the laptop.
* **Keep ownership.** The memory is plain markdown on your own machines, human-readable and yours, with full
version history. No cloud account is required.
* **Stay private.** Sync happens over your own private mesh between your own devices, not through a vendor's
servers.
* **Trust it.** Because notes sync as markdown over git and the search index is rebuilt locally on each
machine, the database file is never synced and never corrupts. If two machines edit the same note in
conflicting ways, that surfaces as a normal git conflict for you to resolve, rather than one edit being
silently dropped.
## What this is not [#what-this-is-not]
To set expectations honestly:
* It is not a hosted service or a cloud account. There is nothing to sign up for, and your notes are not stored
on anyone else's servers.
* It is not a team or multi-user shared memory. It syncs across *your own* machines.
* It is not a magic knowledge graph. The notes are plain files, on purpose, because plain files are what the
latest models are best at using.
## Project status [#project-status]
Anamnesis is open source under the Apache 2.0 license and is currently pre-alpha, so setup and details may
still change. The local-first core (the note store, the memory tools Claude Code uses, and git-over-Tailscale
sync) is built and tested, including a real desktop-to-laptop round-trip. The dashboard and a one-command
installer are in place.
The fastest way to install Anamnesis is the one-line install from PyPI
(`uv tool install anamnesis-memory && anamnesis init`), which is live today. Cloning the repository and
building from source is the path for contributors and local development. See the [Install guide](./install)
for both.
## Next step [#next-step]
Ready to try it?
Set up Anamnesis on this machine and wire it into Claude Code with a single command.
See exactly what gets injected, captured, and synced as you work.
# Install and connect to Claude Code (/docs/guide/install)
This page takes you from nothing to a working setup: Anamnesis installed, connected to Claude Code, and your memory turning on automatically. Plan for about five minutes.
Anamnesis is a memory layer for Claude Code. It saves what Claude learns about your projects as plain markdown files on your own machine and feeds the relevant pieces back to Claude when you start a new session. You install it once per machine. After that it works in the background.
Anamnesis is pre-alpha and open source (Apache-2.0). The one-line install below is the whole setup for normal use; the from-source steps at the end of this page are for contributors and air-gapped machines.
## What you need first [#what-you-need-first]
* **Claude Code**, installed and working. Anamnesis plugs into it. If you can run `claude` in a terminal, you are set.
* **Python 3.12.** Anamnesis runs on Python 3.11 or newer, and the setup commands here pin 3.12 (the version it is built and tested against).
* **`uv`**, a fast Python package manager. It creates the isolated environment Anamnesis runs in and fetches its dependencies. If you do not have it, install it from [the uv install guide](https://docs.astral.sh/uv/getting-started/installation/) (a single command), then continue.
* **`git`.** It is almost certainly already on your machine. Anamnesis stores your memory as a git repository.
You do not need a cloud account, an API key, or a server. Everything lives on your machine.
## Install [#install]
Anamnesis is on PyPI as `anamnesis-memory`. One command installs it and puts the `anamnesis` command on your PATH:
```bash
uv tool install anamnesis-memory
```
Two details worth knowing:
* The package is named **`anamnesis-memory`** (the plain name `anamnesis` was taken by an unrelated project), but the command it installs is just **`anamnesis`**.
* The server is also listed on the official [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.oscardvs/anamnesis`, so MCP clients that browse the registry can find it there.
That is the install done. You now have the `anamnesis` command available. Next you connect it to Claude Code.
(Building from a clone instead? See [Install from source](#install-from-source-contributors) further down; every command on this page then takes a `uv run` prefix.)
## Connect it: `anamnesis init` [#connect-it-anamnesis-init]
One command wires this machine up:
```bash
anamnesis init
```
`anamnesis init` is interactive. It asks you a few short questions, each with a sensible default in brackets that you can accept by pressing Enter:
```text
store home [/home/you/.anamnesis]:
machine id [your-hostname]:
sync remote (blank = local-only):
command form [anamnesis]:
```
* **store home** is the folder where your memory lives. The default, `~/.anamnesis`, is right for almost everyone.
* **machine id** is a friendly name stamped on every note this machine writes, so later you can tell which machine recorded what. It defaults to your computer's hostname. You can type something clearer, like `desktop-amsterdam`.
* **sync remote** is for sharing memory across several of your own machines. Leave it blank for now to stay on one machine. You can add it later by running `init` again. (The full multi-machine setup is covered in [Memory across machines](./across-machines).)
* **command form** is the exact command Claude Code will use to launch Anamnesis. The default is correct; just press Enter. (On a source checkout the default shows the `uv run --project ...` form instead; that is also correct.)
When it finishes, you will see a short report and a closing line:
```text
mcp: registered anamnesis (user scope)
hooks: installed SessionStart/SessionEnd/PreCompact -> /home/you/.claude/settings.json
config: wrote /home/you/.anamnesis/config.json
sync: pushed=False pulled=0 (committed locally; no remote configured)
init: done. Start a new Claude Code session for the MCP server and hooks to take effect.
```
The exact wording of the `sync:` line varies a little: on a single machine with no remote yet it reports that there was nothing to push (`pushed=False`) and explains why (`no remote configured`). That is expected and not an error.
### What `anamnesis init` actually set up [#what-anamnesis-init-actually-set-up]
In one run it did four things for you, so you do not have to edit any config by hand:
1. **Registered the memory server with Claude Code** at user scope. This is what lets Claude search, list, and write your memory while you work. (The "MCP server" is just the small program Claude talks to; you never run it yourself.)
2. **Installed the lifecycle hooks** into your `~/.claude/settings.json`. These are what make memory automatic: relevant notes are pulled in when a session starts, and a summary of the session is saved when it ends or when the context is about to be compacted. See [What happens when you use it](./how-it-works) for the details.
3. **Wrote a small config file** at `~/.anamnesis/config.json` recording your machine id and (if you set one) your sync remote, so every later launch finds the right settings.
4. **Ran a first sync.** On a single machine with no remote there is nothing to send to other machines, so it just reports that (`pushed=False ... no remote configured`); once you have a sync remote it makes sure this machine is in step with the others.
`init` is safe to run more than once. It backs up your existing `settings.json` before changing it (to `settings.json.bak`) and never adds a hook twice. Re-run it any time you want to change a setting, for example to add a sync remote later.
You MUST start a NEW Claude Code session for any of this to take effect. The server and the hooks are read when a session starts, so a session that was already open will not see them. Fully quit Claude Code and open it again, then begin a new session.
### Preview before committing: `--print` [#preview-before-committing---print]
If you want to see exactly what `init` will do without it writing or changing anything, add `--print`:
```bash
anamnesis init --print
```
It prints the full plan (the store path, machine id, remote, the command form, and the precise `claude mcp add` and hooks it would write) and exits without touching your system. This is a good way to look before you leap.
### Single machine for now [#single-machine-for-now]
If you only use one machine and want to skip the sync question entirely, run:
```bash
anamnesis init --local-only
```
You can always add a remote later by running `init` again. Nothing else changes.
## A note on environment variables (read this if sync seems off) [#a-note-on-environment-variables-read-this-if-sync-seems-off]
This trips up newcomers, so it is worth one minute.
When Claude Code launches the memory server, it does so with a stripped-down, filtered environment. That means **the server does not see variables you exported in your shell.** If you ran something like `export ANAMNESIS_GIT_REMOTE=...` in your terminal, the server will not pick it up.
You do not need to worry about this if you use `anamnesis init`. It is the whole point of `init`: it bakes your settings into the right places (the server registration, the hooks, and `~/.anamnesis/config.json`) so the server always finds them, no shell exports required.
If you ever do configure things by hand, set these in the server's `"env"` block in `.mcp.json` rather than your shell. The variables Anamnesis reads are:
| Variable | Default | What it does |
| ---------------------- | -------------- | ---------------------------------------------------------------------------- |
| `ANAMNESIS_HOME` | `~/.anamnesis` | Where the store and index live. |
| `ANAMNESIS_MACHINE_ID` | your hostname | The machine name stamped on notes you write. |
| `ANAMNESIS_GIT_REMOTE` | unset | The git remote used to sync across machines; unset means local commits only. |
The `.env.example` file in the repo documents these too, but remember: that `.env` file is for running tooling by hand. It is not read by the server Claude Code launches.
## Install from source (contributors) [#install-from-source-contributors]
If you are working on Anamnesis itself, or you want an editable install from a clone:
```bash
git clone https://github.com/oscardvs/anamnesis.git
cd anamnesis/server
uv venv --python 3.12
uv pip install -e ".[mcp,dev]"
```
* `uv venv --python 3.12` creates a clean, isolated Python 3.12 environment just for Anamnesis, so it never collides with anything else on your machine.
* `uv pip install -e ".[mcp,dev]"` installs Anamnesis into it in editable mode: the code stays in the folder you cloned and changes take effect immediately.
With this install the command is not on your PATH: run everything with a `uv run` prefix from the `server/` folder (`uv run anamnesis init`, `uv run anamnesis status`). `init` detects that form and bakes it into what Claude Code launches.
## How the pieces fit together [#how-the-pieces-fit-together]
## Check it worked [#check-it-worked]
After you start a fresh Claude Code session, you can confirm Anamnesis is connected by asking Claude to check its memory status. With the server registered, Claude has a read-only `memory_status` tool that reports note counts, the store paths, and the sync state. If it answers with those, you are wired up.
You can also verify from the terminal at any time:
```bash
anamnesis status
```
## Troubleshooting [#troubleshooting]
**Nothing seems to happen in Claude Code.** The most common cause is forgetting to start a new session. Quit Claude Code completely and reopen it, then start a fresh session.
* \*\*`mcp: \`claude\` not found on PATH`** in the `init`output. Anamnesis could not find the`claude`command to register itself. Make sure Claude Code is installed and that running`claude`in your terminal works, then re-run`anamnesis init\`. (Anamnesis will also print the exact command to run by hand if you prefer.)
* **`init: refusing to run; ... settings.json is not valid JSON`.** Your `~/.claude/settings.json` has a syntax error from a previous manual edit. Fix the JSON, then re-run. Anamnesis deliberately stops before changing anything so it cannot make a broken file worse.
* **`sync: skipped (...)`.** Your sync remote could not be reached. On a single machine you can ignore this. If you set a remote, check it is reachable and then run `anamnesis sync`. Multi-machine setup is covered in [Memory across machines](./across-machines).
* **Python version errors (source install).** Make sure the environment was created with `uv venv --python 3.12`. Anamnesis needs Python 3.11 or newer. (The `uv tool install` path picks a suitable Python for you.)
## Next steps [#next-steps]
* [What happens when you use it](./how-it-works) - how memory is recalled, captured, and synced as you work.
* [Memory across machines](./across-machines) - put your laptop and desktop on the same memory.
* [The dashboard](./dashboard) - browse, search, and edit your memory in a git-like GUI.
# Architecture (/docs/internals/architecture)
Anamnesis is a cross-machine, file-first memory layer for Claude Code. This page is the canonical
description of how it fits together: the five layers, the data that flows between them, and how Claude
Code itself drives the whole loop through the MCP server and lifecycle hooks. Every field name, default,
and path below is taken straight from the source in `server/src/anamnesis/`.
The one sentence that explains the whole design: **markdown is the source of truth, everything else is
either derived from it or moves it between your machines.**
## The five layers [#the-five-layers]
The layers depend on each other in exactly one direction, so the core stays usable when an upper layer is
absent. The store (`store.py`) never imports FastMCP; the server (`server.py`) imports the store but
nothing above it; the dashboard is a thin client over both. The module docstring in `server.py` states
this explicitly: "The store layer never imports FastMCP; the dependency points one way (server -> store)
so the engine stays usable without the MCP extra."
### 1. Markdown notes (source of truth) [#1-markdown-notes-source-of-truth]
Every note is one markdown file with a YAML front-matter block followed by the body. Files live under the
store root, which resolves from `ANAMNESIS_HOME` (default `~/.anamnesis`):
```
~/.anamnesis/
memory/ # SOURCE OF TRUTH - portable (synced) notes
/.md
local/ # machine-local notes, never synced
/.md
index.db # DERIVED - SQLite (WAL + FTS5), rebuilt locally
config.json # per-machine config (machine_id, remote), never synced
```
`` is one of `procedural`, `semantic`, or `episodic`. `` is a ULID (lexicographically sortable
by creation time), generated in `MemoryStore.write` via `str(ULID())`. The relative filename is
`/.md` inside the tree the note's scope maps to.
A note's scope decides which tree it lives in, and the tree is authoritative for the scope on reindex
(`MemoryStore.reindex` walks `memory/` as `portable` and `local/` as `machine-local`). The split matters:
`local/` is deliberately outside the git-synced `memory/` tree, so `machine-local` notes are never pushed
to your other machines.
The front-matter mirrors the `Memory` dataclass (`store.py`). Fields and their defaults:
| Field | Default | Notes |
| --------------------------- | ----------------------- | ------------------------------------------------- |
| `id` | (generated ULID) | primary key, also the filename |
| `type` | (required) | `procedural` / `semantic` / `episodic` |
| `title` | (required) | |
| `project` | `global` | project key; `global` notes inject everywhere |
| `machine_id` | `unknown` | machine of origin for the write |
| `scope` | `portable` | `portable` (synced) or `machine-local` |
| `tags` | `[]` | |
| `created_at` / `updated_at` | (UTC, second precision) | `datetime.now(UTC).isoformat(timespec="seconds")` |
| `prov_source` | `human` | `human` / `session-end` / `reflection` / `import` |
| `prov_model` | `""` | model id, omitted from front-matter when empty |
| `prov_session` | `""` | session id, omitted when empty |
| `confidence` | `1.0` | |
| `supersedes` | `""` | id of a note this one replaces |
Because the file is the source of truth, you can edit it by hand, `git diff` it, and review it in a pull
request. The full record and markdown format are covered in [the data model](./data-model).
### 2. SQLite FTS5 index (derived, rebuilt locally) [#2-sqlite-fts5-index-derived-rebuilt-locally]
The index at `~/.anamnesis/index.db` is a pure cache of the markdown. It exists for fast recall and stats,
and it can always be thrown away and rebuilt with `MemoryStore.reindex` (or `anamnesis reindex`). The
connection is opened with `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`, and with
`check_same_thread=False` because FastMCP runs sync tool calls in a worker threadpool that shares one
connection. WAL plus the busy timeout is what lets concurrent Claude Code sessions touch the store without
file-locking conflicts.
The schema (`_SCHEMA` in `store.py`) is three tables:
* `memories`: one row per note with the structured columns (`id`, `type`, `title`, `body_path`,
`project`, `machine_id`, `scope`, `created_at`, `updated_at`, the three `prov_*` columns, `confidence`,
`supersedes`). `type` and `scope` are `CHECK`-constrained to their allowed values. Note that the body
text is **not** stored here: the column is `body_path`, the file path relative to the scope's tree.
* `memory_tags`: `(memory_id, tag)` with an `ON DELETE CASCADE` to `memories`.
* `memories_fts`: an FTS5 virtual table over `title`, `body`, and `tags` (id is `UNINDEXED`), tokenized
with `porter unicode61`.
The schema is versioned (`_SCHEMA_VERSION = 1`, tracked in `PRAGMA user_version`). Because the index is
fully derived, a schema upgrade is just: drop the derived tables, recreate them, and reindex from the
markdown. There is never a risky in-place migration of user data in the database.
`MemoryStore.get` reads a note back from its markdown file, not from the database, reinforcing that the
file is canonical: it looks up `body_path` and `scope` in the index, then reads and deserializes the file.
The database file is **never synced**. This is the claude-brain corruption lesson: syncing a live SQLite
file over a cloud folder corrupts it. Only markdown travels (layer 3); each machine rebuilds its own
`index.db` locally after every pull. See [Design decisions](./design-decisions).
How keyword recall actually works (BM25, the deliberate OR-of-tokens query, superseded-note filtering) is
covered in depth in [Recall](./recall).
### 3. git over Tailscale (sync) [#3-git-over-tailscale-sync]
`~/.anamnesis/memory/` is an ordinary git repository. Syncing is a git cycle against a remote that lives on
your private Tailscale mesh (a bare repo on an always-on node, or another machine directly). The backend is
`GitSyncBackend` in `sync.py`, behind a `SyncBackend` Protocol so a peer-to-peer or libSQL backend can slot
in later without touching the server.
One sync cycle (`GitSyncBackend.sync`) is:
1. `git add -A`, then commit if anything is staged. The commit message is
`anamnesis: sync from at `, authored as `anamnesis >`.
2. If no remote is configured, stop here (commit locally only).
3. `git fetch origin`, then if the remote has the `main` branch, `git rebase origin/main`. A brand-new
local repo with no commits does a `git reset --hard origin/main` instead.
4. `git push -u origin main`.
The CLI wraps this in `_run_sync`, which also runs the native-memory import first and a `store.reindex`
afterwards, so the local FTS5 index is rebuilt to match the freshly-pulled markdown. The MCP `memory_sync`
tool does the same reindex step (it calls `backend.sync()` then `store.reindex()`).
Conflict policy is "surface, never silently drop." If the rebase hits a conflict, `GitSyncBackend.sync`
runs `git rebase --abort` (your local edits stay in place) and returns a `SyncResult` with
`conflicted=True` and the message "conflict on rebase; kept local edits, did not push - resolve and
re-sync." Nothing is lost; you resolve it and sync again.
The branch is always `main` (`_BRANCH = "main"` in `sync.py`). The full sync mechanics, the bare-repo
setup, and the Tailscale wiring are in [Sync](./sync) and the
[Across machines](../guide/across-machines) guide.
### 4. FastMCP server and lifecycle hooks [#4-fastmcp-server-and-lifecycle-hooks]
This is the layer Claude Code talks to. It has two faces:
* **The MCP server** (`anamnesis serve`, built by `build_server` in `server.py` using `FastMCP`), which
exposes five tools over stdio.
* **The lifecycle hooks** (`anamnesis inject` / `capture` / `sync`), thin CLI subcommands Claude Code runs
at session boundaries. They live in `cli.py` and deliberately avoid importing FastMCP so the hot path
works without the optional `mcp` extra.
Both read the same `ANAMNESIS_*` configuration (`config.py`): `ANAMNESIS_HOME`, `ANAMNESIS_MACHINE_ID`
(falls back to the store config, then the hostname), and `ANAMNESIS_GIT_REMOTE` (falls back to the store
config). The store-config fallback is what lets the MCP server, launched via `.mcp.json` without inline
env, still find the remote and actually push on `memory_sync`.
The five MCP tools (from `build_server`):
| Tool | Annotation | What it does |
| ---------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------- |
| `memory_search(query, project?, type?, scope?, k=8)` | read-only | FTS5 BM25 keyword search; returns up to `k` ranked notes with body and metadata |
| `memory_list(project?, type?, scope?)` | read-only | list notes newest-first (titles and metadata, no bodies) |
| `memory_status()` | read-only | counts by type/project/scope, store paths, git sync state |
| `memory_write(type, title, body, project="global", tags?, scope="portable")` | write (confirm) | create a note: write markdown, then index it |
| `memory_sync(force=False)` | write (open-world) | one git cycle (commit, pull --rebase, push), then reindex |
The three read-only tools carry `ToolAnnotations(readOnlyHint=True, openWorldHint=False)`, so a client can
safely auto-approve them. `memory_write` is flagged for confirmation (`readOnlyHint=False,
destructiveHint=False`) and `memory_sync` is open-world (`openWorldHint=True`, it touches the network).
The `force` flag on `memory_sync` is reserved for future use. A `memory_write` note is always stamped with
this machine as its origin and defaults to `prov_source="human"`. Full tool signatures and return shapes
are in [the MCP tools reference](../reference/mcp-tools) and [MCP server internals](./mcp-server).
### 5. Next.js dashboard [#5-nextjs-dashboard]
The dashboard is a git-like GUI over the exact same local store: browse and full-text search every note,
read and edit markdown with per-note history from the real git log, and a fleet view of which machine wrote
what. It reads the SQLite index directly for fast queries and shells out to the `anamnesis` CLI for writes
and sync, so it never becomes a second source of truth. Run it with:
```bash
cd dashboard
npm install
npm run dev # http://localhost:3000
```
When the dashboard saves an edit it writes markdown and then re-indexes the FTS5 index without touching
git (sync stays a separate, explicit step). See [Dashboard internals](./dashboard-internals) and the
[Dashboard guide](../guide/dashboard).
## How Claude Code drives the loop [#how-claude-code-drives-the-loop]
Claude Code drives Anamnesis at two points: lifecycle hooks (automatic, at session boundaries) and the MCP
tools (on demand, mid-session). `anamnesis init` installs both; the canonical hook definitions are in
`examples/hooks.settings.json`.
### SessionStart: inject relevant memory [#sessionstart-inject-relevant-memory]
The `SessionStart` event fires two hooks. The first matches `startup|resume|clear` and runs `anamnesis
inject` (timeout 15s); the second matches `startup|resume` and runs `anamnesis sync` asynchronously
(`"async": true`) so the network round-trip never blocks the session opening.
`anamnesis inject` (`cmd_inject` -> `select_inject` in `inject.py`) prints a markdown block to stdout, which
Claude Code injects into the session context. The selection for the current project (resolved by
`resolve_project_key` from the working directory) is:
* **All `global` notes**, always, in full.
* Up to **`k=8`** project notes: recent durable (`procedural` + `semantic`) notes fill the budget, but up
to **`_MAX_EPISODIC = 2`** of the most recent episodic notes are reserved for the "what I last did"
continuity thread.
* Superseded notes are hidden (a note is hidden when another note's `supersedes` points at it).
* Already-reflected episodics (tagged `reflected`) are dropped, because their content now lives in durable
notes.
* Ties on `updated_at` are broken by `confidence`.
`resolve_project_key` derives a stable project identity, in order: an explicit `.anamnesis/project` marker
file (searched up-tree, stopping below `$HOME`), else the normalized `origin` git remote, else the repo
root directory name, else the cwd basename.
### SessionEnd and PreCompact: capture durable notes [#sessionend-and-precompact-capture-durable-notes]
Both events run `anamnesis capture` (`cmd_capture` -> `parse_transcript` + `write_episodic`), which reads
the session transcript JSONL and writes one `episodic` note (the ask, the branch, the files touched, the
outcome). Trivial sessions are skipped (no note written), via the `is_trivial_session` gate that runs
before any summarizer.
* **SessionEnd** runs `anamnesis capture` (timeout 120s) and then syncs, so the note is on your other
machines by the next session.
* **PreCompact** runs `anamnesis capture --source precompact --no-sync` (timeout 60s). `--no-sync` is used
here because compaction can happen repeatedly mid-session; the note is committed and synced on the next
sync hook or at session end. The `--source` value is recorded as a tag (`session` plus `session-end` or
`precompact`); both map to the `session-end` `prov_source` stamped on the note.
The session-end summary is deterministic by default (`HeuristicSummarizer`) and needs no API key. The
summarization model is a swappable config value (`ANAMNESIS_REFLECTION_PROVIDER`, default `heuristic`);
see [Capture and injection](./capture-and-injection) for the transcript parser and
[Reflection](./reflection) for how distilling episodic notes into durable ones works.
The MCP server (`.mcp.json`) and the hooks (`~/.claude/settings.json`) are configured separately.
`settings.json` is per-machine and not synced, so each machine points its hooks at its own checkout.
Claude Code launches MCP servers with a filtered environment, so shell exports are not inherited; set
`ANAMNESIS_*` in the `.mcp.json` `"env"` block (or rely on the per-store `config.json` fallback).
## Setup in one command [#setup-in-one-command]
`anamnesis init` wires up a machine: it registers the MCP server at user scope, installs the
`SessionStart` / `SessionEnd` / `PreCompact` hooks, writes the store config, and runs a first sync. It is
idempotent (it backs up `settings.json` and never duplicates a hook), and `--print` shows the full plan
without writing anything.
```bash
cd server
uv venv --python 3.12
uv pip install -e ".[mcp,dev]"
uv run anamnesis init # interactive: confirm store dir, machine id, remote
uv run anamnesis init --print # dry-run: show exactly what it would do
```
To point a machine at your shared bare repo:
```bash
uv run anamnesis init --remote 'you@host.your-tailnet.ts.net:anamnesis-memory.git'
```
Working on a single machine for now? Use `uv run anamnesis init --local-only` and add a remote later by
re-running `init`.
The fastest path is the one-liner `uv tool install anamnesis-memory && anamnesis init`, which is live on PyPI.
The PyPI distribution is `anamnesis-memory`; the installed command is `anamnesis` regardless. The from-repo
steps above are for contributors and local development.
The hook subcommands, every flag, and the `ANAMNESIS_*` variables are documented in the
[CLI reference](../reference/cli) and [configuration reference](../reference/configuration).
## Where to go next [#where-to-go-next]
# Capture and injection (the hooks) (/docs/internals/capture-and-injection)
Anamnesis has no daemon. It rides Claude Code's lifecycle hooks instead. Three things happen for you,
automatically, around every session:
1. At **SessionStart** a small set of notes is selected and printed as context, and a sync runs in the background.
2. At **SessionEnd** the transcript is turned into one episodic note, then synced.
3. At **PreCompact** (just before Claude Code compacts the conversation) the same capture runs, without syncing.
This page is the precise reference for that wiring: which hooks `anamnesis init` installs, with what
timeouts; exactly how the injection working set is chosen; and exactly how a transcript becomes a note.
Every field name, default, and threshold below is taken from `server/src/anamnesis/inject.py`,
`server/src/anamnesis/capture.py`, `server/src/anamnesis/cli.py`, `server/src/anamnesis/onboard.py`, and
`examples/hooks.settings.json`.
## The hooks `init` installs [#the-hooks-init-installs]
`anamnesis init` writes four matcher-groups into Claude Code's `settings.json` (resolved from
`CLAUDE_CONFIG_DIR`, else `~/.claude/settings.json`). The shape is built by `build_hooks` in
`server/src/anamnesis/onboard.py` and mirrors the checked-in example at `examples/hooks.settings.json`.
| Event | Matcher | Command (subcommand) | `timeout` | `async` |
| ------------ | ------------------------ | ------------------------------------------------- | --------- | ------- |
| SessionStart | `startup\|resume\|clear` | `anamnesis inject` | `15` | no |
| SessionStart | `startup\|resume` | `anamnesis sync` | (none) | `true` |
| SessionEnd | (any) | `anamnesis capture` | `120` | no |
| PreCompact | (any) | `anamnesis capture --source precompact --no-sync` | `60` | no |
Timeouts are in seconds. They are the real values from `build_hooks`: `inject` gets `15`, `capture` at
SessionEnd gets `120` (it may run an LLM summary plus a full git sync), and the PreCompact `capture` gets
`60` (no sync, so it is faster). The background `sync` group carries `async: true` and no timeout, so the
session never blocks on the network.
Here is the literal block from `examples/hooks.settings.json`. `init` writes the same structure, except it
substitutes your resolved command form for the placeholder path and prepends an inline
`ANAMNESIS_MACHINE_ID=...` env prefix to each command:
```json
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear",
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis inject",
"timeout": 15
}
]
},
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis sync",
"async": true
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis capture",
"timeout": 120
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "uv run --project /ABSOLUTE/PATH/TO/anamnesis/server anamnesis capture --source precompact --no-sync",
"timeout": 60
}
]
}
]
}
}
```
The `inject` matcher includes `clear`, so re-injection happens on `/clear` too. The background `sync`
matcher is only `startup|resume`: a `/clear` re-injects from the local index but does not kick off another
network sync.
### How the command is resolved [#how-the-command-is-resolved]
The literal `uv run --project ...` form above is one of several. `detect_command` (in `onboard.py`)
resolves the base argv in this order:
1. explicit `--command "..."` override (split with `shlex`),
2. `--uv-project `, which produces `uv run --project anamnesis`,
3. an installed `anamnesis` found on `PATH` (used directly, by resolved absolute path),
4. else the fallback `uv run --project anamnesis`, where `` is the editable
checkout's `server/` directory.
`init` is idempotent. `merge_hooks` strips any prior Anamnesis matcher-groups (identified by the command
markers `ANAMNESIS_`, `anamnesis inject`, `anamnesis sync`, `anamnesis capture`), keeps every other
top-level key and any non-Anamnesis hooks, then inserts the fresh set. `write_settings` backs up the
existing file to `settings.json.bak` before writing it atomically, so re-running `init` is safe. The MCP
server is registered separately via `claude mcp add` at user scope (see
[MCP server](../internals/mcp-server)).
### Inline environment [#inline-environment]
Each installed hook command is prefixed with the `ANAMNESIS_*` values from `build_env`:
`ANAMNESIS_MACHINE_ID` is always set; `ANAMNESIS_GIT_REMOTE` is added when you configured a remote; and
`ANAMNESIS_HOME` is added only when the store home differs from the `~/.anamnesis` default. The
machine-local config at `/config.json` (written by `write_store_config`) carries the same machine id
and remote as a fallback, so a server or CLI launched without inline env still finds them. Because the
remote URL differs per machine, that config file lives outside the synced `memory/` repo and never syncs.
### A session at a glance [#a-session-at-a-glance]
## Injection: choosing the working set [#injection-choosing-the-working-set]
At SessionStart, `cmd_inject` (in `server/src/anamnesis/cli.py`) reads the hook payload from stdin,
derives the project key from `payload["cwd"]` via `resolve_project_key`, then calls
`select_inject(store, project=project, k=args.k)` and prints `render_inject(...)` to stdout. The default
`k` is `8` (from the `inject` subparser). Claude Code injects that stdout as session context.
The whole path is pure reads over `MemoryStore` (no FastMCP, no network), which is why it fits comfortably
inside the 15-second timeout.
### Project identity [#project-identity]
`resolve_project_key` (in `inject.py`) derives a stable project key from the working directory, in this
order:
1. the first non-empty line of the nearest `.anamnesis/project` marker, searched from the cwd upward and
stopping below `$HOME` and the filesystem root (so a stray marker at `$HOME` cannot hijack every
project);
2. else the normalized `origin` git remote (scheme, `user@`, and trailing `.git` stripped, lowercased; an
`scp`-form `host:path` is rewritten to `host/path`);
3. else the git repo-root directory name, lowercased;
4. else the cwd basename, lowercased, or `global` if empty.
The marker is the explicit, cross-machine-stable override, which matters for non-git workspaces where a
subdirectory would otherwise resolve to its bare basename.
### The selection algorithm [#the-selection-algorithm]
`select_inject` builds the working set from two project pools plus the global pool, against a fixed budget.
The relevant constants in `inject.py` are `_DURABLE = ("procedural", "semantic")` and `_MAX_EPISODIC = 2`.
In words, with the default `k = 8`:
* **All global notes, always, in full.** Every note whose `project` is `global` is included (minus any
superseded), and the budget `k` does not constrain them. Global notes are the "always-on" memory.
* **Then up to `k` project notes fill a budget.** The durable pool (types `procedural` and `semantic` for
this project) is sorted by `updated_at` descending, then `confidence` descending, so recency wins and
confidence breaks ties.
* **Reserve up to 2 most-recent episodics.** Up to `_MAX_EPISODIC = 2` recent episodic notes are reserved
inside the budget as the "what I last did" continuity thread. `reserve = min(len(episodic), budget)`,
durable notes take `budget - reserve` slots, and the final `project_sel` is
`durable_sel + episodic[:reserve]`.
* **Exclude superseded notes.** Any note id returned by `store.superseded_ids()` (that is, any note named
by another note's `supersedes` field) is dropped from all three pools. Superseded notes are hidden from
recall but remain browsable via `anamnesis` listing and the dashboard.
* **Drop already-reflected episodics.** Episodics carrying the `reflected` tag are excluded, because their
content has already been distilled into durable notes (see [Reflection](../internals/reflection)).
Including them would double-count.
The final list is the global pool followed by `project_sel`, de-duplicated by note id (so a note that is
both global and otherwise selected appears once).
The budget bounds the *project* notes, not the total. If you have many global notes they all inject. Keep
global notes lean. The continuity reserve means at most two recent episodics ever push durable notes out of
the budget.
### The rendered block [#the-rendered-block]
`render_inject` turns the selected notes into one markdown block written to stdout. The exact header is:
```markdown
# Anamnesis memory (auto-injected)
```
Each note renders as a section:
```markdown
## []
_project: | origin: _
```
The metadata line appends a provenance clause only when the note is not plain human-authored
(`prov_source != "human"`) or its confidence is below `1.0`: `| source: (confidence )`,
where the value is formatted with `%g` (so `0.8`, not `0.80`). `prov_source` is one of `human`,
`session-end`, `reflection`, or `import` (the values the store schema enforces with a `CHECK` constraint).
If the selection is empty, `render_inject` returns an empty string and `cmd_inject` writes nothing.
## Capture: a transcript becomes one episodic note [#capture-a-transcript-becomes-one-episodic-note]
`anamnesis capture` runs at SessionEnd and (with `--source precompact --no-sync`) at PreCompact.
`cmd_capture` in `cli.py` reads the transcript path from `--transcript` or the hook payload's
`transcript_path`, parses it, resolves the project, writes at most one episodic note, and then syncs unless
`--no-sync` was passed.
### Parsing the transcript [#parsing-the-transcript]
`parse_transcript` (in `server/src/anamnesis/capture.py`) reads the transcript JSONL line by line into a
`ParsedSession` with these fields:
* `first_prompt`: the first non-meta `user` message text (the **ask**). Lines with `isMeta` set are
skipped.
* `last_outcome`: the last non-empty `assistant` message text (the **outcome**).
* `files_touched`: the `file_path` inputs from `tool_use` blocks whose tool name is in
`_EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}`, de-duplicated, in first-seen order.
* `git_branch`: the first `gitBranch` field seen.
* `cwd`: the first `cwd` field seen.
* `session_id`: the first `sessionId` field seen.
* `raw`: the full transcript text.
Message text is extracted by `_text_of`, which accepts a plain string `content` or a list of blocks and
joins only the `text` blocks. Parsing is deliberately tolerant: an unreadable file or a malformed JSON line
degrades to an empty or partial `ParsedSession` rather than raising, so capture never breaks session
teardown.
### Skipping trivial sessions (the free gate) [#skipping-trivial-sessions-the-free-gate]
Before any summarizer runs, `is_trivial_session` decides whether the session is even worth a note. It is
provider-agnostic and free (no model call). The thresholds in `capture.py` are
`_TRIVIAL_OUTCOME_FLOOR = 40` characters and `_MAX_LEN = 600` (the clip limit applied later by the
summarizer).
So a session is kept whenever it touched a file, and otherwise kept whenever there is a real user prompt
with enough outcome text. It is skipped when both prompt and outcome are empty, when there is no prompt and
a tiny outcome (shorter than 40 characters), or when the prompt is just a lone slash command (matched by
`^/\S+$`, for example `/clear` or `/effort`) with a tiny outcome. When skipped, `cmd_capture` prints
`capture: skipped trivial session (...)` and writes nothing.
### Summarizing [#summarizing]
If the session is not trivial, `write_episodic` calls the resolved `Summarizer`. The default is the
deterministic `HeuristicSummarizer`, selected by `resolve_summarizer` from the
`ANAMNESIS_REFLECTION_PROVIDER` environment variable (default `heuristic`). The heuristic produces:
* **title**: the first line of the first prompt, truncated to 80 characters, or `"Session summary"` if
there is no prompt.
* **body**: an `**Ask:**` block (the first prompt, clipped to 600 chars), a `**Branch:**` line if a branch
was seen, a `**Files touched (N):**` list if any, and an `**Outcome:**` block (the last assistant text,
clipped to 600 chars). Empty fields fall back to `(no user prompt captured)` and
`(no assistant output captured)`.
The heuristic always returns a result. A summarizer may also return `None` to self-skip a session it judges
not worth keeping; that path is reserved for the swappable LLM summarizer.
The summarizer is swappable, not hardcoded. `resolve_summarizer` maps the `ANAMNESIS_REFLECTION_PROVIDER`
values `deepseek`, `openai`, and `local` to an LLM-backed summarizer (loaded with a lazy import so the hook
hot path never imports the LLM code unless a provider is configured), and any other value falls back to the
heuristic. v0 ships only the deterministic summarizer; the LLM path is the seam, not a default.
### Writing and provenance [#writing-and-provenance]
When there is a result, `write_episodic` persists one note via `store.write(...)` with:
* `type="episodic"`,
* `title` and `body` from the summary,
* `project` resolved from the session `cwd` (falling back to the payload `cwd`, then `.`),
* `machine_id` from `resolve_machine_id()`,
* `tags=["session", source]`, where `source` is `session-end` or `precompact` (so you can tell which hook
wrote it),
* `prov_source="session-end"` (both hooks map to this single provenance value),
* `prov_session` set to the transcript's `sessionId`,
* `prov_model` from the summary (empty for the heuristic, the model label for an LLM summary).
On success `cmd_capture` prints `capture: wrote episodic note (...)`. See [Data model](../internals/data-model)
for the full note schema and front-matter layout.
### Then sync (or not) [#then-sync-or-not]
At SessionEnd, `capture` runs without `--no-sync`, so after writing it calls `_run_sync`: mirror Claude
Code's native memory into the store (unless `ANAMNESIS_IMPORT_NATIVE=0`), then `git` commit,
`pull --rebase`, `push`, then `reindex`. At PreCompact the hook passes `--no-sync`, so the note is written
and indexed but the network sync is deferred to the next SessionEnd or the SessionStart background sync.
This keeps compaction fast and avoids hammering the remote mid-session.
A PreCompact note is committed locally but not pushed until a later sync. If a concurrent sync on another
machine rebases first, that is normal; sync resolves it on the next cycle. The architectural rule still
holds: never sync the raw SQLite file. Only markdown moves over git, and the index is rebuilt locally by
`reindex`. See [Sync over git](../internals/sync) for the full cycle.
## Running and inspecting the hooks by hand [#running-and-inspecting-the-hooks-by-hand]
Every hook is a plain subcommand you can run yourself. From a checkout:
```bash
# Print what would be injected for the current directory's project (k defaults to 8)
uv run --project server anamnesis inject
# Inject for an explicit project key, with a different budget
uv run --project server anamnesis inject --project my-project --k 12
# Capture from a transcript file without syncing (safe to experiment)
uv run --project server anamnesis capture --transcript /path/to/transcript.jsonl --no-sync
# See what init would install, without writing anything
uv run --project server anamnesis init --print
```
`inject` reads its `cwd` from the hook payload on stdin when run as a hook. Run by hand without a payload it
falls back to `.`, so pass `--project` if you want a specific project's working set.
## Related [#related]
* [Data model](../internals/data-model)
* [Recall](../internals/recall)
* [Reflection](../internals/reflection)
* [Sync over git](../internals/sync)
* [MCP server](../internals/mcp-server)
* [Install and init](../guide/install)
# Inside the dashboard (/docs/internals/dashboard-internals)
The dashboard is the memory GUI: a git-like visual interface over your cross-machine memory, built with
Next.js (App Router). It is a thin read/write client. It does not own any data. Markdown files under
`~/.anamnesis/memory` are the source of truth, the SQLite FTS5 index is derived, and the git history of
that folder is the memory history. No business logic lives here that the Python server does not also
enforce.
This page documents how the dashboard actually works: where reads go, where writes go, the full HTTP
route surface, how the 3D memory map is built, and what each machine needs installed to run it.
## The core idea: reads are local, writes go through the CLI [#the-core-idea-reads-are-local-writes-go-through-the-cli]
The single most important design decision is the split between read paths and write paths.
* **Reads** go straight to the machine-local store from Node. Search and list read the SQLite FTS5
index directly through a read-only `better-sqlite3` connection. Note content, history, and diffs read
the markdown files and the git log/show output directly.
* **Writes** (create, edit, delete, reindex, reflect, backfill-provenance, sync) shell out to the
`anamnesis` Python CLI. Keeping the Python store as the single indexer means the dashboard never
duplicates indexing logic.
Why the split exists: reading the index directly avoids spawning a Python process per request and keeps
search and list latency low. Writes route through the CLI so there is exactly one indexer and edits show
up in git history immediately.
## Read path 1: the SQLite index (`lib/db.ts`) [#read-path-1-the-sqlite-index-libdbts]
Search and list read the derived FTS5 index directly with `better-sqlite3`. The connection is opened
strictly read-only and is never allowed to mutate the writer's settings:
```ts
const db = new Database(path, { readonly: true, fileMustExist: true });
db.pragma("busy_timeout = 5000");
```
Key invariants in `src/lib/db.ts`:
* **Read-only, never owns the file.** The index is owned and written by the Python store. The dashboard
opens it `readonly: true`, sets `busy_timeout = 5000` (5 seconds) to tolerate the writer's WAL
checkpoints, and never changes `journal_mode`.
* **The connection is cached** on a global across hot-module-reload in dev, and dropped with `closeDb()`
after any mutation so the next read reopens the freshly rebuilt index.
* **If the index file does not exist yet,** `getDb()` returns `null` and every read returns an empty
result rather than throwing. `indexExists()` is the public check.
### How free text becomes a safe FTS5 query [#how-free-text-becomes-a-safe-fts5-query]
`buildMatch(query)` turns arbitrary input into a safe FTS5 `MATCH` expression. Each word token becomes a
quoted phrase, and the tokens are ANDed together:
```ts
export function buildMatch(query: string): string {
const tokens = query.match(/[\p{L}\p{N}_]+/gu) ?? [];
return tokens.map((t) => `"${t}"`).join(" AND ");
}
```
This mirrors the Python store's `_fts_query` and neutralizes every FTS5 operator (`AND`, `OR`, `NOT`,
`NEAR`, `*`, `:`, `"`), so user input can never break the query parser. When there are no word tokens it
returns `""`, and `searchMeta` then returns an empty result.
### The queries `lib/db.ts` runs [#the-queries-libdbts-runs]
| Function | Purpose | Notable detail |
| --------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------- |
| `listMeta(opts)` | List note metadata, newest first | `ORDER BY m.updated_at DESC, m.id DESC`, default `LIMIT 200 OFFSET 0` |
| `searchMeta(query, opts)` | Keyword (BM25) search | `WHERE memories_fts MATCH ?`, `ORDER BY bm25(memories_fts), m.updated_at DESC`, default `LIMIT 50` |
| `getMeta(id)` | Metadata for one note | returns `null` if not indexed |
| `countPendingReflections()` | Reflection notes awaiting review | `prov_source = 'reflection'` and not tagged `reviewed` |
| `stats()` | Totals by type and by project | mirrors the Python `StoreStats` |
| `countsByMachine()` | Note counts grouped by `machine_id` | feeds the fleet view |
Both `listMeta` and `searchMeta` accept the same optional filters: `project`, `type`, `provSource`,
`excludeTag`, `limit`, and `offset`. Tags are stored in a separate `memory_tags` table and are pulled in
per row with `group_concat(tag, char(31))`, then split on the unit separator (`\x1f`) into a string
array.
## Read path 2: markdown and git (`lib/store.ts`, `lib/git.ts`) [#read-path-2-markdown-and-git-libstorets-libgitts]
Note bodies, history, and diffs never come from SQLite. They come from the source of truth.
* **Bodies** are read by `readNoteText` / `readNote` in `src/lib/store.ts`, which resolve a note's
absolute path (by `bodyPath` from the index, falling back to a disk search across both trees) and read
the markdown file, parsing the front-matter with `parseMemory`.
* **History and diffs** are read by `src/lib/git.ts`, which runs `git` in the memory repo. Every read is
via `runGit` / `runGitSafe`, which invoke `git -C ...` with `GIT_OPTIONAL_LOCKS=0` so
reads never take a lock that could fight a concurrent writer.
`lib/git.ts` is read-only except for one function, `commitPaths`, used by the write path. The history
helpers parse a fixed pretty-format using a unit-separator (`\x1f`) between fields:
```
%H %h %an %ae %aI %s
```
* `globalHistory(limit = 200)`: the global commit log, newest first.
* `noteHistory(relPath)`: per-note history that follows renames (`git log --follow --name-status`),
tagging each commit with a change type (`A`, `M`, `D`, `R`, `T`).
* `noteContentAtCommit(hash, relPath)` and `commitDiff` / `commitFiles`: content and diffs at a revision.
* `repoState()`: working-tree and sync state (`dirty`, `ahead`, `behind`, `conflicted`,
`conflictedPaths`). Ahead/behind come from `git rev-list --left-right --count origin/main...HEAD`.
Conflict states are detected from porcelain v1 status codes (`UU`, `AA`, `DD`, `AU`, `UA`, `DU`, `UD`).
* `machinesFromGit()` and `fleet(noteCounts)`: the per-machine fleet view, derived from commit
authorship.
Diffs are not taken from raw `git diff`. `src/lib/history.ts` computes line diffs from file contents with
`jsdiff` (the `diff` package) so rendering is uniform whether the two sides are two git revisions or a
working-tree edit. `noteDiff(id, from, to)` treats `from === "empty"` as a new file and `to ===
"working"` as the current working-tree file.
### How the fleet view is derived [#how-the-fleet-view-is-derived]
Sync commits are stamped by the Python backend (`sync.py`) with author `anamnesis` and email
`anamnesis@`. The dashboard parses the machine id back out of that email, so the machines
view is derived straight from git authorship. The same identity is used when the dashboard authors a
commit, so dashboard edits and sync commits look consistent in history.
## Write path: markdown, then git, then reindex (`lib/store.ts`) [#write-path-markdown-then-git-then-reindex-libstorets]
Every write follows the same order. The order matters: the markdown file is the source of truth, the
commit records the change in history, and the reindex rebuilds the derived index.
Details from `writeNote`:
* A new note gets a `ulid()` id; an edit preserves the existing id, machine of origin, `createdAt`,
provenance fields, and `confidence`. New notes default to `project: "global"`, `scope: "portable"`,
`provSource: "human"`, and `confidence: 1.0`.
* The markdown is written byte-compatible with the Python serializer via `serializeMemory`
(`src/lib/markdown.ts`). It uses YAML 1.1, single quotes, unindented block sequences, and renders
`confidence` as a float (`1.0`, not `1`). This keeps ISO timestamps quoted so Python's `yaml.safe_load`
reads them back as strings rather than coercing them to `datetime`.
* **Only portable notes are committed.** Machine-local notes (`scope: "machine-local"`) live under
`local/`, a sibling tree that is never committed, so they never leave the machine. Portable notes live
under `memory/`.
* If the type or scope changed on edit, the file moved on disk; the old file is removed, and the removal
is committed when the old file was portable.
* The commit message is `anamnesis: note via dashboard`.
* After the file write and commit, `reindex()` runs the CLI and then calls `closeDb()` so the next read
reopens the rebuilt index. If git fails (for example the store is not a git repo), the commit is
skipped but the markdown write still stands.
### How the CLI is invoked [#how-the-cli-is-invoked]
All mutations funnel through `runCli` in `src/lib/store.ts`, which resolves how to invoke the CLI:
```ts
// default invocation, run from the dashboard directory
uv run --project ../server anamnesis ...
```
The invocation is configurable by environment variable:
* `ANAMNESIS_CLI` (unset by default): a full command prefix that overrides everything below, for example
`anamnesis` if the CLI is on `PATH`.
* `ANAMNESIS_UV` (default `uv`): the uv binary.
* `ANAMNESIS_SERVER` (default `../server`): the server project directory passed to `uv run --project`.
`runCli` always sets `ANAMNESIS_HOME` and `ANAMNESIS_MACHINE_ID` in the child environment so the CLI
operates on the same store the dashboard reads, with a 64 MiB `maxBuffer`.
The mutation helpers map directly to CLI subcommands:
| Helper | CLI command | Notes |
| ------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `reindex()` | `anamnesis reindex` | rebuilds the index; drops the cached connection on success |
| `reflect({ project, apply })` | `anamnesis reflect [--project P] [--apply --no-sync]` | dry-run unless `apply`; `--apply` implies `--no-sync` and reindexes |
| `backfillProvenance({ apply })` | `anamnesis backfill-provenance [--apply --no-sync]` | dry-run unless `apply` |
| `sync()` | `anamnesis sync` | one pull/push cycle; parses `pushed=`, `pulled=`, `conflicted=`, `head=` from output |
`reflect --apply` and `backfill-provenance --apply` both pass `--no-sync`, so they do not push your
changes to the remote. Run a separate **Sync** afterward (or commit) so a concurrent sync on another
machine cannot wipe the local-only output. Sync is always a separate, explicit action, so saving a note
never does surprise network I/O.
## The API route surface [#the-api-route-surface]
All route handlers are App Router route handlers under `src/app/api/`. Every one sets `runtime =
"nodejs"` and `dynamic = "force-dynamic"`: they read local SQLite, git, and the filesystem, so they must
run on the Node runtime and must not be cached or statically rendered. None of them run on the edge.
| Method + path | Reads / writes | Backed by |
| ----------------------------------------- | -------------- | ---------------------------------------------------------------------- |
| `GET /api/overview` | read | `stats()`, `repoState()`, `indexExists()`, `countPendingReflections()` |
| `GET /api/notes?q=&project=&type=&limit=` | read | `searchMeta` when `q` present, else `listMeta` |
| `POST /api/notes` | write | `writeNote` (create), returns `201` |
| `GET /api/notes/:id` | read | `readNote` body + `getMeta` |
| `PUT /api/notes/:id` | write | `writeNote` (edit) |
| `DELETE /api/notes/:id` | write | `deleteNote` |
| `GET /api/notes/:id/diff?from=&to=` | read | `noteDiff` (`from` default `empty`, `to` default `working`) |
| `POST /api/notes/:id/keep` | write | `markReviewed` (adds the `reviewed` tag) |
| `GET /api/notes/:id/history` | read | `noteHistory` (follows renames) |
| `GET /api/history?limit=` | read | `globalHistory` (default limit `200`) |
| `GET /api/commits/:hash` | read | `commitDetail` (per-file diffs) |
| `GET /api/fleet` | read | `fleet(countsByMachine())` + `repoState()` |
| `GET /api/graph` | read | `buildGraph` over `listMeta` + note bodies (the memory map) |
| `POST /api/reindex` | write | `reindex()`; `200` on success, `500` on failure |
| `POST /api/reflect?project=&apply=1` | write | `reflect`; dry-run unless `apply=1` |
| `POST /api/backfill-provenance?apply=1` | write | `backfillProvenance`; dry-run unless `apply=1` |
| `POST /api/sync` | write | `sync()`; `409` when conflicted, else `200` |
Validation notes:
* `POST /api/notes` and `PUT /api/notes/:id` reject a missing or unknown `type` and a blank `title` with
`400`. Valid types are `procedural`, `semantic`, `episodic`.
* `POST /api/sync` returns HTTP `409` when the sync left a conflict (last-writer-wins surfaces unresolved
paths in the machines view).
## The memory-map data model [#the-memory-map-data-model]
The 3D memory map (`/api/graph` plus the `MemoryMap` client component) renders your whole store as a node
cloud. The graph is built by a pure, deterministic function, `buildGraph`, in `src/lib/graph.ts`, so it
is unit-tested independently of rendering. The route handler feeds it real notes:
### Nodes [#nodes]
There are two node kinds:
* **Hub nodes** (`kind: "hub"`), one per distinct `project`, with id `hub:`. Hubs are the
cluster backbone.
* **Memory nodes** (`kind: "mem"`), one per note, carrying `type`, `title`, `tags`, and a short plain-text
`excerpt` derived from the body (markdown stripped, capped at 200 characters with an ellipsis).
### Edges [#edges]
Edges are undirected and de-duplicated. `buildGraph` adds them from four sources:
1. **Membership:** every memory node links to its project hub. This is the backbone that holds each
cluster together.
2. **Inter-cluster:** each project hub links to the up to **2** other projects it shares the most tags
with (real tag overlap, not an arbitrary chain). Projects with no shared tags get no inter-cluster
edge.
3. **Wikilinks:** best-effort note-to-note edges from `[[wikilink]]` references in bodies, resolved by
exact note id or by title slug.
4. **Shared tags:** notes that share a tag are chained within each tag group. Groups smaller than **2** or
larger than **8** are skipped, so a ubiquitous tag does not collapse the map into a hairball.
The map is a visualization derived from the same notes, projects, and tags. It is not a separate graph
database. Architecture stays file-first: markdown is the source of truth, and the graph is recomputed on
each request.
### Rendering [#rendering]
`src/components/memory-map.tsx` is a client component that fetches `/api/graph` and renders it with
**three.js** (dynamically imported, so it only loads in the browser). The notable rendering choices:
* Project hubs are laid out on a Fibonacci sphere; member notes are scattered around their hub.
* Hubs are sized by member count; node color is by memory type (`semantic`, `procedural`, `episodic`)
with a distinct color for hubs, themed for light and dark.
* Distance fog gives the cloud depth; the scene idles with a gentle spin that pauses while you drag,
hover, or inspect a node.
* Type-filter chips toggle node kinds; clicking a node opens a detail card (excerpt, tags) and navigates
from the card (a memory opens `/notes/:id`; a hub opens `/browse?project=...`).
* If WebGL is unavailable the component shows a fallback message; your memory is still browsable from the
lists.
## Runtime requirements [#runtime-requirements]
The dashboard is one Next.js server (`output: "standalone"` in `next.config.ts`). It reads the
machine-local `~/.anamnesis` store, so any machine that runs a server instance needs these present
(the runtimes are not bundled):
* **git** (the memory directory is a git repo; history, diffs, and commits all shell out to it)
* **uv**, **python** (writes, reindex, reflect, backfill, and sync shell out to the `anamnesis` Python
CLI via `uv run --project ../server`)
* **Node 20** (`better-sqlite3` is pinned to `12.9.0`, the last release that ships Node 20 prebuilt
binaries; `better-sqlite3` is also listed in `serverExternalPackages` so the native module stays out of
the server bundle)
Configuration is entirely by environment variable. From the dashboard README:
| Variable | Default | Purpose |
| ---------------------- | -------------- | ------------------------------------------------------------------- |
| `ANAMNESIS_HOME` | `~/.anamnesis` | Store root (markdown + `index.db`) |
| `ANAMNESIS_MACHINE_ID` | hostname | Machine id stamped on dashboard-authored commits |
| `ANAMNESIS_GIT_REMOTE` | unset | Passed through to `anamnesis sync`; unset means commit locally only |
| `ANAMNESIS_CLI` | (derived) | Full CLI command prefix; overrides the default uv invocation |
| `ANAMNESIS_UV` | `uv` | uv binary for the default `uv run --project ../server anamnesis` |
| `ANAMNESIS_SERVER` | `../server` | Server project dir for the default uv invocation |
`src/lib/config.ts` resolves `ANAMNESIS_HOME` (expanding a leading `~`), then derives `memory/` (the
synced git repo), `local/` (machine-local, not synced), and `index.db` (the derived index) under it.
## The tech stack [#the-tech-stack]
Verified against `package.json`. The dashboard pulls in focused libraries rather than a heavy component
framework:
* **Next.js `16.2.9` (App Router), React `19.2.4`, Node.js runtime.** React Compiler is enabled
(`reactCompiler: true`).
* **Tailwind v4 (CSS-first)** with hand-built shadcn-idiom primitives (`class-variance-authority` +
`tailwind-merge` + Radix `Slot`), the dark "Console" aesthetic.
* **`better-sqlite3` `12.9.0`** for the read-only index connection.
* **three.js (`three` `^0.169.0`)** for the 3D memory map.
* **Focused UI libraries:** `cmdk` (the Cmd-K command palette), `react-markdown` + `remark-gfm` (note
bodies), `lucide-react`, `next-themes`, `sonner` (toasts), `ulid` (ids), `yaml` (the front-matter
codec).
* **Hand-rolled git visuals:** an SVG commit-lane renderer plus a `diff` (jsdiff) based line-diff
renderer, lighter and fully themeable since the sync history is mostly linear.
Tests run with `vitest` (`npm test`). The codec tests pin byte-compatibility with the Python store's
serializer (key order, single-quoted timestamps, unindented tag sequences), so notes written here
round-trip through `yaml.safe_load` without timestamps being coerced to `datetime`.
## The app shells: web, PWA, Electron [#the-app-shells-web-pwa-electron]
The same standalone server is delivered three ways. All three read the machine-local `~/.anamnesis` store
and need `git`, `uv`, `python`, and Node 20 on any machine that runs a server instance.
### 1. Web (dev or local) [#1-web-dev-or-local]
```bash
npm install
npm run dev # http://localhost:3000
# production:
npm run build && npm run start
```
### 2. PWA over the tailnet [#2-pwa-over-the-tailnet]
Run one always-on instance on a hub machine and publish it tailnet-only. From `deploy/README.md`, the
shape is: build the standalone server, copy the example env to
`~/.config/anamnesis/dashboard.env`, install and enable the `anamnesis-dashboard` systemd user service,
then expose it on the tailnet:
```bash
systemctl --user enable --now anamnesis-dashboard
loginctl enable-linger "$USER" # run without an active login
tailscale serve --bg 3000 # publish tailnet-only over HTTPS
```
Then install it to a home screen: on iPhone open the tailnet URL in Safari and use Share, Add to Home
Screen; on a laptop open it in Chrome or Edge and use Install app. The service worker is registered in
production only (`src/components/sw-register.tsx`, best-effort).
The standalone entry (`scripts/serve.cjs`) forces `HOSTNAME=127.0.0.1`, so the server binds loopback
only and is never on your LAN or the public internet. The only off-machine access is `tailscale serve`,
which is tailnet-only and authenticated by your tailnet. Do not use `tailscale funnel`, which would
expose it publicly.
### 3. Desktop app (Electron) [#3-desktop-app-electron]
```bash
npm run desktop:build # Linux: dist/*.AppImage and dist/*.deb
```
The Electron app runs its own local standalone server against this machine's store, so it works offline
for reads. The build pins **Electron 34** (Node 20, V8 13.2) so the Node-20 `better-sqlite3` native
module compiles for Electron's ABI, then restores the Node-ABI build so the web flow keeps working.
`electron-builder.yml` ships plain files (no asar) because the standalone server is spawned as a child
Node process that loads a native `.node` from `.next/standalone/node_modules`, and asar packing makes
that path fragile.
Cross-platform: Linux is built and verified locally. macOS (dmg) and Windows (nsis) are produced by the
`desktop build` GitHub Actions workflow (`.github/workflows/desktop.yml`) and are shipped unsigned and
unverified until that hardware is available.
## Related pages [#related-pages]
* [Architecture overview](./architecture)
* [Data model](./data-model)
* [Recall and search](./recall)
* [Sync over git](./sync)
* [Reflection](./reflection)
# Notes, the markdown format, and the store (/docs/internals/data-model)
This is the canonical reference for how a single memory note is represented in Anamnesis: the in-memory `Memory` record, the markdown file it serializes to, the directory it lives in, and the SQLite index derived from it. Everything here is grounded in `server/src/anamnesis/store.py`, `server/src/anamnesis/inject.py`, and `server/src/anamnesis/config.py`.
The governing rule, repeated throughout: **markdown files are the source of truth, and the SQLite index is fully derived.** The index can always be deleted and rebuilt from the markdown with no loss.
## The `Memory` record [#the-memory-record]
A note is modeled in code as the `Memory` dataclass (`store.py`). Every field, with its default:
| Field | Type | Default | Notes |
| -------------- | ----------- | ------------ | ------------------------------------------------------------------------------ |
| `id` | `str` | (required) | A ULID string, generated on `write` via `str(ULID())`. |
| `type` | `str` | (required) | One of `procedural`, `semantic`, `episodic`. |
| `title` | `str` | (required) | Short human-readable label. |
| `body` | `str` | (required) | The markdown body (everything after the frontmatter). |
| `project` | `str` | `"global"` | Project key (see [project resolution](#project-resolution)). |
| `machine_id` | `str` | `"unknown"` | The machine that authored the note. |
| `scope` | `str` | `"portable"` | `portable` or `machine-local` (see [scope](#scope-portable-vs-machine-local)). |
| `tags` | `list[str]` | `[]` | Free-form tags. |
| `created_at` | `str` | `""` | UTC ISO-8601, seconds precision. |
| `updated_at` | `str` | `""` | UTC ISO-8601, seconds precision. |
| `prov_source` | `str` | `"human"` | One of `human`, `session-end`, `reflection`, `import`. |
| `prov_model` | `str` | `""` | Model id, when a model produced the note. |
| `prov_session` | `str` | `""` | Originating session id, when known. |
| `confidence` | `float` | `1.0` | Used to break recency ties during injection. |
| `supersedes` | `str` | `""` | Id of a note this one replaces. |
Two type aliases document the constrained string fields: `MemoryType = str` (`"procedural" | "semantic" | "episodic"`) and `Scope = str` (`"portable" | "machine-local"`).
Timestamps come from `_utcnow()`, which returns `datetime.now(UTC).isoformat(timespec="seconds")`, so the format is for example `2026-06-24T18:33:07+00:00`.
## The markdown format [#the-markdown-format]
Each note is one markdown file: a YAML frontmatter block delimited by `---\n` (the `_FM_DELIM` constant), followed by the body. `_serialize(mem)` writes the frontmatter with `yaml.safe_dump(meta, sort_keys=False, allow_unicode=True)`, so the **key order is fixed by insertion order**, not alphabetical.
### Frontmatter fields, in write order [#frontmatter-fields-in-write-order]
`_serialize` builds the metadata dict in exactly this order:
1. `id`
2. `type`
3. `title`
4. `project`
5. `machine_id`
6. `scope`
7. `prov_source`
8. `confidence`
9. `prov_model` (only if non-empty)
10. `prov_session` (only if non-empty)
11. `supersedes` (only if non-empty)
12. `created_at`
13. `updated_at`
14. `tags`
`prov_model`, `prov_session`, and `supersedes` are **omitted entirely when empty**, so a hand-written or human-sourced note typically has none of them. `created_at`, `updated_at`, and `tags` are always written, and always come after the optional provenance keys.
A representative file (`~/.anamnesis/memory/semantic/.md`) looks like:
```markdown
---
id: 01J9Z8YPM7Q3X2V4WT6B5N0KGD
type: semantic
title: Dashboard grid minmax convention
project: github.com/oscardvs/anamnesis
machine_id: thinkpad
scope: portable
prov_source: human
confidence: 1.0
created_at: '2026-06-24T18:33:07+00:00'
updated_at: '2026-06-24T18:33:07+00:00'
tags:
- dashboard
- css
---
Wrap every Tailwind grid-cols track in minmax(0, ...) so wide content does not blow out the layout.
```
A reflection-derived note that replaces an earlier one adds the optional keys between `confidence` and `created_at`:
```markdown
---
id: 01J9ZB0C4F8H2K6M3P9R7S5T1W
type: procedural
title: Run reflect safely
project: github.com/oscardvs/anamnesis
machine_id: thinkpad
scope: portable
prov_source: reflection
confidence: 0.8
prov_model: claude-opus-4-8
prov_session: 3bf75f14-4c3f
supersedes: 01J9Z8YPM7Q3X2V4WT6B5N0KGD
created_at: '2026-06-24T19:01:55+00:00'
updated_at: '2026-06-24T19:01:55+00:00'
tags:
- reflection
---
Commit immediately after reflect, or a concurrent sync can wipe the output.
```
### Round-tripping (serialize and deserialize) [#round-tripping-serialize-and-deserialize]
`_serialize` appends one trailing newline to the body (`f"{_FM_DELIM}{front}{_FM_DELIM}{mem.body}\n"`). `_deserialize` reverses this exactly:
* It requires the text to start with `---\n`, otherwise it raises `ValueError("memory file missing YAML front-matter")`.
* It splits on the closing `\n---\n` delimiter (`text[len(_FM_DELIM):].partition("\n" + _FM_DELIM)`).
* It strips the single trailing newline that `_serialize` added (`if body.endswith("\n"): body = body[:-1]`).
* Missing optional keys fall back to the same defaults as the dataclass: `project="global"`, `machine_id="unknown"`, `scope="portable"`, `tags=[]`, `prov_source="human"`, `confidence=1.0`, and empty strings for `prov_model`, `prov_session`, `supersedes`. `confidence` is coerced with `float(...)`.
Because deserialization tolerates missing optional keys and supplies defaults, you can hand-author a minimal note with just `id`, `type`, and `title` in the frontmatter and it will index correctly. The full set of keys is what the writer produces, not what the reader requires.
## Note types [#note-types]
There are exactly three note types, enforced at the SQLite layer by a `CHECK (type IN ('procedural','semantic','episodic'))` constraint on the `memories` table:
* **`procedural`** - how to do something (steps, commands, conventions). Durable.
* **`semantic`** - facts and stable knowledge about the world or the project. Durable.
* **`episodic`** - what happened in a session ("what I last did"). Treated as transient continuity.
The distinction matters at injection time. In `inject.py`, `_DURABLE = ("procedural", "semantic")` are the note types that fill the main injection budget, while episodic notes are capped by `_MAX_EPISODIC = 2` and serve only as a short "what I last did" continuity thread. Once an episodic note has been folded into durable notes by reflection, it is tagged `reflected` and dropped from injection (`"reflected" not in m.tags`), since its content now lives in the durable notes.
## Scope: portable vs machine-local [#scope-portable-vs-machine-local]
`scope` answers one question: does this note travel to your other machines? There are two values:
* **`portable`** (the default) - the note belongs to the synced corpus.
* **`machine-local`** - the note stays on the machine that created it.
### The tree is authoritative for scope [#the-tree-is-authoritative-for-scope]
Scope is not trusted from the frontmatter when rebuilding the index. It is determined by **which directory tree the file is in**. `MemoryStore._dir_for_scope(scope)` returns `self.local_dir` for `machine-local` and `self.memory_dir` for everything else:
```python
def _dir_for_scope(self, scope: Scope) -> Path:
return self.local_dir if scope == "machine-local" else self.memory_dir
```
On `reindex`, the store walks both trees and **overwrites the in-memory `scope` from the tree it found the file in**, regardless of what the frontmatter said:
```python
for base, scope in ((self.memory_dir, "portable"), (self.local_dir, "machine-local")):
for path in sorted(base.rglob("*.md")):
mem = _deserialize(path.read_text(encoding="utf-8"))
mem.scope = scope # tree wins
self._index(mem, str(path.relative_to(base)))
```
Moving a file between `memory/` and `local/` changes its scope on the next reindex, even if the frontmatter still says otherwise. The directory is the authority. The frontmatter `scope` value is a convenience for readers and for the freshly-written file; the reindex path reconciles it to the tree.
`get` mirrors this: it reads the stored `scope` from the index, picks the base directory with `_dir_for_scope`, and reads the body from `base / body_path`. The `body_path` stored in the index is relative to the scope's base directory, not to the store root.
## Store layout under `~/.anamnesis` [#store-layout-under-anamnesis]
The store root defaults to `~/.anamnesis`. `config.resolve_home()` resolves it from `ANAMNESIS_HOME` if set, otherwise `Path.home() / ".anamnesis"`.
```
~/.anamnesis/
memory/ # SOURCE OF TRUTH, portable, git-synced
procedural/.md
semantic/.md
episodic/.md
local/ # SOURCE OF TRUTH, machine-local, NEVER synced
procedural/.md
semantic/.md
episodic/.md
index.db # DERIVED, SQLite (WAL + FTS5), rebuildable
config.json # machine-local config, never synced
```
`MemoryStore.__init__` wires these paths and creates both note trees if missing:
```python
self.memory_dir = self.root / "memory"
self.local_dir = self.root / "local"
self.db_path = self.root / "index.db"
self.memory_dir.mkdir(parents=True, exist_ok=True)
self.local_dir.mkdir(parents=True, exist_ok=True)
```
Within each tree, the relative path of a note is `/.md` (set on write as `rel_path = f"{mem.type}/{mem.id}.md"`). So a portable procedural note lives at `~/.anamnesis/memory/procedural/.md` and a machine-local one at `~/.anamnesis/local/procedural/.md`.
### `config.json` [#configjson]
`config.json` is **machine-local and never synced**. It lives at `/config.json`, deliberately outside the synced `memory/` tree, because the git remote URL differs per machine. It is written by `anamnesis init` (`onboard.write_store_config`) and holds two keys:
```json
{
"machine_id": "thinkpad",
"remote": "git@example.com:you/anamnesis-memory.git"
}
```
`remote` is omitted when you run local-only. `config.py` reads these via `_store_config()` (a best-effort JSON read that returns `{}` on any `OSError`/`ValueError` so resolution never crashes on a bad file), and exposes:
* `resolve_machine_id()` - `ANAMNESIS_MACHINE_ID`, else `config.json`'s `machine_id`, else `socket.gethostname()`, else `"unknown"`.
* `resolve_remote()` - `ANAMNESIS_GIT_REMOTE`, else `config.json`'s `remote`, else `None`. The `config.json` fallback is what lets the MCP server (launched from `.mcp.json` without inline env) and the dashboard find the remote so an in-session `memory_sync` can push rather than only commit locally.
Never sync the raw `index.db` over a cloud folder. Sync the markdown under `memory/` via git and rebuild the index locally on each machine. `config.json` and the entire `local/` tree are intentionally outside the synced corpus.
## The SQLite index [#the-sqlite-index]
`index.db` is a derived cache. It is opened with `sqlite3.connect(self.db_path, check_same_thread=False)` because the FastMCP server runs sync tools in a worker threadpool and shares the connection across threads. Two PRAGMAs make that safe:
```python
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA busy_timeout=5000")
```
* **WAL mode** lets concurrent Claude Code sessions read while one writes, avoiding the file-locking conflicts a rollback journal would cause.
* **`busy_timeout=5000`** (5 seconds) makes a blocked writer wait and retry rather than fail immediately under contention.
### Schema [#schema]
The schema (`_SCHEMA` in `store.py`) is three objects: a structured `memories` table, a `memory_tags` join table, and a `memories_fts` FTS5 virtual table.
```sql
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
type TEXT NOT NULL CHECK (type IN ('procedural','semantic','episodic')),
title TEXT NOT NULL,
body_path TEXT NOT NULL,
project TEXT NOT NULL DEFAULT 'global',
machine_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'portable' CHECK (scope IN ('portable','machine-local')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
prov_source TEXT NOT NULL DEFAULT 'human'
CHECK (prov_source IN ('human','session-end','reflection','import')),
prov_model TEXT,
prov_session TEXT,
confidence REAL NOT NULL DEFAULT 1.0,
supersedes TEXT
);
CREATE INDEX IF NOT EXISTS idx_mem_scope ON memories(project, type, scope);
CREATE INDEX IF NOT EXISTS idx_mem_recency ON memories(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_mem_prov ON memories(prov_source);
CREATE TABLE IF NOT EXISTS memory_tags (
memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (memory_id, tag)
);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, title, body, tags, tokenize='porter unicode61'
);
```
Note what the `memories` table does and does not store. It holds all the structured metadata plus `body_path` (the relative path to the markdown file), but **not the body itself**. The body lives only in the markdown file and, for search, in the FTS5 table.
The `memories_fts` virtual table indexes `title`, `body`, and `tags` (with `id` carried `UNINDEXED` so it can be selected back). The tokenizer is `porter unicode61`: `unicode61` provides Unicode-aware tokenization and diacritic folding, and `porter` adds English stemming so "running" matches "run". Tags are stored in FTS as a single space-joined string (`" ".join(mem.tags)`).
### How a row is written [#how-a-row-is-written]
`_index(mem, rel_path)` does an idempotent upsert for one note:
1. `INSERT OR REPLACE INTO memories (...)` with all structured columns. Empty `prov_model`, `prov_session`, and `supersedes` are stored as SQL `NULL` (`mem.prov_model or None`, etc.).
2. `DELETE FROM memory_tags WHERE memory_id = ?` then re-insert the current tags.
3. `DELETE FROM memories_fts WHERE id = ?` then re-insert the FTS row.
This delete-then-insert pattern keeps `memory_tags` and `memories_fts` consistent on rewrites. `write` and `put` both call `_index` and then `self._db.commit()`. On any indexing failure they unlink the just-written markdown file before re-raising, so a half-written note never lingers on disk:
```python
abs_path.write_text(_serialize(mem), encoding="utf-8")
try:
self._index(mem, rel_path)
except Exception:
abs_path.unlink(missing_ok=True)
raise
self._db.commit()
```
`write` generates the id and timestamps for you; `put` takes a fully-formed `Memory` (caller-supplied id and timestamps) and upserts by id, which the native-memory importer uses to make re-imports overwrite in place rather than duplicate.
### Supersession [#supersession]
A note with a non-empty `supersedes` pointing at another note's id hides that older note from recall. `superseded_ids()` collects every non-empty `supersedes` value, and both `search` and the injection selector exclude those ids:
```sql
AND m.id NOT IN
(SELECT supersedes FROM memories WHERE supersedes IS NOT NULL AND supersedes <> '')
```
Superseded notes are hidden from recall and injection but remain on disk and browsable via `list`.
### Schema version and rebuild from markdown [#schema-version-and-rebuild-from-markdown]
`_SCHEMA_VERSION = 1` is stored in SQLite's `PRAGMA user_version`. On open, the store compares the DB's recorded version against the constant:
Because the index is fully derived, a version bump needs no hand-written migration: the store drops `memories`, `memory_tags`, and `memories_fts`, recreates them from `_SCHEMA`, sets `user_version`, and calls `reindex()`.
`reindex()` is the canonical "rebuild from source of truth" path and can be run any time (it returns the number of notes indexed):
1. `DELETE FROM memories`, `DELETE FROM memory_tags`, `DELETE FROM memories_fts`.
2. Walk `memory/` as `portable` and `local/` as `machine-local`, in that order, over `sorted(base.rglob("*.md"))`.
3. Deserialize each file, force `scope` from the tree, and `_index` it under its path relative to that tree's base.
4. Commit.
If `index.db` is ever lost or corrupted, deleting it and re-opening the store (or running a reindex) reconstructs it entirely from the markdown. No memory is lost, because the markdown is the source of truth.
## Project resolution [#project-resolution]
Notes are scoped to a `project` key. The default is `"global"`, and global notes are always injected in full. For a working directory, the project key is resolved by `inject.resolve_project_key(cwd)` in a fixed precedence order:
1. **`.anamnesis/project` marker.** `_read_marker(cwd)` searches from `cwd` upward through its parents and returns the first non-empty line of the nearest `.anamnesis/project` file. The search **stops below the home directory and the filesystem root**, so a stray marker at `$HOME` cannot hijack every project. This is the explicit, cross-machine-stable override, useful for non-git workspaces where a subdirectory would otherwise resolve to a bare basename.
2. **Normalized git `origin` remote.** Runs `git -C remote get-url origin`; on success the URL is normalized by `_normalize_remote`: strip the scheme (`https://`, `ssh://`, `git://`), strip a leading `user@`, convert the scp form `host:path` to `host/path`, strip a trailing `.git`, then strip trailing slashes and lowercase. So `git@github.com:oscardvs/anamnesis.git` and `https://github.com/oscardvs/anamnesis` both normalize to `github.com/oscardvs/anamnesis`.
3. **Repo-root directory name.** If there is no `origin`, `git -C rev-parse --show-toplevel` gives the repo root, and its directory name is used, lowercased.
4. **cwd basename.** Outside any git repo, the basename of `cwd` lowercased, or `"global"` if that is empty.
Normalizing the remote is what makes a project key stable across machines: the same repo cloned over SSH on one machine and HTTPS on another resolves to the same key, so its notes group together. The `inject.py` docstring flags the fuller cross-machine identity work as a deliberate follow-up isolated to this one function.
## Related pages [#related-pages]
* [Recall and search](./recall) - how the FTS5 BM25 query is built and ranked.
* [Capture and injection](./capture-and-injection) - how notes are selected and rendered at SessionStart.
* [Sync](./sync) - how the `memory/` tree travels over git on a Tailscale mesh.
* [Architecture overview](./architecture) - the file-first design in one place.
# Design decisions (/docs/internals/design-decisions)
This page is the canonical record of the architecture decisions Anamnesis is built on, written in an ADR
(Architecture Decision Record) style: for each decision, what we decided, why, and the specific condition
under which we would revisit it. These are the choices stated in `CLAUDE.md` under "Architecture decisions
(do not relitigate without evidence)," reconciled with how they actually show up in `server/src/anamnesis/`.
The through-line behind every decision is one sentence: **markdown is the source of truth, and everything
else is either derived from it or moves it between your machines.** Each decision below is a consequence of
holding that line and refusing to add machinery that does not earn its keep.
The bar for changing any of these is evidence, not preference. `CLAUDE.md` frames them as decisions not to
relitigate "without evidence," and each one below names the concrete signal that would constitute that
evidence. Where the project plans to grow, it grows by adding behind an existing seam (for example the
`SyncBackend` protocol), not by rewriting the core.
## The decisions at a glance [#the-decisions-at-a-glance]
| Decision | Status | Revisit when |
| -------------------------------------------- | ------------ | ----------------------------------------------------------------------- |
| File-first, not a knowledge graph | Adopted (v1) | Recall quality on real usage demonstrably suffers |
| SQLite is first-class, with WAL | Adopted | (WAL stays; vectors are the conditional part) |
| Add `sqlite-vec` vectors | Deferred | Keyword search measurably fails on paraphrase queries |
| Never sync the raw DB; sync markdown via git | Adopted | (Load-bearing; not expected to change) |
| Git-as-sync now | Adopted (v0) | Multi-user lands (then Turso/libSQL), or true multi-writer (then CRDTs) |
| Reflection/compression model is swappable | Adopted | (Never hardcode it; the frontier moves weekly) |
## Decision 1: File-first, not a knowledge graph [#decision-1-file-first-not-a-knowledge-graph]
**Decision.** Memory is plain markdown files, one note per file, and they are the single source of truth.
Anamnesis does **not** build a knowledge-graph memory or a custom context compressor for v1.
**Why.** The publicly stated rationale in `CLAUDE.md`: "the research shows files + keyword search are
competitive and graphs add cost without proportional benefit." A graph layer is real, permanent complexity
(a schema, an ingestion pipeline, a query language, its own corruption and migration story) added on top of
something users already understand. Plain files keep the whole system inspectable and recoverable: you can
read a note in any editor, `git diff` it, review it in a pull request, and hand-edit it without going through
Anamnesis at all.
This shows up concretely in the store. A note is a YAML front-matter block followed by a markdown body,
serialized by `_serialize` and parsed back by `_deserialize` in `server/src/anamnesis/store.py`. Reads go to
the file, not the database: `MemoryStore.get` looks up the file path in the index and then reads and
deserializes the markdown, so the file stays canonical.
```python
# server/src/anamnesis/store.py (MemoryStore.get)
def get(self, memory_id: str) -> Memory:
"""Read a memory back from its markdown file (the source of truth)."""
row = self._db.execute(
"SELECT body_path, scope FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if row is None:
raise KeyError(memory_id)
base = self._dir_for_scope(row["scope"])
text = (base / row["body_path"]).read_text(encoding="utf-8")
return _deserialize(text)
```
Note the column the database stores: `body_path`, not the body itself. The schema (`_SCHEMA` in `store.py`)
indexes structured metadata and a derived FTS copy of the text, but the authoritative body lives only on
disk. The `memories` table even encodes `body_path TEXT NOT NULL`, making "the file is where the body lives"
a structural fact, not a convention.
The `General` conventions in `CLAUDE.md` restate the same posture as a rule: "Don't introduce a database
server, a graph DB, or a vector DB 'just in case' - stay local-first and simple."
**When we would revisit.** `CLAUDE.md` is explicit: "Revisit only if recall quality on real usage demonstrably
suffers." That is a measurable bar, not a vibe. The instrument that would measure it already exists:
`server/src/anamnesis/eval.py` runs `recall_at_k(store, cases, ks=(1, 3, 5, 8))` and reports Recall\@k plus MRR
over a set of paraphrase queries. Files-plus-keyword-search stays until that number, on real usage, drops far
enough to justify the cost of a graph.
"Files + keyword search are competitive" is a claim about the current generation of techniques, recorded in
the local-only `docs/research/model-landscape.md` and kept fresh. The decision is held against evidence, so
the right response to "should we add a graph" is to run the eval, not to argue from intuition.
## Decision 2: SQLite is first-class, with WAL [#decision-2-sqlite-is-first-class-with-wal]
**Decision.** SQLite is a deliberate, first-class part of the design, not an afterthought. It provides
FTS5-backed keyword and BM25 recall, and the connection runs in WAL mode so multiple concurrent Claude Code
sessions do not hit file-locking conflicts.
**Why.** `CLAUDE.md`: "FTS5 for keyword/BM25 recall; use WAL mode so multiple concurrent Claude Code sessions
don't hit file-locking conflicts." In practice, several Claude Code sessions on one machine can all touch the
same store at once (mid-session MCP queries, a background sync hook, the dashboard). The classic SQLite default
(rollback journal) serializes readers against a writer hard enough to produce lock errors under that pattern.
WAL lets readers proceed concurrently with a writer, and a busy timeout absorbs the brief windows where a lock
genuinely must be waited for.
The store opens its connection with exactly these pragmas, and the comment in `store.py` spells out why the
connection is even shared across threads:
```python
# server/src/anamnesis/store.py (MemoryStore.__init__)
# check_same_thread=False: the FastMCP server runs sync tools in a worker
# threadpool, so the connection is shared across threads. SQLite's
# serialized threadsafety + WAL + busy_timeout (below) keep that safe.
self._db = sqlite3.connect(self.db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA busy_timeout=5000")
```
So the three concrete settings are: `journal_mode=WAL`, `busy_timeout=5000` (5 seconds), and
`check_same_thread=False`. The full-text side is an FTS5 virtual table over the searchable fields:
```sql
-- _SCHEMA in server/src/anamnesis/store.py
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, title, body, tags, tokenize='porter unicode61'
);
```
Search ranks with BM25 (`ORDER BY bm25(memories_fts), m.updated_at DESC`) and the default result budget is
`k=8`. The recall mechanics, including the deliberate OR-of-tokens query that recovered recall from about 0%
to about 94% on the eval set, are covered in depth in [Recall](./recall).
A corollary decision is what is **deferred**: vectors. `CLAUDE.md` says "Add `sqlite-vec` vectors only when
keyword search measurably fails on paraphrase queries." Today nothing in the package imports `sqlite-vec`; it
exists only as an optional `vectors` packaging extra and is not wired into `store.py`.
**When we would revisit.** Add `sqlite-vec` embeddings only when the eval harness shows keyword search
measurably failing on paraphrase queries. The threshold is empirical: `recall_at_k` with `ks=(1, 3, 5, 8)`
over LLM-generated paraphrase cases (`build_eval_candidates`) is the gate. Until BM25 demonstrably falls short
there, the simpler FTS5 path stands and the vector dependency stays out of the default install.
## Decision 3: Never sync the raw database; sync markdown via git [#decision-3-never-sync-the-raw-database-sync-markdown-via-git]
**Decision.** The raw SQLite database file is never synced over cloud folders or any other file-mirroring
mechanism. Only markdown travels (via git); each machine rebuilds its own index locally.
**Why.** `CLAUDE.md`: "Never sync the raw DB file over cloud folders (the claude-brain corruption lesson).
Sync markdown via git; rebuild the index locally on each machine." A SQLite database is binary pages plus a
write-ahead log, not a single value that merges cleanly. Mirroring that live file between machines through a
folder-sync tool interleaves partial writes and corrupts it. The project refers to this repeatedly as the
"claude-brain corruption lesson," and it is the reason the rule is absolute rather than a preference.
The guarantee is enforced by **topology**, not by a `.gitignore` entry, which is stronger. The git repository
is `memory/`, while the database lives one level up at the store root, physically outside the git working
tree, so git literally never sees it:
```text
~/.anamnesis/ # store root (MemoryStore.root)
memory/ # the git repo (MemoryStore.memory_dir) -- SYNCED
/.md # one markdown file per note, source of truth
local/ # machine-local notes (MemoryStore.local_dir) -- NEVER SYNCED
/.md
index.db # derived SQLite index (WAL + FTS5) -- NEVER SYNCED
```
Because the index is fully derived, it is disposable: `MemoryStore.reindex` walks both trees (`memory/` as
`portable`, `local/` as `machine-local`), reads every `*.md`, and rebuilds the FTS5 tables from scratch. The
sync callers run that rebuild right after every pull, so search reflects the markdown that just arrived. Even
schema upgrades exploit this: the migration path in `MemoryStore.__init__` drops the derived tables, recreates
them at the current `_SCHEMA_VERSION` (currently `1`, tracked in `PRAGMA user_version`), and reindexes, so
there is never a risky in-place migration of user data in the database.
Do not put `index.db` inside `memory/`, and do not configure any folder-sync tool (Dropbox, iCloud, a naive
git of the binary) to mirror it between machines. The index is derived state. Syncing it is the exact failure
mode this design exists to avoid. If an index is ever damaged or stale, delete it and reindex; the files are
authoritative and recovery is total.
**When we would revisit.** This one is load-bearing and not expected to change. Even the planned sync evolution
(Decision 5) keeps it intact: a Turso/libSQL embedded-replica path replicates a managed database under its own
consistency protocol; it does not mean mirroring `~/.anamnesis/index.db` over a cloud folder. The markdown stays
the source of truth regardless of what carries it. See [Sync](./sync) for the full mechanics and the durability
tests that guard this.
## Decision 4: Git-as-sync now (and the planned evolution) [#decision-4-git-as-sync-now-and-the-planned-evolution]
**Decision.** The v0 sync layer is plain git over a private Tailscale mesh: commit local changes, integrate
the remote with `pull --rebase`, push. The deliberate plan for the future is staged: Turso/libSQL embedded
replicas when multi-user lands, and CRDTs only if true concurrent multi-writer editing ever appears.
**Why.** `CLAUDE.md`: "Sync evolution: git-as-sync for the MVP -> Turso/libSQL embedded replicas when
multi-user lands -> CRDTs only if true concurrent multi-writer editing appears." Git is simple, already
battle-tested, version-controlled, human-readable, and good enough for a single user's own fleet of machines.
For one person syncing one machine at a time, there is no concurrent-multi-writer problem to solve, so the
heavier machinery would be cost without benefit, the same logic as Decision 1.
The implementation is `GitSyncBackend` in `server/src/anamnesis/sync.py`, and crucially it sits behind a
`Protocol` so the mechanism can evolve without touching the server, the CLI, or the dashboard:
```python
# server/src/anamnesis/sync.py
class SyncBackend(Protocol):
"""Pluggable sync mechanism (git-over-Tailscale today; P2P/libSQL later)."""
def init(self) -> None: ...
def sync(self) -> SyncResult: ...
def state(self) -> SyncState: ...
```
That seam is the decision made concrete. Every consumer depends only on `SyncBackend` and the `SyncResult` /
`SyncState` shapes, so a future Turso/libSQL backend or a direct peer-to-peer backend can slot in as another
implementation without a rewrite. The branch is always `main` (the module constant `_BRANCH = "main"`), and
integration is rebase-only, so the shared history stays linear.
The v0 conflict policy is "surface, never silently drop": if a rebase cannot apply cleanly, the backend runs
`git rebase --abort` (your local edits stay exactly as they are on disk) and returns a `SyncResult` with
`conflicted=True`, pushing nothing. That is a normal return value, not an exception; a human or the dashboard
reconciles the two versions, then syncs again.
**When we would revisit.** Each arrow above is gated on a real condition, not a calendar:
* Move past pure git **when multi-user lands** (the trigger for a Turso/libSQL embedded-replica backend).
* Reach for **CRDTs only if true concurrent multi-writer editing appears**, which the current single-user,
one-machine-at-a-time pattern does not demand.
The guiding rule from `CLAUDE.md`'s conventions still applies: stay local-first and simple, and do not add a
database server "just in case." The current design earns its keep; anything heavier waits for evidence it is
needed. Full sync behavior, the bare-repo Tailscale topology, and the durability tests are in
[Sync](./sync) and the [Across machines](../guide/across-machines) guide.
## Decision 5: The reflection/compression model is swappable [#decision-5-the-reflectioncompression-model-is-swappable]
**Decision.** The model used for session-end summarization and for reflecting episodic notes into durable ones
is a swappable configuration value. It is never hardcoded; provider, model, and endpoint all come from
configuration, and the default path needs no API key.
**Why.** `CLAUDE.md`: "The reflection/compression model is swappable. Never hardcode it - the price/quality
frontier moves weekly (see `docs/research/model-landscape.md`, local-only)." Pinning a specific model into the
code would mean shipping a new release every time the frontier moves. Instead the choice is a runtime config
knob, and the project keeps a living record of current model/technique tradeoffs locally.
The module docstring of `server/src/anamnesis/llm_summarizer.py` states the contract directly: it "Sends a
redacted, size-bounded transcript to any OpenAI-compatible endpoint and parses a strict-JSON summary.
Provider, model, and URL come entirely from config; nothing about any provider is hardcoded. Any failure falls
back to the deterministic heuristic so capture never breaks session teardown."
Configuration is read from the environment by `resolve_reflection_config`, which is machine-local and never
synced (it is reflection config, not memory):
| Variable | Default | Purpose |
| --------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `ANAMNESIS_REFLECTION_PROVIDER` | `heuristic` | provider label; anything other than a complete config falls back to the deterministic heuristic |
| `ANAMNESIS_REFLECTION_MODEL` | `""` | model id sent in the request payload |
| `ANAMNESIS_REFLECTION_BASE_URL` | `""` | OpenAI-compatible base URL (`/chat/completions` is appended) |
| `ANAMNESIS_REFLECTION_API_KEY` | (falls back to `DEEPSEEK_API_KEY`, then `OPENAI_API_KEY`) | bearer token |
| `ANAMNESIS_REFLECTION_TIMEOUT` | `30` (seconds) | HTTP timeout |
| `ANAMNESIS_REFLECTION_MAX_TOKENS` | `120000` | drives the transcript char window (`max_tokens * 4`) |
`make_llm_summarizer` builds an `LLMSummarizer` only when `model`, `base_url`, and `api_key` are all present;
otherwise it returns the deterministic `HeuristicSummarizer`. So the model is not just swappable, it is
optional, and its absence degrades gracefully rather than breaking teardown.
The HTTP client is a thin stdlib `urllib` POST to `/chat/completions` with `temperature=0.2` and
`stream=False`, so the base hook install needs no extra dependency. Provenance is recorded on the resulting
note: a model-summarized note carries `prov_model = "/"`, so you can always tell which model
produced a given memory. How distilling episodic notes into durable ones works end to end is in
[Reflection](./reflection).
**When we would revisit.** There is no fixed model to revisit; the design's entire point is that the choice is
a config value that moves with the frontier. The living evaluation lives in `docs/research/model-landscape.md`
(local-only), and the `CLAUDE.md` "Staying current" section makes keeping it fresh part of the workflow. Pin a
model only at the edges (your own env), never in the code.
Because the reflection config is machine-local environment configuration, it is read fresh on each invocation
and is never written into the synced `memory/` tree. Set it per machine. The provenance on each note records
which model actually produced it, so you can audit and re-reflect later if you change models.
## How these decisions reinforce each other [#how-these-decisions-reinforce-each-other]
The five decisions are not independent; they compose into one coherent stance.
* File-first (1) is what makes "never sync the DB" (3) safe: there is exactly one authoritative copy of memory
(the files), so the index can be thrown away and rebuilt freely.
* SQLite-with-WAL (2) is the fast, derived read path over those files, and keeping it derived is what lets it
be local and disposable rather than something that has to be synced.
* Git-as-sync (4) moves only the markdown, which is only tractable because the source of truth is plain text
with clean diffs and a clear conflict story.
* The swappable model (5) keeps the one genuinely fast-moving dependency (the LLM) out of the code and behind
config, so the stable core does not churn when the model frontier does.
Each decision also names its own escape hatch: the recall eval for files-vs-graph and for keyword-vs-vectors,
the `SyncBackend` protocol for sync evolution, and the reflection config plus `model-landscape.md` for the
model. Nothing here is permanent by assertion; it is permanent until the named evidence shows up.
## Related pages [#related-pages]
* [Architecture overview](./architecture)
* [Data model and note format](./data-model)
* [Recall: FTS5 and BM25](./recall)
* [Cross-machine sync over git and Tailscale](./sync)
* [Reflection](./reflection)
* [The MCP server](./mcp-server)
# The MCP server (/docs/internals/mcp-server)
Anamnesis exposes the memory store to Claude Code through a [Model Context Protocol](https://modelcontextprotocol.io) server built with [FastMCP](https://github.com/jlowin/fastmcp). The server is deliberately thin: it maps MCP tool calls onto the store and the git sync backend, and nothing more. Markdown stays the source of truth, the SQLite index stays derived, and the server holds no state of its own beyond the bound store, sync backend, and machine id.
This page is the reference for the server. Every field name, default, and threshold below is taken from `server/src/anamnesis/server.py`, its tests in `server/tests/test_server.py`, and the supporting modules `store.py`, `sync.py`, and `config.py`.
## What the server is [#what-the-server-is]
The server module defines one FastMCP instance named `anamnesis` with five tools. The dependency points one way only: `server.py` imports the store and sync layers, but neither of those imports FastMCP. That keeps the engine (and the hook-driven CLI hot path) usable without the optional `mcp` extra installed.
```python
mcp: FastMCP = FastMCP(name="anamnesis")
```
The five tools split into two groups:
| Tool | Mutates store | Annotation | Auto-approvable |
| --------------- | ------------- | ------------------------------------------- | --------------- |
| `memory_search` | no | `readOnlyHint=True, openWorldHint=False` | yes |
| `memory_list` | no | `readOnlyHint=True, openWorldHint=False` | yes |
| `memory_status` | no | `readOnlyHint=True, openWorldHint=False` | yes |
| `memory_write` | yes | `readOnlyHint=False, destructiveHint=False` | no (confirm) |
| `memory_sync` | yes | `readOnlyHint=False, openWorldHint=True` | no (confirm) |
The three read-only query tools carry the shared `_READ_ONLY` annotation:
```python
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
```
A client that honors annotations (Claude Code does) can auto-approve the read-only tools and prompt for confirmation on the two writers. The test `test_read_only_query_tools_are_annotated_read_only` asserts `readOnlyHint is True` for `memory_search`, `memory_list`, and `memory_status`, and `readOnlyHint is False` for `memory_write` and `memory_sync`. The test `test_build_server_registers_the_five_tools` asserts the registered set is exactly those five names.
`openWorldHint=False` on the read-only tools says they operate over a closed, local world (your store). `memory_sync` sets `openWorldHint=True` because it talks to a remote over the network. `memory_write` sets `destructiveHint=False` because it only ever creates new notes (each write gets a fresh ULID id); it never overwrites or deletes an existing note.
## Architecture: tool wrappers over pure functions [#architecture-tool-wrappers-over-pure-functions]
Each MCP tool is a small closure registered inside `build_server`. Every one of them delegates to a separately testable, module-level function that takes the store (and, where needed, the sync backend) as an explicit argument. The wrapper does the MCP-facing work (signature, docstring shown to the model, annotations); the pure function does the logic.
The mapping is one wrapper to one function:
| Tool wrapper | Pure function | Signature |
| --------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| `memory_search` | `search_memories` | `search_memories(store, *, query, project=None, type=None, scope=None, k=8)` |
| `memory_list` | `list_memories` | `list_memories(store, *, project=None, type=None, scope=None)` |
| `memory_status` | `status_report` | `status_report(store, backend)` |
| `memory_write` | `write_memory` | `write_memory(store, *, type, title, body, project="global", tags=None, machine_id="unknown", scope="portable")` |
| `memory_sync` | `sync_memory` | `sync_memory(store, backend)` |
This split is why the test file can exercise behavior two ways: it calls the pure functions directly against a temp-directory store (fast, no transport), and it drives the registered tools through an in-memory FastMCP `Client` to prove the wiring, schemas, and annotations Claude Code actually sees.
The shared serializer `_memory_dict(mem, *, include_body)` is what turns a `Memory` dataclass into the JSON-friendly dict every tool returns. `include_body=True` adds the `body` field; `include_body=False` omits it. That single flag is the entire difference between what `memory_search` returns (bodies included) and what `memory_list` returns (bodies omitted).
## How the server is built and launched [#how-the-server-is-built-and-launched]
`build_server` binds the tools to a concrete store and resolves the machine id once, at build time:
```python
def build_server(store: MemoryStore, *, machine_id: str | None = None) -> FastMCP:
mid = machine_id or resolve_machine_id()
backend: SyncBackend = GitSyncBackend(
store.memory_dir, remote=resolve_remote(), machine_id=mid
)
mcp: FastMCP = FastMCP(name="anamnesis")
# ... register the five tools, all closing over `store`, `backend`, `mid` ...
return mcp
```
`server.py` also defines a `main()` that serves the store over stdio, but it is not the wired console script:
```python
def main() -> None:
build_server(MemoryStore(resolve_home())).run()
```
The actual entry point declared in `server/pyproject.toml` is `anamnesis = "anamnesis.cli:main"`, and `anamnesis serve` (the default subcommand when none is given) is what Claude Code runs. Its handler `cmd_serve` does the same thing, but it imports `build_server` lazily so the non-serve hot path (inject, capture, sync) never needs the MCP extra installed:
```python
def cmd_serve() -> int:
from anamnesis.server import build_server # local import keeps the hot path MCP-free
build_server(MemoryStore(resolve_home())).run()
return 0
```
The repo ships a project-scoped `.mcp.json` that registers the server with Claude Code:
```json
{
"mcpServers": {
"anamnesis": {
"command": "uv",
"args": ["run", "--project", "server", "anamnesis", "serve"]
}
}
}
```
You can run the same command yourself to sanity-check that the server starts:
```bash
uv run --project server anamnesis serve
```
Claude Code launches MCP servers with a filtered environment, so your shell exports (`ANAMNESIS_HOME`, `ANAMNESIS_MACHINE_ID`, `ANAMNESIS_GIT_REMOTE`) are not inherited. Set them in the server's `.mcp.json` `"env"` block, or rely on the store-config fallback described under [Resolution at build time](#resolution-at-build-time).
### Resolution at build time [#resolution-at-build-time]
Three values are resolved from the environment (with fallbacks) and then frozen into the server:
* **Store root** comes from `resolve_home()`: `ANAMNESIS_HOME` if set (with `~` expanded), otherwise `~/.anamnesis`.
* **Machine id** comes from `resolve_machine_id()`: `ANAMNESIS_MACHINE_ID`, else the `machine_id` in the per-store `config.json`, else `socket.gethostname()`, else the literal string `"unknown"`. It is never empty.
* **Sync remote** comes from `resolve_remote()`: `ANAMNESIS_GIT_REMOTE`, else the `remote` in the per-store `config.json`, else `None`.
The store config lives at `/config.json`, outside the synced `memory/` repo (the remote URL differs per machine). It is written by `anamnesis init`. A missing or malformed config yields `{}` so resolution never fails on a bad file.
`machine_id` is **not** a tool parameter. It is bound at build time as `mid` and threaded into `memory_write` from inside the closure, so the model cannot spoof the machine of origin. The end-to-end test builds the server with `machine_id="m-test"`, writes a note through the client, and asserts the returned `machine_id == "m-test"`, proving the bound id wins regardless of tool arguments.
## The read-only tools [#the-read-only-tools]
### memory\_search [#memory_search]
```python
@mcp.tool(annotations=_READ_ONLY)
def memory_search(
query: str,
project: str | None = None,
type: str | None = None,
scope: str | None = None,
k: int = 8,
) -> list[dict[str, object]]:
...
```
Keyword search over the FTS5 index, ranked by BM25. Returns up to `k` notes (default `8`), each a dict with `body` and full metadata. The optional filters narrow the result:
* `project` restricts to one project bucket.
* `type` restricts to `procedural`, `semantic`, or `episodic`.
* `scope` restricts to `portable` (synced) or `machine-local` (this machine only).
Returned shape, one dict per hit:
```jsonc
{
"id": "01J...", // ULID
"type": "procedural",
"title": "WAL mode",
"project": "anamnesis",
"machine_id": "desktop", // machine of origin
"scope": "portable",
"tags": ["sqlite"],
"created_at": "2026-06-24T12:00:00+00:00",
"updated_at": "2026-06-24T12:00:00+00:00",
"body": "set busy_timeout"
}
```
Two recall details worth knowing, both implemented in the store, not the server:
* The query text is tokenized into words, each word becomes a quoted FTS5 phrase, and the phrases are joined with **`OR`** (not `AND`) and ranked by BM25. ANDing every token scored 0 percent recall on natural-language paraphrase queries; the OR-plus-BM25 form recovered recall to about 94 percent on the same eval set. If the query has no word tokens, search returns an empty list.
* Notes that another note marks as `supersedes` are excluded from results, so superseded memories do not resurface in recall.
### memory\_list [#memory_list]
```python
@mcp.tool(annotations=_READ_ONLY)
def memory_list(
project: str | None = None,
type: str | None = None,
scope: str | None = None,
) -> list[dict[str, object]]:
...
```
Lists notes newest-first (ordered by `updated_at DESC, id DESC`), returning titles and metadata but **no bodies**. The shape is identical to `memory_search` minus the `body` key. The test `test_list_memories_returns_metadata_without_body` asserts `"body" not in items[0]`. Same three optional filters as search; there is no `k` cap on `memory_list`.
### memory\_status [#memory_status]
```python
@mcp.tool(annotations=_READ_ONLY)
def memory_status() -> dict[str, object]:
...
```
Reports index health and git sync state. No parameters. Returned shape:
```jsonc
{
"root": "/home/you/.anamnesis",
"db_path": "/home/you/.anamnesis/index.db",
"total": 42,
"by_type": { "procedural": 20, "semantic": 18, "episodic": 4 },
"by_project": { "anamnesis": 30, "global": 12 },
"by_scope": { "portable": 40, "machine-local": 2 },
"sync": {
"initialized": true,
"remote": "ssh://node.tailnet/srv/anamnesis.git",
"head": "3237e8f",
"dirty": false,
"detail": "ok"
}
}
```
The counts come from `store.stats()`; the `sync` block comes from `backend.state()`. `sync.initialized` is `false` until the `memory/` git repo exists (no sync has run yet); `sync.remote` echoes the configured remote, so it is `null` when no remote is set. The test `test_status_report_reports_index_health_and_sync_state` builds a backend with `remote=None` and asserts `initialized is False` and `remote is None`.
## The write tools [#the-write-tools]
### memory\_write [#memory_write]
```python
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False))
def memory_write(
type: str,
title: str,
body: str,
project: str = "global",
tags: list[str] | None = None,
scope: str = "portable",
) -> dict[str, object]:
...
```
Creates one durable note: it writes the markdown file first, then indexes it. Note the wrapper has **no** `machine_id` parameter; the bound `mid` is injected inside the closure. The model controls everything else:
* `type` is one of `procedural` (verified how-tos, decisions, fixes), `semantic` (facts, preferences, conventions), or `episodic` (what happened). The store schema enforces this set with a `CHECK` constraint.
* `project` defaults to `"global"`.
* `tags` defaults to an empty list.
* `scope` defaults to `"portable"` (syncs to your other machines). `"machine-local"` keeps the note on this machine only and never syncs it.
The note's id is a freshly generated ULID, and `created_at`/`updated_at` are stamped at write time. The return value is the created note's full metadata including its `body`. Because the write modifies the store, the tool is not auto-approved; the client should confirm it.
The portable vs machine-local split is enforced by *where the file lives*, not just by a field. A portable note is written under `/memory//.md` (inside the git-synced tree); a machine-local note is written under `/local//.md` (outside it). The test `test_write_memory_accepts_machine_local_scope` asserts a `machine-local` write lands in `local/` and leaves `memory/` empty, so it can never be pushed.
### memory\_sync [#memory_sync]
```python
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, openWorldHint=True))
def memory_sync(force: bool = False) -> dict[str, object]:
...
```
Runs one git sync cycle and then rebuilds the index. Concretely, `sync_memory` calls `backend.sync()` and then `store.reindex()`:
```python
def sync_memory(store: MemoryStore, backend: SyncBackend) -> dict[str, object]:
r = backend.sync()
indexed = store.reindex()
return {
"pushed": r.pushed,
"pulled": r.pulled,
"conflicted": r.conflicted,
"head": r.head,
"indexed": indexed,
"detail": r.detail,
}
```
The git cycle (in `GitSyncBackend.sync`) is: `git add -A`, commit if anything is staged, then `git fetch origin`, integrate `origin/main` with `git rebase`, and `git push -u origin main`. The branch is always `main`. The reindex afterward matters because pulling brings in markdown from other machines and the SQLite index is derived; rebuilding keeps search in step with the freshly synced files. The test `test_sync_memory_reindexes_so_pulled_notes_are_searchable` proves a pulled note is searchable on the receiving machine with no manual reindex.
Returned shape:
```jsonc
{
"pushed": true, // did we push new commits?
"pulled": 2, // commit count pulled from the remote
"conflicted": false, // true if a rebase conflict was surfaced
"head": "3237e8f", // short HEAD after the cycle
"indexed": 42, // notes reindexed from markdown
"detail": "synced"
}
```
Two behaviors to know:
* **No remote configured.** If `resolve_remote()` returned `None`, sync just commits locally and returns `pushed=false`, `conflicted=false`, with `detail` explaining there is no remote (it contains the substring `remote`). Asserted by `test_memory_sync_without_remote_commits_locally`.
* **Conflict policy.** On a rebase conflict the backend aborts the rebase, keeps your local edits in place, does not push, and returns `conflicted=true` with `detail` telling you to resolve and re-sync. It never silently drops either side.
The `force` flag is accepted in the signature but **reserved for future use**: `memory_sync` ignores it entirely and always calls `sync_memory(store, backend)`. Passing `force=true` changes nothing today. Do not rely on it.
## Concurrency model [#concurrency-model]
Multiple Claude Code sessions can target the same store at once, and FastMCP runs synchronous tool functions in a worker threadpool. So the store's single SQLite connection is shared across threads. Three things make that safe, all set up in `MemoryStore.__init__`:
```python
self._db = sqlite3.connect(self.db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA busy_timeout=5000")
```
* `check_same_thread=False` lets the threadpool reuse the one connection across threads. This relies on SQLite's serialized threadsafety.
* **WAL mode** (`journal_mode=WAL`) lets readers and a writer proceed concurrently without the readers blocking on the writer.
* **`busy_timeout=5000`** (5000 milliseconds) makes a contended write wait and retry for up to five seconds instead of failing immediately with "database is locked".
The index database is never synced. It lives at `/index.db`, outside the git-managed `memory/` tree, and is always rebuildable from markdown via `store.reindex()`. This is the deliberate lesson from the claude-brain DB-corruption incident: sync markdown over git, rebuild the index locally on each machine, never copy the live DB file between machines.
The index schema is versioned (`_SCHEMA_VERSION = 1`, tracked in `PRAGMA user_version`). On startup, if an existing DB is at a lower version, the store drops the derived tables, recreates them with the current schema, and reindexes from markdown. Because the index is fully derived, this migration is lossless: the markdown files are untouched.
## Testing the wiring yourself [#testing-the-wiring-yourself]
Run the server test suite from the `server/` package:
```bash
cd server
uv run pytest tests/test_server.py -v
```
The suite covers home and machine-id resolution, each pure tool function against a temp store, the exact set of five registered tools, the read-only annotations, an end-to-end write-then-search through an in-memory `Client`, the bound machine id, the sync-and-reindex round trip across two stores sharing a bare git remote, and the no-remote local-commit path.
## Honest status [#honest-status]
* The server, the five tools, the annotations, and the concurrency model described here are all shipped and tested.
* The one-line install via PyPI is live: `uv tool install anamnesis-memory && anamnesis init`. The PyPI distribution name is `anamnesis-memory`, but the import package and the installed CLI command stay `anamnesis`. Installing from source with `uv pip install -e ".[mcp,dev]"` in `server/` is the path for contributors and local development.
## Related pages [#related-pages]
* [The memory store](./store) for FTS5, BM25, the schema, and scope.
* [Cross-machine sync](./sync) for the git-over-Tailscale backend in detail.
* [Configuration](../reference/configuration) for `ANAMNESIS_HOME`, `ANAMNESIS_MACHINE_ID`, and `ANAMNESIS_GIT_REMOTE`.
# Keyword recall: FTS5 and BM25 (/docs/internals/recall)
Anamnesis recall is keyword search, not a vector database. The source of truth is markdown, and a SQLite FTS5 index derived from those files answers queries with BM25 ranking. This page documents exactly how `MemoryStore.search` turns a free-text query into ranked notes, why query tokens are joined with `OR` and not `AND`, and how superseded notes are kept out of recall while staying browsable.
All of the behavior below lives in `server/src/anamnesis/store.py`, with the contract pinned by tests in `server/tests/test_store.py`.
## Where recall fits [#where-recall-fits]
A query enters through the MCP tool `memory_search` (defined in `server/src/anamnesis/server.py`), which calls the pure helper `search_memories`, which calls `MemoryStore.search`. The store runs one FTS5 query, then re-reads each hit from its markdown file before returning.
The index is never the source of truth. Every hit returned by `search` is re-read from disk via `get`, which loads and parses the note's markdown file. If the index and the files ever disagree, `reindex` rebuilds the entire index from the markdown tree.
## The FTS5 table [#the-fts5-table]
The index defines one virtual table for full-text search (`_SCHEMA` in `store.py`):
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
id UNINDEXED, title, body, tags, tokenize='porter unicode61'
);
```
Notes on each column and option:
* `id UNINDEXED` stores the note id alongside the searchable text but does not tokenize it, so ids never pollute keyword matches. It is used to join back to the structured `memories` table.
* `title`, `body`, and `tags` are the three searchable fields. On indexing (`_index`), `tags` is stored as the tag list joined with single spaces: `" ".join(mem.tags)`.
* `tokenize='porter unicode61'` applies Unicode-aware tokenization plus the Porter stemmer, so `connection` and `connections` collapse to the same stem and match each other.
The structured `memories` table holds everything else (type, project, machine\_id, scope, timestamps, provenance, `supersedes`) and a `body_path` pointing at the markdown file on disk. `search` joins `memories_fts` to `memories` on `id`.
## How search() works [#how-search-works]
`MemoryStore.search` has this signature:
```python
def search(
self,
query: str,
*,
project: str | None = None,
type: MemoryType | None = None,
scope: Scope | None = None,
k: int = 8,
) -> list[Memory]:
```
The default `k` is `8`. The steps are:
1. **Build the MATCH expression.** `match = _fts_query(query)`. If it comes back empty (the query had no word characters), `search` returns `[]` immediately and never touches the database.
2. **Assemble the SQL.** A base `SELECT m.id FROM memories_fts f JOIN memories m ON m.id = f.id WHERE memories_fts MATCH ?`, with optional `AND m.project = ?`, `AND m.type = ?`, and `AND m.scope = ?` clauses appended only when those filters are passed. All values are bound parameters, never string-interpolated.
3. **Exclude superseded notes.** A fixed clause is always appended (see [Superseded notes](#superseded-notes-are-hidden-from-recall) below).
4. **Rank and limit.** `ORDER BY bm25(memories_fts), m.updated_at DESC LIMIT ?` with `k` as the final parameter.
5. **Re-read from markdown.** The query returns ids only. `search` then calls `self.get(r["id"])` for each row, which reads the note back from its markdown file. The returned list is `Memory` objects, in rank order.
The full assembled query (with all three optional filters present) is:
```sql
SELECT m.id FROM memories_fts f
JOIN memories m ON m.id = f.id
WHERE memories_fts MATCH ?
AND m.project = ?
AND m.type = ?
AND m.scope = ?
AND m.id NOT IN
(SELECT supersedes FROM memories WHERE supersedes IS NOT NULL AND supersedes <> '')
ORDER BY bm25(memories_fts), m.updated_at DESC LIMIT ?
```
### Ranking: BM25 with a recency tie-break [#ranking-bm25-with-a-recency-tie-break]
`bm25(memories_fts)` is SQLite FTS5's built-in relevance function. It returns a score where lower (more negative) is better, so a plain `ORDER BY bm25(memories_fts)` already puts the most relevant note first. Anamnesis adds a secondary sort key, `m.updated_at DESC`, so that when two notes score equally on BM25 the more recently updated one wins. The result: relevance first, freshness as the tie-break.
`updated_at` is an ISO-8601 string (for example `2026-06-24T10:30:00+00:00`, produced by `_utcnow()` with `timespec="seconds"`). Because ISO-8601 sorts lexicographically in time order, `ORDER BY ... DESC` on the text column is correct without any date parsing. The `memories` table also carries `idx_mem_recency ON memories(updated_at DESC)` to back this ordering.
## The query builder: \_fts\_query [#the-query-builder-_fts_query]
`_fts_query` is the small function that turns arbitrary user or imported text into a safe FTS5 MATCH expression. The entire implementation:
```python
def _fts_query(query: str) -> str:
tokens = re.findall(r"\w+", query, flags=re.UNICODE)
return " OR ".join(f'"{t}"' for t in tokens)
```
Three things happen here:
1. **Tokenize on `\w+`.** `re.findall(r"\w+", query, flags=re.UNICODE)` pulls out runs of word characters (letters, digits, underscore, Unicode-aware). Everything else (spaces, punctuation, FTS5 operators) is discarded at this stage.
2. **Double-quote every token.** Each token becomes a quoted phrase, for example `"sqlite"`. Quoting neutralizes FTS5-special characters so a token can never be interpreted as a query operator. This is what lets a query like `state-of-the-art` or `16:9` run without raising: the `-` and `:` are not part of any `\w+` run, so they are dropped, and the surviving word tokens are quoted.
3. **Join with `OR`.** The quoted tokens are joined with `OR`.
So the query:
```
how to configure a SQLite connection to avoid lock errors on concurrent writes
```
becomes the MATCH expression:
```
"how" OR "to" OR "configure" OR "a" OR "SQLite" OR "connection" OR "to" OR "avoid" OR "lock" OR "errors" OR "on" OR "concurrent" OR "writes"
```
If the query has no word characters at all (for example `"-"`), `re.findall` returns an empty list, the join produces `""`, and `search` short-circuits to `[]`.
There is no stop-word removal. Common words like `to`, `a`, and `on` stay in the OR expression. They contribute little to BM25 (they appear in many notes, so their inverse-document-frequency weight is low) but they do not hurt ranking, and keeping the function this simple is deliberate.
## Why OR and not AND [#why-or-and-not-and]
This is the load-bearing design decision in recall, and it is the subject of a dedicated fix (commit `e727005`).
The intuitive choice is to `AND` the tokens: require a matching note to contain every word of the query. That is wrong for natural-language recall. A real query is a paraphrase, not a copy of the note. It almost always contains at least one word the relevant note does not. Under `AND`, a single missing word drops the note from the results entirely.
Measured on the eval set, ANDing every token scored about **0% recall** on natural-language paraphrase queries. Switching to `OR` plus BM25 ranking surfaces the best-overlapping notes first (a note that shares more, and rarer, query words ranks higher) and recovered recall to about **94%** on the same eval set.
The pure intent is captured by `test_search_recalls_on_partial_overlap_not_only_full_match` in `test_store.py`. It writes a note titled `Use WAL mode for SQLite` with body `Set busy_timeout on every connection to avoid lock errors.`, then searches:
```
how to configure a SQLite connection to avoid lock errors on concurrent writes
```
The query shares `SQLite`, `connection`, `avoid`, `lock`, and `errors` with the note, but also carries `configure`, `concurrent`, and `writes`, which the note lacks. An AND-of-all-tokens match would return nothing; the test asserts the note is still found. That is the OR + BM25 behavior working as designed.
OR recall means `search` returns the best-overlapping notes, not only exact matches. Treat the result as a ranked candidate set, and rely on BM25 order plus `k` (default `8`) to keep it tight. This is the right trade for feeding Claude a working set, where a near-miss is far cheaper than a missed memory.
## Superseded notes are hidden from recall [#superseded-notes-are-hidden-from-recall]
When a newer note replaces an older one, the new note's `supersedes` field carries the old note's id. `search` always appends this exclusion clause, regardless of any project, type, or scope filters:
```sql
AND m.id NOT IN
(SELECT supersedes FROM memories WHERE supersedes IS NOT NULL AND supersedes <> '')
```
So any id that appears as some other note's `supersedes` value is excluded from search results. The helper `superseded_ids()` returns the same set (every non-empty `supersedes` value in the store) for callers that need it directly.
The key asymmetry, pinned by `test_search_excludes_superseded_but_list_includes`:
* `search` excludes superseded notes. They are stale, so they must not be recalled into a working set.
* `list` includes them. The full history stays browsable (for example in the dashboard), so nothing is silently lost.
## Scope, project, and type filters [#scope-project-and-type-filters]
The three optional filters narrow recall before ranking:
* `project` restricts to notes whose `project` equals the value. `test_search_is_scoped_by_project` confirms a query only returns notes from the requested project.
* `type` restricts to one of `procedural`, `semantic`, or `episodic` (enforced by a `CHECK` constraint on the `memories` table).
* `scope` restricts to `portable` or `machine-local`.
When no `scope` is passed, search spans both portable and machine-local notes. `test_list_and_search_filter_by_scope_but_span_both_by_default` verifies that a default `search("findme", project="p")` returns notes from both trees, while `scope="machine-local"` narrows to the local-only note. (Machine-local notes live under `local/` outside the git-synced `memory/` tree; see [Sync](./sync).)
## Concurrency and the rebuildable index [#concurrency-and-the-rebuildable-index]
The SQLite connection is opened with `check_same_thread=False` because the FastMCP server runs synchronous tools in a worker threadpool, so one connection is shared across threads. Safety comes from `PRAGMA journal_mode=WAL` plus `PRAGMA busy_timeout=5000` (5 seconds), set at open time in `MemoryStore.__init__`. `test_store_is_usable_from_another_thread` writes and searches from a separate thread to pin this.
Because the index is fully derived, a fresh machine that synced only the markdown can rebuild a working index from the files alone. `reindex` clears `memories`, `memory_tags`, and `memories_fts`, then walks both trees (`memory/` as `portable`, `local/` as `machine-local`) and re-indexes every `*.md` file, returning the count. `test_reindex_rebuilds_index_from_markdown_only` deletes `index.db` (and its `-wal` and `-shm` sidecars), reopens the store, confirms search is empty, then reindexes and confirms recall returns. Schema upgrades use the same mechanism: on open, a `user_version` below `_SCHEMA_VERSION` (currently `1`) drops the derived tables, recreates them, and reindexes from markdown.
Never sync `index.db` itself. It is derived state and syncing a live SQLite database over a file-sync tool risks corruption. Sync the markdown via git and rebuild the index locally. See [Sync](./sync) for the git-over-Tailscale flow.
## Vectors are an optional, currently-unused extra [#vectors-are-an-optional-currently-unused-extra]
Recall is keyword-only today. Semantic vector search exists only as an optional dependency, not a code path: `server/pyproject.toml` declares
```toml
# Optional semantic recall - added only when keyword search proves insufficient.
vectors = [
"sqlite-vec>=0.1",
]
```
This `vectors` extra is not installed by default and is not wired into `store.py` (nothing imports `sqlite-vec` anywhere in the package). The architecture position is explicit: add `sqlite-vec` embeddings only if keyword search measurably fails on paraphrase queries. The eval harness in `server/src/anamnesis/eval.py` is the instrument that would detect such a failure: `recall_at_k(store, cases, ks=(1, 3, 5, 8))` runs `store.search` over each case and returns a `RecallReport` with Recall\@k for k in `(1, 3, 5, 8)` plus MRR (mean reciprocal rank of the first relevant hit). The cases themselves are LLM-generated paraphrase queries (`build_eval_candidates`, one paraphrase query per sampled note). Until that bar is crossed, the simpler FTS5 + BM25 path stands.
## Trying it [#trying-it]
The store is plain Python. To exercise recall directly from a checkout:
```bash
cd server
uv venv
uv pip install -e ".[dev]"
uv run pytest tests/test_store.py -q
```
To run only the recall-shaped tests:
```bash
cd server
uv run pytest tests/test_store.py -q -k "search or superseded or reindex"
```
A quick interactive check from a Python shell:
```python
from anamnesis.store import MemoryStore
store = MemoryStore(root="/tmp/anamnesis-demo")
store.write(
type="procedural",
title="Use WAL mode for SQLite",
body="Set busy_timeout on every connection to avoid lock errors.",
project="demo",
)
hits = store.search("how to avoid sqlite lock errors on concurrent writes", project="demo")
print([m.title for m in hits])
```
## See also [#see-also]
* [MCP server](./mcp-server) - how `memory_search` is exposed to Claude Code.
* [Data model](./data-model) - the `memories` schema, provenance fields, and the `supersedes` relationship.
* [Sync](./sync) - git-over-Tailscale sync of the markdown source of truth (and why `index.db` is never synced).
* [Reflection](./reflection) - how notes get superseded and consolidated over time.
# Reflection and compression (/docs/internals/reflection)
Anamnesis captures one **episodic** note per Claude Code session (see [Capture and injection](./capture-and-injection)). Over time those notes pile up: many of them repeat the same facts, decisions, and procedures. **Reflection** is the offline pass that reads a project's un-reflected episodics and distills them into a smaller set of durable **semantic** and **procedural** notes, so future sessions get a tighter, higher-signal working set instead of a growing stack of raw session logs.
This page is the reference for how that works. It covers the swappable LLM, the exact pipeline, redaction as the enforcement seam, the strict no-fallback policy on bad model output, and the eval suite you use to confirm reflection actually helps recall before you trust it.
The two source files to read alongside this page are `server/src/anamnesis/reflect.py` (the pipeline) and `server/src/anamnesis/llm_summarizer.py` (the LLM client and config). Redaction lives in `server/src/anamnesis/redact.py`, capture-time summarization in `server/src/anamnesis/capture.py`, and the measurement harness in `server/src/anamnesis/eval.py`.
## Two places the model runs [#two-places-the-model-runs]
The same swappable LLM serves two distinct jobs. Keep them straight, because their failure policies are opposite.
| Job | Where | Input | Output | On bad/failed output |
| ------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ |
| Capture-time summary | `llm_summarizer.py` (`LLMSummarizer`) | one raw session transcript | one episodic note (`{skip, title, body}`) | falls back to the deterministic heuristic so teardown never breaks |
| Reflection / distillation | `reflect.py` (`Reflector`) | many episodic notes for one project | zero or more durable notes (a JSON array) | **aborts** that project, writes nothing, never fabricates |
Capture runs inline at session teardown (a `SessionEnd` or `PreCompact` hook), so it must never raise; if the LLM is misconfigured or returns garbage it silently degrades to a deterministic heuristic summary. Reflection is a deliberate, user-invoked batch job, so correctness wins over availability: a bad response aborts rather than inventing memory. Both jobs are detailed below.
## The swappable LLM [#the-swappable-llm]
The reflection model is **deliberately swappable and never hardcoded**. Nothing in the code names a specific vendor model in a way that the call site depends on: provider, model, base URL, key, timeout, and token budget all come from the environment, and the HTTP layer speaks the generic OpenAI-compatible `/chat/completions` shape. This is an explicit architecture decision so the price/quality frontier can move without code changes.
### Configuration (environment only) [#configuration-environment-only]
`resolve_reflection_config()` in `llm_summarizer.py` reads everything from the environment. These are machine-local and never synced.
| Variable | Purpose | Default |
| --------------------------------- | ------------------------------------------------------------------- | ----------- |
| `ANAMNESIS_REFLECTION_PROVIDER` | provider label; also the heuristic/LLM switch | `heuristic` |
| `ANAMNESIS_REFLECTION_MODEL` | model id sent in the request body | (empty) |
| `ANAMNESIS_REFLECTION_BASE_URL` | OpenAI-compatible base URL (no trailing `/chat/completions`) | (empty) |
| `ANAMNESIS_REFLECTION_API_KEY` | bearer key; falls back to `DEEPSEEK_API_KEY`, then `OPENAI_API_KEY` | (empty) |
| `ANAMNESIS_REFLECTION_TIMEOUT` | per-request timeout in seconds (float) | `30` |
| `ANAMNESIS_REFLECTION_MAX_TOKENS` | token budget; drives the input char window (`max_tokens * 4`) | `120000` |
A few exact behaviors worth knowing:
* The API key is resolved by `_env(...)` which tries each name in order and returns the first non-empty value, so an existing `DEEPSEEK_API_KEY` or `OPENAI_API_KEY` in your shell is picked up without setting `ANAMNESIS_REFLECTION_API_KEY`.
* `base_url` has any trailing slash stripped and `/chat/completions` appended (`_http_client`).
* `max_tokens` is an **input window budget**, not an output cap. It is multiplied by 4 (the project's \~4-chars-per-token estimate) to produce `max_chars`, the size the content is windowed to before sending. It is not sent to the provider as `max_tokens`.
* `provider` is lowercased and used as the first half of the recorded `prov_model` label (`f"{provider}/{model}"`), so a reflection note's provenance reads like `deepseek/deepseek-chat`.
The reflection/provider environment is only present in the user's interactive shell (zsh in this setup). It is not visible to a non-interactive subshell, so `anamnesis reflect --apply` and `anamnesis eval build/experiment` must be run from that shell, or the provider will resolve as unconfigured and the command will no-op.
### Default provider heuristic [#default-provider-heuristic]
The provider also selects which summarizer/reflector you get, and the default is intentionally conservative: **no LLM unless you configure one.**
* `ANAMNESIS_REFLECTION_PROVIDER` defaults to `heuristic`. Capture's `resolve_summarizer()` maps `heuristic` to the deterministic `HeuristicSummarizer`, and maps `deepseek`, `openai`, and `local` to the LLM path. Any unknown value also falls back to the heuristic.
* Even when the provider is one of the LLM values, the LLM path only activates if `model`, `base_url`, **and** `api_key` are all non-empty. `make_llm_summarizer()` returns a `HeuristicSummarizer` otherwise, and `make_reflector()` returns `None` otherwise (which the `reflect` command treats as "no provider configured" and exits cleanly).
So the gate is: a recognized provider value, plus a model, plus a base URL, plus a key. Miss any one and capture stays deterministic and reflection stays off.
### The HTTP client [#the-http-client]
`_http_client(base_url, api_key, model, timeout)` returns a closure that POSTs to `{base_url}/chat/completions` using stdlib `urllib.request` only. There is no SDK and no third-party dependency, so the base (hook) install stays dependency-light. The request body is:
```json
{
"model": "",
"messages": [
{ "role": "system", "content": "" },
{ "role": "user", "content": "" }
],
"temperature": 0.2,
"stream": false
}
```
It sets `Content-Type: application/json` and `Authorization: Bearer `, applies the configured `timeout`, and returns `body["choices"][0]["message"]["content"]` as a string. Any network error, timeout, or unexpected JSON shape raises out of the closure, where the caller's policy (fallback for capture, abort for reflection) takes over.
## The reflect pipeline [#the-reflect-pipeline]
`anamnesis reflect` distills a project's episodic notes. The default run is a **dry-run**; you must pass `--apply` to write anything.
```bash
# dry-run: report which projects have enough un-reflected episodics
anamnesis reflect
# distill one project and write the durable notes (then sync)
anamnesis reflect --project myproj --apply
# distill every eligible project, write notes, but skip the git sync
anamnesis reflect --apply --no-sync
```
### End-to-end flow [#end-to-end-flow]
### Step by step [#step-by-step]
**1. Pick projects.** With `--project X` it reflects only `X`. Otherwise it reflects every project that has any portable episodic note: `sorted({m.project for m in store.list(type="episodic", scope="portable")})`.
**2. Select un-reflected episodics.** `select_unreflected(store, project)` returns the project's notes that are `type="episodic"`, `scope="portable"`, and do **not** carry the `reflected` tag. Local-scope notes are never reflected; only portable memory is eligible.
**3. Threshold gate.** `resolve_min_episodics()` reads `ANAMNESIS_REFLECT_MIN_EPISODICS` (default **5**). A project with fewer than that many un-reflected episodics is skipped this run. The idea is to wait until there is enough material to find recurring patterns, rather than distilling one or two sessions into low-value notes. A non-integer env value falls back to 5.
**4. Render, redact, window.** `Reflector.reflect()` joins the selected notes into one text blob (`## {title}\n{body}` per note), runs it through `redact(...)`, then `_window(...)` to bound it to `max_chars` (which is `cfg.max_tokens * 4`, default 480,000 chars at the default token budget). Redaction always runs before windowing, and windowing keeps the head (60%) and tail with an explicit `...[transcript truncated for length]...` marker.
**5. LLM call.** The redacted, windowed content is sent as the user message with `REFLECT_SYSTEM_PROMPT` as the system message. The prompt instructs the model to return only a JSON array where each element is `{"type": "semantic" | "procedural", "title": , "body": }`, to use `semantic` for durable facts/decisions/preferences and `procedural` for repeatable how-tos, to merge points that recur across sessions, to omit one-off chatter and transient state, and to return `[]` if nothing is worth keeping. It also explicitly forbids including secrets, keys, tokens, or credentials.
**6. Strict parse.** `_parse_reflection(text)` strips any markdown code fences, `json.loads` the result, and validates the shape hard:
* the top level must be a list, else `ValueError("reflection output is not a JSON array")`;
* each item must be an object;
* `type` must be exactly `semantic` or `procedural`;
* both `title` and `body` must be non-empty after stripping.
Any violation raises `ValueError`. There is no partial acceptance: the whole project's response is rejected.
**7. Write durable notes.** For each parsed `DistilledNote`, `apply_reflection` calls `store.write(...)` with:
* `type` = `semantic` or `procedural` (as returned by the model);
* `project` = the project being reflected;
* `scope="portable"`;
* `tags=["reflection"]`;
* `prov_source="reflection"`;
* `prov_model` = the reflector's `model_label` (`f"{provider}/{model}"`);
* `confidence=0.6` (the module default `_DEFAULT_CONFIDENCE`).
The low confidence and the `reflection` tag are how the dashboard surfaces these notes as machine-proposed and reviewable, distinct from human-authored memory (which writes at `confidence=1.0`, `prov_source="human"`).
**8. Tag the sources.** Each source episodic gets the `reflected` tag added (`ep.tags = sorted(set(ep.tags) | {"reflected"})`) and is persisted via `store.put(ep)`. That is what makes reflection idempotent: a re-run will not re-select the same episodics, because they now carry `reflected`. The same episodics are never distilled twice.
**9. Sync.** After a successful `--apply` run that wrote at least one note, the command reindexes and runs a git sync, unless `--no-sync` was passed (in which case it reindexes only). With `--no-sync`, the new notes are written to markdown and indexed locally but not committed.
`reflect --no-sync` does **not** commit. The notes exist on disk and in the local index, but until you commit them they can be clobbered by a concurrent sync (for example, a `SessionEnd` capture firing in another session, which does commit and push). If you use `--no-sync`, commit the new markdown promptly, or just let the default (sync) run.
### Provenance of a reflection note [#provenance-of-a-reflection-note]
A reflection note never deletes or rewrites its source episodics. The episodics stay as-is (just tagged `reflected`); the durable note is additive. This keeps the markdown source of truth append-only and auditable: you can always trace a distilled note back to the sessions that were available when it was written, and you can delete a bad reflection note without losing the underlying history.
## The no-fallback policy [#the-no-fallback-policy]
This is the most important behavioral contract of reflection, and it is the opposite of capture-time summarization.
**Reflection aborts; it never fabricates.** In `apply_reflection`, the LLM call happens **first**, before any write. If `reflector.reflect()` raises (a network error, a timeout, or a parse failure from `_parse_reflection`), the exception propagates and nothing has been written for that project. The `reflect` command catches it per project (`reflect: {project}: failed ({exc}); skipped`) so one bad project does not kill the whole run, but the policy inside the pipeline is absolute: a failed or invalid response yields zero notes. The system would rather write nothing than write a hallucinated or malformed memory.
**Capture falls back; it never breaks teardown.** `LLMSummarizer.summarize()` wraps the whole LLM path in a `try/except Exception`. On any failure it logs `capture: llm summary failed (...); using heuristic` to stderr and returns `self.fallback.summarize(session)`, the deterministic `HeuristicSummarizer`. Capture runs inline during session teardown, so it must always produce something (or a clean skip) and must never raise.
The reason for the split: a hallucinated one-line episodic from a single session is low stakes and self-correcting (the next session overwrites the working set anyway), but a hallucinated durable semantic note would be promoted, low-friction, and injected into many future sessions. The cost of a bad durable note is much higher, so reflection refuses to guess.
The eval suite's candidate generation (`build_eval_candidates`) follows the same no-fallback rule: an unparseable query-gen response raises rather than fabricating an eval case.
## Redaction is the enforcement seam [#redaction-is-the-enforcement-seam]
Redaction is the single point where secrets are masked before anything leaves the machine for an external provider. It is not a best-effort sprinkle across the codebase; it is one function, `redact(text)` in `redact.py`, called on the exact content that is about to be sent.
Every path that sends content to the LLM redacts first, immediately before windowing:
* reflection: `Reflector.reflect()` does `_window(redact(_render_episodics(episodics)), self.max_chars)`;
* capture: `LLMSummarizer.summarize()` does `_window(redact(transcript), self.max_chars)`;
* eval query-gen: `build_eval_candidates()` does `_window(redact(f"# {note.title}\n{note.body}"), max_chars)`.
`redact()` is deterministic and conservative. It replaces secret-shaped spans with the literal `[REDACTED]`. The patterns (applied in order, the multi-line key block first) cover:
* PEM private-key blocks (`-----BEGIN ... PRIVATE KEY----- ... -----END ... PRIVATE KEY-----`);
* AWS access key ids (`AKIA` + 16 chars);
* `sk-` / `rk-` / `pk-` style keys (12+ trailing chars);
* GitHub tokens (`ghp_`, `gho_`, `ghs_`, `ghr_`, `ghu_` + 20+ chars);
* Slack tokens (`xoxb-` / `xoxa-` / `xoxp-` / `xoxr-` / `xoxs-`);
* `Bearer ` headers (12+ chars).
It also masks key/value assignments for sensitive key names (`password`, `passwd`, `secret`, `token`, `api_key`/`apikey`, `authorization`, `access_key`, `client_secret`), preserving the key name and any surrounding quotes while replacing the value. The prefix capture means a trailing-segment name like `DEEPSEEK_API_KEY=...` still matches.
The reflect and capture system prompts also tell the model never to emit secrets, but that is belt-and-suspenders. The hard guarantee is the deterministic `redact()` pass on the content before the request is built, not the model's cooperation. See [Security](../reference/security) for the full redaction reference.
Redaction is pattern-based, so it catches secret-shaped spans, not every possible secret. A credential in an unusual format (no recognizable prefix, no `key=value` shape) can slip through. Treat the reflection provider as a place your redacted transcripts go, and prefer a provider and key you are comfortable sending developer notes to.
## The eval suite [#the-eval-suite]
Reflection changes your memory store, so before you trust it you want evidence that it improves recall without bloating the per-session working set. `anamnesis eval` is the measurement harness for exactly that. It lives in `eval.py` and has three subcommands.
The two metrics:
* **recall\@k** and **MRR** (`recall_at_k`): for each eval case (a query plus the ids of the notes that should answer it), run `store.search(query, k)` and check whether any relevant id appears in the top k. The defaults report recall\@1, @3, @5, @8, plus MRR (mean reciprocal rank, using the rank of the first relevant hit). See [Recall](./recall) for how `store.search` ranks.
* **working set**: the estimated token size of the SessionStart inject block per non-global project, plus mean/median and the share of the full corpus a session injects (`inject_working_set`). Tokens are estimated with the \~4-chars-per-token heuristic (`estimate_tokens`); the harness only ever reports ratios and diffs of the same estimator, so the constant factor cancels out.
### build: generate candidate eval cases [#build-generate-candidate-eval-cases]
```bash
anamnesis eval build --n 30 --types semantic,procedural
```
`build` samples notes from the store (a deterministic round-robin across projects for coverage, no RNG) and, for each, asks the LLM for one realistic paraphrase query that the note answers, using `QUERYGEN_SYSTEM_PROMPT`. The prompt deliberately asks for **different words** from the note so the query tests meaning-based recall, not exact keyword overlap. Each generated case is appended to the eval set with `approved=false` and `source="llm:/"`. Duplicate queries already in the set are skipped (`append_candidates`).
Defaults and paths:
* `--n` defaults to 30, `--types` defaults to `semantic,procedural`.
* The eval set defaults to `/eval/eval.jsonl` (override with `--eval-set`). It lives under the store root, outside the repo.
* `build` requires a configured provider (`make_reflector()`); with none it prints the no-provider message and exits.
`build` writes candidates as `approved=false`. They are LLM-generated and must be curated by hand. `eval run` and `eval experiment` ignore unreviewed cases unless you pass `--include-unreviewed`. Curating means reading each query, confirming the listed `relevant_ids` really are the right answers, and flipping `approved` to `true` in the JSONL.
### run: measure the current store [#run-measure-the-current-store]
```bash
anamnesis eval run # human-readable report
anamnesis eval run --json # machine-readable, for tracking over time
```
`run` loads the approved cases, refreshes each case's note titles from the store (warning, not erroring, on any relevant id that is no longer present), and reports recall\@k, MRR, and the working-set sizes for the store as it is right now. This is your baseline. Pass `--include-unreviewed` to also score candidates you have not curated yet.
### experiment: before/after reflection, safely [#experiment-beforeafter-reflection-safely]
```bash
anamnesis eval experiment
```
`experiment` answers the real question: does running reflection help? It does so **without touching your live store**. `sandbox_store(store)` copies the `memory/` and `local/` markdown trees into a temp directory, builds a fresh `MemoryStore` over the copy, and reindexes it. All reflection then happens on that throwaway copy, which is removed on exit. Your real notes are never modified.
The report shows the inject-token mean per project before and after (with a percent delta), recall\@k and MRR before and after (flagging any `REGRESSION` where after is worse than before), the count of projects reflected and notes written, the count skipped for being below the episodic threshold, and any projects that errored during reflection. The `ExperimentReport.recall_regressed` property is `True` if recall dropped at any k, which is your stop signal: if reflection regresses recall on a curated eval set, do not promote it.
`experiment` also requires a configured provider, and like `run` it ignores unreviewed cases unless `--include-unreviewed` is passed.
### Recommended workflow [#recommended-workflow]
```bash
# 1. generate candidates (LLM)
anamnesis eval build --n 30
# 2. curate eval.jsonl by hand: verify relevant_ids, set approved=true
# 3. baseline the live store
anamnesis eval run --json > baseline.json
# 4. simulate reflection on a sandbox copy; check for regressions
anamnesis eval experiment
# 5. only if recall holds or improves, run it for real
anamnesis reflect --apply
```
## Defaults and field reference [#defaults-and-field-reference]
| Thing | Value | Where |
| ------------------------------------- | --------------------------------------------------------- | ------------------------------------------------- |
| Min un-reflected episodics to reflect | `5` (`ANAMNESIS_REFLECT_MIN_EPISODICS`) | `resolve_min_episodics` |
| Reflection note confidence | `0.6` | `_DEFAULT_CONFIDENCE` |
| Reflection note `prov_source` | `reflection` | `apply_reflection` |
| Reflection note tag | `reflection` | `apply_reflection` |
| Source episodic tag after reflection | `reflected` | `apply_reflection` |
| Eligible source notes | `type=episodic`, `scope=portable`, not tagged `reflected` | `select_unreflected` |
| Distilled note types | `semantic`, `procedural` | `REFLECT_SYSTEM_PROMPT`, `_parse_reflection` |
| Default input window | `max_tokens * 4` chars (480,000 at defaults) | `make_reflector`, `_window` |
| Request temperature | `0.2`, `stream: false` | `_http_client` |
| Default provider | `heuristic` (no LLM) | `resolve_reflection_config`, `resolve_summarizer` |
| Default timeout | `30` seconds | `resolve_reflection_config` |
| recall\@k defaults | k = 1, 3, 5, 8 | `recall_at_k`, `run_baseline` |
| Eval set path | `/eval/eval.jsonl` | `_eval_set_path` |
## Related [#related]
* [Capture and injection](./capture-and-injection) - where episodic notes come from and how the working set is injected.
* [Recall](./recall) - how `store.search` ranks, which is what the eval recall\@k metric measures.
* [Data model](./data-model) - note types, scopes, tags, and provenance fields.
* [Configuration](../reference/configuration) - the full environment-variable reference.
* [CLI](../reference/cli) - the `reflect` and `eval` subcommands and their flags.
* [Security](../reference/security) - redaction and what does and does not leave the machine.
# Cross-machine sync over git and Tailscale (/docs/internals/sync)
Anamnesis keeps a coding agent's memory in step across every machine you own. The mechanism is deliberately boring: markdown files in a git repo, pushed and pulled over your private Tailscale mesh, with a SQLite FTS5 index rebuilt locally on each machine after every pull. There is no central server, no cloud account, and no database file traveling over the wire.
This page is the reference for that layer. It is grounded in `server/src/anamnesis/sync.py` and its tests in `server/tests/test_sync.py`.
## Where memory lives on disk [#where-memory-lives-on-disk]
A store has a fixed layout under its root (default `~/.anamnesis`), described in `server/src/anamnesis/store.py`:
```text
~/.anamnesis/ # store root (MemoryStore.root)
memory/ # the git repo (MemoryStore.memory_dir) -- SYNCED
/.md # one markdown file per note, the source of truth
local/ # machine-local notes (MemoryStore.local_dir) -- NEVER SYNCED
/.md
index.db # derived SQLite index (WAL + FTS5) -- NEVER SYNCED
```
The single most important structural fact: the git repository is `memory/`, not the store root. `GitSyncBackend` is always constructed against `store.memory_dir`:
```python
backend = GitSyncBackend(store.memory_dir, remote=resolve_remote(), machine_id=resolve_machine_id())
```
Because `local/` and `index.db` live one level up at the store root, they are physically outside the git working tree. They cannot be staged, committed, or pushed, because git never sees them. This is enforced by topology, not by a `.gitignore` rule, which is a stronger guarantee. The test `test_index_db_is_never_tracked` asserts exactly this by running `git ls-files` and checking that `.db` never appears.
Markdown is the source of truth. The SQLite index is fully derived from it and can always be rebuilt with `MemoryStore.reindex()`. That asymmetry is what makes the sync model safe: there is exactly one authoritative copy of memory (the files), and it is the only thing that travels.
## The sync cycle [#the-sync-cycle]
`GitSyncBackend.sync()` runs one full cycle: commit local changes, integrate the remote, push, and report what happened. The server and CLI both wrap it with a reindex afterward (`sync_memory()` in `server.py`, `_run_sync()` in `cli.py`), so search stays in step with the files that just arrived.
### Step 1: commit local changes [#step-1-commit-local-changes]
```python
self._git("add", "-A")
committed = self._git("diff", "--cached", "--quiet", check=False).returncode != 0
if committed:
stamp = datetime.now(UTC).isoformat(timespec="seconds")
self._git("commit", "-m", f"anamnesis: sync from {self.machine_id} at {stamp}")
```
`git add -A` stages everything in `memory/`. `git diff --cached --quiet` returns a nonzero exit code when there is something staged, which is how the backend decides whether a commit is needed. The commit message is `anamnesis: sync from at `, for example `anamnesis: sync from desktop at 2026-06-24T14:03:00+00:00`.
Every commit is authored by a fixed anamnesis identity, regardless of your personal git config. `_git()` injects four environment variables on every invocation:
```python
ident = {
"GIT_AUTHOR_NAME": "anamnesis",
"GIT_AUTHOR_EMAIL": f"anamnesis@{self.machine_id}",
"GIT_COMMITTER_NAME": "anamnesis",
"GIT_COMMITTER_EMAIL": f"anamnesis@{self.machine_id}",
}
```
So author and committer name are both `anamnesis`, and the email encodes the originating machine, for example `anamnesis@desktop`. This keeps the history clean, machine-attributable, and independent of whatever `user.name` / `user.email` you happen to have set globally.
### Step 2: integrate the remote, then push [#step-2-integrate-the-remote-then-push]
If no remote is configured (the `--local-only` case), `sync()` stops here and returns `SyncResult(pushed=False, pulled=0, conflicted=False, head=, detail="committed locally; no remote configured")` (or `nothing to commit; no remote configured` when there was nothing staged).
With a remote, the backend fetches and then integrates `origin/main`. The branch is always `main` (the module constant `_BRANCH = "main"`):
```python
self._git("fetch", "origin", check=False)
pulled = 0
if self._remote_has_branch():
before = self._head()
if not self._has_commits():
self._git("reset", "--hard", f"origin/{_BRANCH}")
else:
rebase = self._git("rebase", f"origin/{_BRANCH}", check=False)
...
```
There are two integration paths, chosen by whether the local repo has any commits yet:
* **Fresh clone-equivalent (no local commits):** `git reset --hard origin/main`. A brand-new machine that has never committed simply adopts the remote history wholesale. There is nothing local to preserve, so a hard reset is correct and cheap. This is the path a second machine takes the first time it syncs (see `test_round_trip_write_on_A_appears_on_B`, where store B pulls A's commit).
* **Established machine (has local commits):** `git rebase origin/main`. Local commits are replayed on top of the remote tip, producing a linear history with no merge commits.
After integration, `pulled` is computed as the number of commits the local HEAD advanced by, using `git rev-list --count before..after`. This is the count surfaced to you as "how many commits did this pull bring in."
Finally the push:
```python
push = self._git("push", "-u", "origin", _BRANCH, check=False)
if push.returncode != 0:
raise SyncError(f"git push failed: {push.stderr.strip()}")
pushed = "up-to-date" not in (push.stderr + push.stdout).lower()
```
A failed push raises `SyncError` (an unrecoverable failure, surfaced to the caller). A successful push reports `pushed=True` unless git said the remote was already up to date, in which case `pushed=False`. On success the result is `SyncResult(pushed, pulled, conflicted=False, head=, detail="synced")`.
### Step 3: reindex (in the callers) [#step-3-reindex-in-the-callers]
`sync()` itself does not touch the SQLite index; that is the caller's job, done immediately after a sync so search reflects the files that just arrived:
```python
# server/src/anamnesis/server.py
r = backend.sync()
indexed = store.reindex()
```
```python
# server/src/anamnesis/cli.py
result = backend.sync()
store.reindex()
```
`reindex()` walks both trees (`memory/` as `portable`, `local/` as `machine-local`), reads every `*.md`, and rebuilds the FTS5 tables from scratch. The index is disposable by design, so a rebuild after every pull is the normal, safe operation. `test_round_trip_write_on_A_appears_on_B` shows the full chain: write on A, push, pull into B, `store_b.reindex()` returns `1`, and the note is then searchable on B.
## The conflict policy: surface, never silently drop [#the-conflict-policy-surface-never-silently-drop]
The single hardest part of any sync system is divergent edits to the same file. Anamnesis's v0 policy is intentionally simple and safe: **if a rebase cannot apply cleanly, abort it, keep the local edits exactly as they are, and report the conflict back to the caller. Never silently drop a change.**
```python
rebase = self._git("rebase", f"origin/{_BRANCH}", check=False)
if rebase.returncode != 0:
# v0 policy: surface the conflict, never silently drop. Abort the
# rebase (local edits stay in place) and leave it for resolution.
self._git("rebase", "--abort", check=False)
return SyncResult(
False,
0,
True,
self._head(),
"conflict on rebase; kept local edits, did not push - resolve and re-sync",
)
```
When this fires:
* `git rebase --abort` returns the working tree to its pre-rebase state, so your local edit is still on disk, untouched.
* The result is `SyncResult(pushed=False, pulled=0, conflicted=True, head=, detail="conflict on rebase; kept local edits, did not push - resolve and re-sync")`.
* Nothing is pushed. The remote is left as it was, so the other machine's already-pushed version is also intact.
This is the contract that `test_conflicting_edit_is_surfaced_not_silently_dropped` pins down. Two machines edit the same note file divergently. Desktop pushes first and wins the race to the remote. The laptop then syncs, its rebase fails, and the test asserts both `res_b.conflicted` is `True` and that `"laptop edit"` is still present in the file. The local edit survives.
A `conflicted` result is not an error and does not raise. It is a normal return value telling you a human (or the dashboard) needs to reconcile the two versions before the next sync can push. Until then your local edit is safe but not shared. Resolve the divergence in `memory/`, then run sync again.
The two structures that carry this information out of the backend:
```python
@dataclass
class SyncResult:
pushed: bool # did this cycle push new commits to the remote?
pulled: int # how many commits did integrating origin/main bring in?
conflicted: bool # did a rebase conflict abort the push?
head: str # short SHA of local HEAD after the cycle
detail: str # human-readable summary
@dataclass
class SyncState:
initialized: bool # is memory/ a git repo yet?
remote: str | None # the configured remote URL, or None for local-only
head: str # short SHA, or "" before the first commit
dirty: bool # are there uncommitted changes in memory/?
detail: str
```
`SyncState` is what `memory_status` surfaces. `state()` returns `SyncState(False, remote, "", False, "not initialized")` before `memory/` is a git repo, and otherwise reports the live remote, short HEAD, and whether the working tree is dirty (computed from `git status --porcelain`).
## Initialization [#initialization]
`init()` is idempotent and is called automatically on the first `sync()` if `memory/` is not yet a repo:
```python
def init(self) -> None:
if not self._is_git():
self._git("init", "-b", _BRANCH)
if self.remote is not None:
if self._git("remote", "get-url", "origin", check=False).returncode == 0:
self._git("remote", "set-url", "origin", self.remote)
else:
self._git("remote", "add", "origin", self.remote)
```
It initializes the repo on branch `main`, and either adds `origin` or updates it to the configured remote. Re-running it is safe: an existing repo is left alone, and an existing `origin` is repointed rather than duplicated. This is why re-running `anamnesis init` to add or change a remote later "just works" without any other changes.
## Topology: a bare repo on your tailnet [#topology-a-bare-repo-on-your-tailnet]
There is no Anamnesis server in the loop. Sync is plain git over SSH to a bare repository hosted on one always-on machine in your [Tailscale](https://tailscale.com) mesh (a desktop, a home server, a NAS). Tailscale gives every machine a stable MagicDNS name on a private, encrypted network, so the remote URL is reachable from anywhere without exposing anything to the public internet.
Setting this up once, per the README:
1. Put every machine on the same tailnet (`tailscale up`), and pick one always-on machine to host the shared repo. Note its MagicDNS name from `tailscale status`, for example `host.your-tailnet.ts.net`.
2. Create one shared bare repo on that host:
```bash
git init --bare -b main ~/anamnesis-memory.git
```
3. Point each machine at it when you run the installer:
```bash
uv run anamnesis init --remote 'you@host.your-tailnet.ts.net:anamnesis-memory.git'
```
The host node itself can use a local path instead of an SSH URL:
```bash
uv run anamnesis init --remote "$HOME/anamnesis-memory.git"
```
The bare repo is the rendezvous point: every machine pushes its commits there and fetches everyone else's. Because the branch is always `main` and integration is rebase-only, the shared history stays linear.
The tests prove this is genuinely a plain-git design with no Anamnesis-specific server. `test_sync.py` stands up a real `git init --bare -b main` repo via `_bare_remote()` as the remote, so the entire round trip (write on A, push, pull into B, reindex, search on B) runs hermetically with no network and no daemon. The bare repo in production is the same thing, just reachable over your tailnet.
To work on a single machine for now, run `anamnesis init --local-only` and add a remote later by re-running `init`. In that mode `sync()` commits locally and returns immediately with `pushed=False`.
## Why the database is never synced [#why-the-database-is-never-synced]
It would be tempting to sync `index.db` directly and skip the rebuild. Anamnesis deliberately does not, and this is a load-bearing architecture decision, not an oversight.
A SQLite database is not a single logical value that merges cleanly. It is binary pages plus a write-ahead log, and concurrent writers expect file locks they can actually take. Syncing that file through a folder-sync mechanism (Dropbox, iCloud, a naive git of the binary) interleaves partial writes from two machines and corrupts the database. That is the "claude-brain corruption lesson" the codebase repeatedly refers to, and it is why the project rule is unambiguous: never sync the raw DB file.
Anamnesis sidesteps the whole problem by syncing only the markdown source of truth and treating the index as a local, disposable cache:
* `index.db` lives at the store root, outside the `memory/` git tree, so git cannot pick it up.
* After every pull, the caller runs `reindex()` to rebuild the FTS5 index from the freshly arrived markdown.
* If an index is ever damaged or stale, deleting it and reindexing recovers fully, because the files are authoritative.
`test_durability_over_many_sync_cycles` is the guard rail here: it runs 24 consecutive write -> push -> pull -> reindex cycles across two stores and asserts that all notes converge on both machines and that the files are uncorrupted at the end (`store_b.get(...).body == "durable body 0"`). The index is rebuilt every cycle and never drifts from the files.
Do not put `index.db` inside `memory/`, and do not configure any folder-sync tool to mirror it between machines. The index is derived state. Syncing it is the exact failure mode this design exists to avoid.
## Sync evolution: git now, more only if needed [#sync-evolution-git-now-more-only-if-needed]
Git-as-sync is the v0 layer, chosen because it is simple, already battle-tested, version-controlled, human-readable, and good enough for a single user's own fleet. The backend is intentionally pluggable: `SyncBackend` is a `Protocol` with just `init()`, `sync()`, and `state()`, and `GitSyncBackend` is one implementation of it.
```python
class SyncBackend(Protocol):
"""Pluggable sync mechanism (git-over-Tailscale today; P2P/libSQL later)."""
def init(self) -> None: ...
def sync(self) -> SyncResult: ...
def state(self) -> SyncState: ...
```
That seam exists so the sync mechanism can evolve without touching the server, the CLI, or the dashboard, all of which depend only on the `SyncBackend` protocol and the `SyncResult` / `SyncState` shapes:
* **Now:** git over Tailscale (this page).
* **Later, only if and when it is actually needed:** a Turso / libSQL embedded-replica path, or direct peer-to-peer sync, could slot in as another `SyncBackend`.
* **CRDTs:** considered only if true concurrent multi-writer editing ever becomes a real requirement, which the single-user, one-machine-at-a-time usage pattern does not currently demand.
The guiding principle is to stay local-first and simple, and not to introduce a database server, a graph DB, or heavier sync machinery "just in case." The current design earns its keep; anything more waits for evidence that it is needed.
## Surfaces that drive sync [#surfaces-that-drive-sync]
The same backend is reached three ways, all running the identical `sync()` plus reindex:
* `memory_sync` MCP tool (a write tool, not auto-approved), returning `pushed`, `pulled`, `conflicted`, `head`, `indexed`, and `detail`.
* `anamnesis sync` CLI command, printing `sync: pushed=... pulled=... conflicted=... head=... (detail)`.
* The SessionStart background sync hook, so a note written on one machine is searchable on the others by the next session with no manual step.
## Related pages [#related-pages]
* [Architecture overview](./architecture)
* [Data model and note format](./data-model)
* [Recall: FTS5 and BM25](./recall)
* [The MCP server](./mcp-server)
* [Capture and injection hooks](./capture-and-injection)
# CLI reference (/docs/reference/cli)
The `anamnesis` command is a single console entry point (`anamnesis.cli:main`) that dispatches a
subcommand. It is deliberately kept free of FastMCP outside of `serve`, so the hooks that Claude Code
calls on the hot path (`inject`, `capture`, `sync`) run without the optional `mcp` extra installed.
When you give no subcommand, it defaults to `serve` (the MCP server over stdio). Everything else is an
explicit verb.
## Invocation [#invocation]
The fastest path is the one-line install, which is live on PyPI:
```bash
uv tool install anamnesis-memory && anamnesis init
```
The PyPI distribution name is `anamnesis-memory`; the installed command stays `anamnesis`. After this you can
run `anamnesis [flags]` directly.
For contributors and local development, run the CLI from the editable checkout instead:
```bash
cd server
uv venv --python 3.12
uv pip install -e ".[mcp,dev]"
uv run anamnesis [flags]
```
The store lives at `~/.anamnesis` by default (`ANAMNESIS_HOME` overrides it). Inside it: `memory/`
holds the markdown source of truth (one file per note, `memory//.md`), `local/` holds
machine-local notes, `index.db` is the derived SQLite index (WAL + FTS5, rebuilt locally and never
synced), and `config.json` holds machine-local config (`machine_id`, `remote`) written by `init`.
## Command map [#command-map]
## Subcommand summary [#subcommand-summary]
| Command | One-line behavior | Mutates the store? |
| --------------------- | --------------------------------------------------------------------------------- | ------------------------- |
| `serve` (default) | Run the MCP server over stdio. | No (read/write via tools) |
| `sync` | Run one git sync cycle (import native, commit, pull --rebase, push) then reindex. | Yes (commits, may pull) |
| `reindex` | Rebuild the SQLite index from markdown. No git. | Index only |
| `status` | Print store stats and sync state. | No |
| `inject` | Print top notes for the project as SessionStart context. | No |
| `capture` | Write an episodic note from a transcript, then sync. | Yes |
| `import` | Import Claude Code's native per-project memory, then sync. | Yes |
| `migrate` | Re-key note projects from a JSON map. | Yes, with `--apply` |
| `dedup` | Remove notes with a byte-identical body. | Yes, with `--apply` |
| `backfill-provenance` | Infer `prov_source` from tags and rewrite front-matter. | Yes, with `--apply` |
| `reflect` | Distill episodic notes into durable notes via the LLM. | Yes, with `--apply` |
| `eval build` | Generate candidate eval cases via the LLM. | Eval set file only |
| `eval run` | Report recall\@k + inject token size on the current store. | No |
| `eval experiment` | Before/after reflect on a sandbox copy. | No (sandbox only) |
| `init` | Configure Claude Code (MCP + hooks), the store, and first sync. | Config + first sync |
Four commands are dry-run by default and write nothing until you pass `--apply`: `migrate`, `dedup`,
`backfill-provenance`, and `reflect`. Without `--apply` they only print the changes they would make.
## serve (default) [#serve-default]
Run the MCP server over stdio. This is the only path that imports FastMCP (lazily, inside `cmd_serve`),
so the hook commands stay dependency-light.
```bash
uv run anamnesis serve
# or simply:
uv run anamnesis
```
It builds a `MemoryStore` over `ANAMNESIS_HOME` and runs the server, exposing the MCP tools
(`memory_search`, `memory_list`, `memory_status`, `memory_write`, `memory_sync`). In normal use you do
not run this by hand; `anamnesis init` registers it with Claude Code via `claude mcp add`. No flags.
## sync [#sync]
One full sync cycle, then reindex.
```bash
uv run anamnesis sync
```
The cycle (`_run_sync`) is: mirror Claude Code's native memory into the store (best-effort; disable with
`ANAMNESIS_IMPORT_NATIVE=0`), then `git add -A`, commit if dirty, `fetch`, `pull --rebase` against
`origin/main`, `push`, and finally `store.reindex()` to rebuild the FTS5 index from the pulled markdown.
Output reports the `SyncResult` fields: `pushed`, `pulled`, `conflicted`, `head` (short SHA), and a
`detail` string, for example:
```
sync: pushed=True pulled=2 conflicted=False head=3237e8f (synced)
```
Conflict policy is fail-loud, not fail-silent. If the rebase conflicts, the backend aborts the rebase,
keeps your local edits in place, does not push, and returns `conflicted=True` with the detail
`conflict on rebase; kept local edits, did not push - resolve and re-sync`. Resolve the conflict in
`~/.anamnesis/memory/` and run `sync` again. No flags.
## reindex [#reindex]
Rebuild the derived SQLite index from the markdown source of truth, without touching git.
```bash
uv run anamnesis reindex
```
This is the cheap, safe operation the dashboard calls after writing markdown directly: it refreshes the
FTS5 index without a sync. It prints the number of notes indexed (`reindex: indexed N note(s)`). Sync
stays a separate, explicit step. No flags.
## status [#status]
Print store statistics and the current sync state.
```bash
uv run anamnesis status
```
Output:
```
store: /home/you/.anamnesis
notes: 142 by_type={'semantic': 30, 'procedural': 12, 'episodic': 100} by_scope={'portable': 138, 'machine-local': 4}
sync: initialized=True remote=you@host.ts.net:anamnesis-memory.git head=3237e8f dirty=False (ok)
```
It reads `StoreStats` (`total`, `by_type`, `by_scope`) and the `SyncState`
(`initialized`, `remote`, `head`, `dirty`, `detail`). No flags.
## inject [#inject]
Print the top notes for a project as a markdown block, for the SessionStart hook to inject as context.
```bash
uv run anamnesis inject --project github.com/oscardvs/anamnesis --k 8
```
| Flag | Default | Behavior |
| ----------- | ------- | --------------------------------------------------------------------------------------------------------- |
| `--project` | `None` | Project key to inject for. When omitted, derived from the hook payload's `cwd` via `resolve_project_key`. |
| `--k` | `8` | Project-note budget (global notes are always included in full, on top of this budget). |
Selection (`select_inject`) returns all `global` notes in full, plus up to `k` project notes:
recent durable (`procedural`/`semantic`) notes fill the budget first, reserving up to two
(`_MAX_EPISODIC = 2`) of the most recent episodic notes for the "what I last did" thread. Superseded
notes are hidden, already-reflected episodics are dropped, and confidence breaks recency ties. When
there are no notes, it prints nothing.
The project key (`resolve_project_key`) is resolved in this order: a `.anamnesis/project` marker file
searched up-tree (stopping below `$HOME`), else the normalized `origin` git remote, else the repo-root
directory name, else the cwd basename (lowercased; `global` if empty).
## capture [#capture]
Write an episodic note from a Claude Code transcript, then sync unless told not to. This is the
SessionEnd and PreCompact hook.
```bash
uv run anamnesis capture --transcript /path/to/session.jsonl --source session-end
```
| Flag | Default | Behavior |
| -------------- | ------------- | ----------------------------------------------------------------------------------------------------- |
| `--transcript` | `None` | Path to the transcript JSONL. Falls back to the hook payload's `transcript_path`. |
| `--project` | `None` | Project key. Falls back to the transcript's `cwd`, then the payload `cwd`, via `resolve_project_key`. |
| `--source` | `session-end` | Recorded as a tag on the note. The PreCompact hook passes `precompact`. |
| `--no-sync` | off | Write the note but do not run a sync cycle. |
`parse_transcript` deterministically extracts the first user prompt, last assistant outcome, files
touched (from `Edit`/`Write`/`MultiEdit`/`NotebookEdit` tool calls), git branch, cwd, and session id.
`write_episodic` skips trivial sessions (no files touched plus empty/short outcome, or a lone slash
command) and otherwise calls the configured summarizer. Both `session-end` and `precompact` are stamped
with `prov_source=session-end`; the `--source` value is added as a tag alongside `session`.
The summarizer is selected by `ANAMNESIS_REFLECTION_PROVIDER` (default `heuristic`, which needs no API
key and produces a deterministic note). With `deepseek`, `openai`, or `local`, an OpenAI-compatible LLM
summarizer is used; any LLM failure falls back to the heuristic so capture never breaks session
teardown. The PreCompact hook uses `--source precompact --no-sync` so a mid-session compaction does not
trigger a network sync.
## import [#import]
Import Claude Code's native per-project memory into the store, then sync unless `--no-sync`.
```bash
uv run anamnesis import --claude-home ~/.claude
```
| Flag | Default | Behavior |
| --------------- | ------- | -------------------------------------------------------------------------------------- |
| `--claude-home` | `None` | Claude config dir to import from. Falls back to `CLAUDE_CONFIG_DIR`, else `~/.claude`. |
| `--no-sync` | off | Import only, do not sync afterward. |
This is the explicit, one-shot entry point and the way to seed a machine the first time. The same import
runs automatically at the start of every sync cycle (best-effort; disabled by
`ANAMNESIS_IMPORT_NATIVE=0`). Output reports `imported`, `updated`, and `skipped` counts plus the source
directory.
## migrate [#migrate]
Re-key note `project` fields from a JSON map. Dry-run unless `--apply`.
```bash
# Preview (writes nothing):
uv run anamnesis migrate --map /path/to/map.json
# Apply:
uv run anamnesis migrate --map /path/to/map.json --apply
```
| Flag | Default | Behavior |
| ----------- | -------- | ---------------------------------------------------------------------------------------- |
| `--map` | required | Path to the JSON map (see below). |
| `--apply` | off | Without it, dry-run: prints each note that would change. With it, rewrites the markdown. |
| `--no-sync` | off | After `--apply`, reindex but do not sync (otherwise it syncs). |
The map JSON has two optional keys: `projects` (old key -> new key, applied to any note in that project)
and `notes` (note id -> new key, a per-note override that wins over `projects`):
```json
{
"projects": { "anamnesis": "github.com/oscardvs/anamnesis" },
"notes": { "01J...": "global" }
}
```
Only the front-matter `project:` line changes; body, `updated_at`, and every other field are preserved,
so `git diff` shows exactly one line per note and the memory repo's history is the undo. Notes with no
mapping, or already at their target, are skipped.
## dedup [#dedup]
Collapse notes whose body is byte-identical to one keeper. Dry-run unless `--apply`.
```bash
uv run anamnesis dedup # preview
uv run anamnesis dedup --apply # delete duplicates
```
| Flag | Default | Behavior |
| ----------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `--apply` | off | Without it, dry-run: prints each removal that would happen. With it, deletes the duplicate markdown files. |
| `--no-sync` | off | After `--apply`, reindex but do not sync. |
Notes are grouped by a SHA-256 hash of their stripped body (project is deliberately excluded, so
cross-project and cross-machine duplicates collapse). It keeps one per group, preferring `global` notes,
then the earliest `created_at`, then the lowest id. It operates on the synced `memory/` tree only;
machine-local notes in `local/` are left alone. Reversible via the memory repo's git history.
## backfill-provenance [#backfill-provenance]
Infer `prov_source` from a note's tags and rewrite its front-matter. Dry-run unless `--apply`.
```bash
uv run anamnesis backfill-provenance # preview
uv run anamnesis backfill-provenance --apply # rewrite front-matter
```
| Flag | Default | Behavior |
| ----------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `--apply` | off | Without it, dry-run: prints each note whose inferred `prov_source` differs. With it, rewrites the front-matter. |
| `--no-sync` | off | After `--apply`, reindex but do not sync. |
Inference precedence (`_infer_source`): an `import` tag yields `prov_source=import`; else a `session` tag
yields `session-end`; else `human`. It scans both `memory/` (portable) and `local/` (machine-local).
One-time and reversible via git. Notes already carrying the inferred value are left untouched.
## reflect [#reflect]
Distill a project's un-reflected episodic notes into durable semantic/procedural notes via the
configured LLM. Dry-run unless `--apply`.
```bash
# Preview across all projects:
uv run anamnesis reflect
# Apply for one project:
uv run anamnesis reflect --project github.com/oscardvs/anamnesis --apply
```
| Flag | Default | Behavior |
| ----------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
| `--project` | `None` | Reflect a single project. When omitted, reflects every project that has portable episodic notes. |
| `--apply` | off | Without it, dry-run: prints how many episodics would be distilled per project. With it, calls the LLM and writes notes. |
| `--no-sync` | off | After `--apply`, reindex but do not sync. |
A project is reflected only when its un-reflected episodic count reaches
`ANAMNESIS_REFLECT_MIN_EPISODICS` (default `5`). With `--apply`, a reflection provider must be
configured (`ANAMNESIS_REFLECTION_PROVIDER` plus model/base-url/key) or the command prints a message and
exits without writing. Distilled notes are written with `prov_source=reflection`, a confidence of `0.6`,
and a `reflection` tag, so they are clearly reviewable; the source episodics are then tagged `reflected`
so they are excluded from injection and from the next reflection pass. There is no fallback: a failed or
invalid LLM response aborts that one project (the others still run) rather than fabricating a note.
`reflect --no-sync` writes notes and reindexes but does not commit. A concurrent sync (for example a
SessionStart background sync) can overwrite uncommitted output. Prefer `--apply` without `--no-sync` so
the new notes are committed and pushed in the same run, or commit immediately after.
## eval [#eval]
The measurement harness: retrieval recall and working-set (inject token) size. It has three
subcommands, dispatched on the second positional argument.
```bash
uv run anamnesis eval build
uv run anamnesis eval run
uv run anamnesis eval experiment
```
The eval set defaults to `/eval/eval.jsonl` (`ANAMNESIS_HOME/eval/eval.jsonl`); `--eval-set`
overrides the path. It is JSONL, one `EvalCase` per line with fields `query`, `relevant_ids`,
`note_titles`, `approved`, and `source`.
### eval build [#eval-build]
Generate candidate eval cases via the LLM: one paraphrased query per sampled note.
```bash
uv run anamnesis eval build --types semantic,procedural --n 30
```
| Flag | Default | Behavior |
| ------------ | ------------------------ | ------------------------------------------ |
| `--eval-set` | `/eval/eval.jsonl` | Output JSONL path. |
| `--types` | `semantic,procedural` | Comma-separated note types to sample from. |
| `--n` | `30` | Number of candidate cases to generate. |
It samples notes round-robin across projects for coverage, asks the LLM for a paraphrased query per
note, and appends candidates with `approved=false` (skipping queries already present). You then curate
the file (set `approved=true`) before running. Requires a reflection provider; without one it prints a
message and exits.
### eval run [#eval-run]
Report recall\@k and inject token size on the current store.
```bash
uv run anamnesis eval run
uv run anamnesis eval run --json --include-unreviewed
```
| Flag | Default | Behavior |
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------- |
| `--eval-set` | `/eval/eval.jsonl` | Eval set path. Exits with code 2 if it does not exist. |
| `--include-unreviewed` | off | Include cases with `approved=false` (otherwise only approved cases are scored). |
| `--json` | off | Emit machine-readable JSON instead of the text report. |
It computes recall\@k and MRR over `ks = (1, 3, 5, 8)` using `store.search`, and a working-set report:
per-project inject-block token size (`~4 chars/token`), mean and median across non-global projects, plus
a full-corpus token denominator. Missing relevant ids produce warnings, not errors.
### eval experiment [#eval-experiment]
Measure recall and working set before and after `reflect --apply`, on a throwaway sandbox copy.
```bash
uv run anamnesis eval experiment
```
| Flag | Default | Behavior |
| ---------------------- | ------------------------ | ------------------------------------------------------ |
| `--eval-set` | `/eval/eval.jsonl` | Eval set path. Exits with code 2 if it does not exist. |
| `--include-unreviewed` | off | Include unapproved cases. |
It copies `memory/` and `local/` into a temp directory, builds a fresh store over the copy, measures the
baseline, runs reflection there, and measures again. The live store is never touched. The report shows
the inject-token delta, recall\@k before/after with a `REGRESSION` flag per k, MRR, and how many projects
were reflected, skipped (below threshold), or failed. Requires a reflection provider.
## init [#init]
Configure Claude Code on this machine (MCP server + lifecycle hooks), write store config, and run a
first sync. Idempotent: it backs up `settings.json` to `settings.json.bak` and never duplicates a hook.
```bash
# Interactive (prompts for store dir, machine id, remote, command form):
uv run anamnesis init
# Dry-run, prints the full plan and writes nothing:
uv run anamnesis init --print
# Non-interactive with a remote:
uv run anamnesis init --yes --remote 'you@host.ts.net:anamnesis-memory.git'
```
### All flags [#all-flags]
| Flag | Default | Behavior |
| -------------- | -------------- | -------------------------------------------------------------------------------------------------------- |
| `--home` | `~/.anamnesis` | Store root. Passed into config only when it differs from the default. |
| `--machine-id` | hostname | This machine's id (embedded in hook/MCP env and `config.json`). |
| `--remote` | prompt / none | Sync remote URL. Mutually exclusive with `--local-only`. |
| `--local-only` | off | Configure with no remote. Mutually exclusive with `--remote`. |
| `--command` | autodetected | Override the base argv used to invoke `anamnesis` (shell-split). Mutually exclusive with `--uv-project`. |
| `--uv-project` | autodetected | Invoke via `uv run --project anamnesis`. Mutually exclusive with `--command`. |
| `--name` | `anamnesis` | MCP server registration name. |
| `--no-mcp` | off | Skip registering the MCP server. |
| `--no-hooks` | off | Skip installing the lifecycle hooks. |
| `--no-sync` | off | Skip the first sync. |
| `--yes` | off | Non-interactive: take all defaults, no prompts. |
| `--print` | off | Dry-run: print the plan and exit without writing anything. |
### What it does [#what-it-does]
The command form is resolved in order: explicit `--command`; then `--uv-project`; then an installed
`anamnesis` found on `PATH`; else a `uv run --project anamnesis` fallback. Hooks are written
to `CLAUDE_CONFIG_DIR/settings.json` (or `~/.claude/settings.json`):
* **SessionStart** (`startup|resume|clear`): `inject`, timeout 15s.
* **SessionStart** (`startup|resume`): `sync`, async (background).
* **SessionEnd**: `capture`, timeout 120s.
* **PreCompact**: `capture --source precompact --no-sync`, timeout 60s.
The MCP server is registered at user scope over stdio via `claude mcp add` (after a best-effort
`claude mcp remove` for idempotency), with the `ANAMNESIS_*` env inlined. If `claude` is not on `PATH`,
`init` prints the exact command for you to run instead. The machine-local `config.json` (`machine_id`
plus `remote`) lets the MCP server and dashboard find the remote even when launched without inline env.
`init` only writes the configured `ANAMNESIS_*` env when set: `ANAMNESIS_MACHINE_ID` always,
`ANAMNESIS_GIT_REMOTE` when a remote is given, and `ANAMNESIS_HOME` only when home differs from
`~/.anamnesis`. After running it, start a new Claude Code session for the MCP server and hooks to take
effect. A first sync that fails (bad remote) does not abort `init`: it prints a message telling you to
fix the remote and run `anamnesis sync`.
## Environment variables used by the CLI [#environment-variables-used-by-the-cli]
| Variable | Used by | Default |
| --------------------------------- | --------------------------------- | ------------------------------------------------------- |
| `ANAMNESIS_HOME` | all | `~/.anamnesis` |
| `ANAMNESIS_MACHINE_ID` | all (origin stamp) | store config, else hostname |
| `ANAMNESIS_GIT_REMOTE` | `sync`, `capture`, `import`, etc. | store config, else none |
| `CLAUDE_CONFIG_DIR` | `import`, `init` | `~/.claude` |
| `ANAMNESIS_IMPORT_NATIVE` | every sync cycle | `1` (set `0` to disable native import) |
| `ANAMNESIS_REFLECTION_PROVIDER` | `capture`, `reflect`, `eval` | `heuristic` |
| `ANAMNESIS_REFLECTION_MODEL` | LLM path | (none) |
| `ANAMNESIS_REFLECTION_BASE_URL` | LLM path | (none) |
| `ANAMNESIS_REFLECTION_API_KEY` | LLM path | falls back to `DEEPSEEK_API_KEY`, then `OPENAI_API_KEY` |
| `ANAMNESIS_REFLECTION_TIMEOUT` | LLM path | `30` (seconds) |
| `ANAMNESIS_REFLECTION_MAX_TOKENS` | LLM path | `120000` |
| `ANAMNESIS_REFLECT_MIN_EPISODICS` | `reflect`, `eval experiment` | `5` |
See the configuration reference for the full set.
## Related [#related]
* [Configuration reference](./configuration)
* [MCP tools reference](./mcp-tools)
* [Capture and injection internals](../internals/capture-and-injection)
* [Reflection internals](../internals/reflection)
* [Sync internals](../internals/sync)
# Configuration and environment (/docs/reference/configuration)
Anamnesis is configured entirely through environment variables and two small JSON files. There is no central config file you hand-edit for behavior: defaults are baked into the code, the environment overrides them, and a machine-local `config.json` (written by `anamnesis init`) acts as a fallback so the MCP server and dashboard can find your sync remote even when no shell environment reaches them.
This page is the canonical reference for every knob. Every variable name, default, and threshold below is read directly from the source: `server/src/anamnesis/config.py`, `server/src/anamnesis/llm_summarizer.py`, `server/src/anamnesis/reflect.py`, `server/src/anamnesis/onboard.py`, and `.env.example`.
## How configuration resolves [#how-configuration-resolves]
Three layers feed every setting, in this precedence order:
The `config.json` fallback only exists for two values, `machine_id` and `remote`. Everything else resolves from the environment or a hardcoded default. The reflection settings have no `config.json` fallback at all: they come from the environment or they fall back to the deterministic heuristic.
When Claude Code launches the MCP server it uses a **filtered environment**. Your interactive shell variables (`.zshrc`, `.bashrc`, exported `ANAMNESIS_*`) are **not** inherited by the server process. This is why the inline `env` block in the MCP registration and the machine-local `config.json` exist. See [The filtered-environment caveat](#the-filtered-environment-caveat) below.
## Store and identity variables [#store-and-identity-variables]
These are read in `config.py` and control where memory lives, what machine stamp goes on notes you write, and where to sync.
| Variable | Default | Purpose |
| ---------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANAMNESIS_HOME` | `~/.anamnesis` | Root of the local store. Holds `memory/` (synced markdown, the source of truth), `local/` (machine-local markdown, never synced), `index.db` (the derived SQLite FTS5 index), and `config.json`. A leading `~` is expanded. |
| `ANAMNESIS_MACHINE_ID` | this host's `socket.gethostname()` | The machine-of-origin stamped on every note you write. Resolution order is env, then `config.json` `machine_id`, then hostname, then the literal `"unknown"`. |
| `ANAMNESIS_GIT_REMOTE` | unset (local-only) | Git remote used to sync the markdown store across your machines. Resolution order is env, then `config.json` `remote`, then `None` (commit locally, never push). With Tailscale this is typically an SSH path to another node, for example `git@desktop.tailnet-name.ts.net:anamnesis-memory.git`. |
| `CLAUDE_CONFIG_DIR` | `~/.claude` | Claude Code's config directory. Its `projects//memory` trees hold Claude's native per-project memory, which the importer mirrors into the Anamnesis store. `anamnesis init` writes hooks into `/settings.json`. |
The store layout that `ANAMNESIS_HOME` points at:
```text
~/.anamnesis/
memory/ # SOURCE OF TRUTH - synced markdown, one file per note
local/ # machine-local markdown, kept OUT of the synced memory/ tree
index.db # DERIVED - SQLite (WAL mode, FTS5), rebuildable any time
config.json # machine-local: machine_id + remote (never synced)
```
`index.db` is derived and never synced. It is rebuilt from the markdown with `anamnesis reindex` (or automatically after every sync). Markdown in `memory/` is the only source of truth. Do not sync `index.db` across machines.
### Resolution functions [#resolution-functions]
For the precise behavior, these are the resolver functions in `config.py`:
* `resolve_home()` reads `ANAMNESIS_HOME`, else `~/.anamnesis`.
* `resolve_claude_home()` reads `CLAUDE_CONFIG_DIR`, else `~/.claude`.
* `resolve_machine_id()` reads `ANAMNESIS_MACHINE_ID`, else `config.json` `machine_id`, else `socket.gethostname()`, else `"unknown"`.
* `resolve_remote()` reads `ANAMNESIS_GIT_REMOTE`, else `config.json` `remote`, else `None`.
## The machine-local config.json fallback [#the-machine-local-configjson-fallback]
`anamnesis init` writes a small `config.json` at `/config.json` (via `write_store_config` in `onboard.py`). It lives **outside** the synced `memory/` repo on purpose: the remote URL differs per machine, so it must never be committed or synced.
It holds at most two string keys:
```json
{
"machine_id": "desktop-amsterdam",
"remote": "git@desktop.tailnet-name.ts.net:anamnesis-memory.git"
}
```
The `remote` key is only written when a remote was configured (local-only installs omit it). A missing or malformed file is read as `{}`, so resolution never fails on a bad config (the `_store_config()` reader swallows `OSError` and `ValueError`).
Why it matters: the MCP server is launched without any inline `ANAMNESIS_GIT_REMOTE` in the bare `.mcp.json` form, and the dashboard is a separate process. The `config.json` fallback is what lets an in-session `memory_sync` actually **push** to your remote rather than only committing locally. Environment variables still take precedence over `config.json`.
## Reflection and summarizer variables [#reflection-and-summarizer-variables]
The reflection (compression) model is the swappable LLM that distills sessions into notes. It is read by `resolve_reflection_config()` in `llm_summarizer.py` and reused by `reflect.py`. Nothing about any provider is hardcoded: provider, model, base URL, and key all come from the environment.
| Variable | Default | Purpose |
| --------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANAMNESIS_REFLECTION_PROVIDER` | `heuristic` | Provider label, lowercased. `heuristic` (or unset) uses the deterministic builder with no network call. A cloud value like `deepseek`, `openai`, or `local` is used only as a label (it becomes the `prov_model` stamp as `/`); it does not select an SDK. |
| `ANAMNESIS_REFLECTION_MODEL` | empty | The provider's model id, for example a DeepSeek or OpenAI chat model. Required to enable the LLM path. |
| `ANAMNESIS_REFLECTION_BASE_URL` | empty | An OpenAI-compatible base URL, for example `https://api.deepseek.com`. The client POSTs to `/chat/completions`. Required to enable the LLM path. |
| `ANAMNESIS_REFLECTION_TIMEOUT` | `30` | HTTP timeout in seconds for the chat completion. SessionEnd capture runs inline, so this bounds how long teardown can block. Parsed as a float; a non-numeric value falls back to `30.0`. |
| `ANAMNESIS_REFLECTION_MAX_TOKENS` | `120000` | Transcript size budget in tokens. The transcript is windowed to `max_tokens * 4` characters (the approximate 4 chars-per-token ratio) before being sent. Parsed as an int; a non-numeric value falls back to `120000`. |
| `ANAMNESIS_REFLECTION_API_KEY` | empty | Provider-neutral bearer token. Sent as `Authorization: Bearer `. First in the key fallback chain. |
| `DEEPSEEK_API_KEY` | empty | Fallback API key, second in the chain (after `ANAMNESIS_REFLECTION_API_KEY`). |
| `OPENAI_API_KEY` | empty | Fallback API key, third (last) in the chain. |
The API key resolves by trying each name in order and taking the first non-empty value:
### When the LLM path activates [#when-the-llm-path-activates]
The LLM summarizer and the reflector both require **all three** of model, base URL, and key to be present. If any is missing, the summarizer (`make_llm_summarizer`) silently returns the deterministic `HeuristicSummarizer`, and the reflector (`make_reflector`) returns `None`.
Two important behavioral differences between the two consumers of this config:
* **Capture (SessionEnd) never breaks.** `LLMSummarizer.summarize` catches every exception, prints `capture: llm summary failed (...); using heuristic` to stderr, and falls back to the heuristic builder. Session teardown always succeeds.
* **Reflect has no fallback.** A failed or invalid LLM response aborts that project's reflection rather than fabricating a note. In `anamnesis reflect`, one project failing prints `reflect: : failed (...); skipped` and the run continues to the next project.
Setting `ANAMNESIS_REFLECTION_PROVIDER` alone does nothing. The provider string is just a label. You must also set `ANAMNESIS_REFLECTION_MODEL`, `ANAMNESIS_REFLECTION_BASE_URL`, and one of the API key variables, or the LLM path stays off.
The chat request is fixed at `temperature: 0.2` and `stream: false`, sent over stdlib `urllib` (no extra dependency in the base hook install). The system prompts forbid emitting secrets, keys, tokens, or credentials, and the transcript is redacted and size-windowed before the request.
### Reflection threshold [#reflection-threshold]
| Variable | Default | Purpose |
| --------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANAMNESIS_REFLECT_MIN_EPISODICS` | `5` | Minimum number of un-reflected episodic notes a project must have before `anamnesis reflect` will distill it. Read by `resolve_min_episodics()` in `reflect.py`. A non-numeric value falls back to `5`. |
A note is "un-reflected" if it is a portable episodic note that does **not** carry the `reflected` tag. After reflection, the source episodics are tagged `reflected` and the distilled notes are written with `prov_source=reflection` and confidence `0.6` (the `_DEFAULT_CONFIDENCE` in `reflect.py`), so the output is reviewable.
## Native-import toggle [#native-import-toggle]
| Variable | Default | Purpose |
| ------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ANAMNESIS_IMPORT_NATIVE` | `1` (enabled) | Set to `0` to disable mirroring Claude Code's native per-project memory (under `/projects//memory`) into the Anamnesis store. The import otherwise runs automatically inside every sync cycle. |
This is the only accepted "off" value: the check in `cli.py` is literally `== "0"`. Any other value (including unset) leaves the import enabled. Import failures never break a sync: they are reported to stderr (`import: skipped native import (...)`) and the sync proceeds.
## The .mcp.json shape [#the-mcpjson-shape]
The repository ships a minimal project-scoped `.mcp.json` at the repo root:
```json
{
"mcpServers": {
"anamnesis": {
"command": "uv",
"args": ["run", "--project", "server", "anamnesis", "serve"]
}
}
}
```
This bare form has **no** `env` block, so the server inherits only Claude Code's filtered environment. It works for local development from the repo root (the relative `--project server` path resolves there) and for a local-only store where no remote is needed.
For a real install, `anamnesis init` does not use this bare form. It registers the server at **user scope** with an inline `env` block via `claude mcp add`, embedding the resolved `ANAMNESIS_*` values so the filtered-environment server still knows your machine id and remote. The argv it builds (`build_mcp_add_argv` in `onboard.py`) looks like this:
```bash
claude mcp add --scope user --transport stdio anamnesis \
-e ANAMNESIS_MACHINE_ID=desktop-amsterdam \
-e ANAMNESIS_GIT_REMOTE=git@desktop.tailnet-name.ts.net:anamnesis-memory.git \
-- uv run --project /abs/path/to/server anamnesis serve
```
A few details that matter:
* The server name (`anamnesis`) comes **before** the `-e` flags. Claude's `-e/--env` is variadic, so placing the name after it would let the parser swallow the name as an env value.
* The command to run follows a `--` separator.
* `ANAMNESIS_MACHINE_ID` is always embedded (`build_env` always sets it). `ANAMNESIS_GIT_REMOTE` is embedded only when a remote was configured. `ANAMNESIS_HOME` is embedded only when the store home differs from the `~/.anamnesis` default.
* The base command (`uv run --project anamnesis` versus a bare `anamnesis` on PATH) is auto-detected by `detect_command`; you can override it with `--command` or `--uv-project`.
A registered user-scope MCP server ends up in Claude Code's own config (managed by the `claude` CLI, not in this repo's `.mcp.json`). The equivalent stored shape is a `mcpServers` entry with a `command`, `args`, and an `env` object.
## Lifecycle hooks [#lifecycle-hooks]
`anamnesis init` also installs four lifecycle hook groups into `/settings.json` (built by `build_hooks` in `onboard.py`). Each hook command is prefixed inline with the same `ANAMNESIS_*` env values, for the same filtered-environment reason.
| Event | Matcher | Command | Timeout |
| -------------- | ------------------------ | ------------------------------------------------- | ------------------ |
| `SessionStart` | `startup\|resume\|clear` | `anamnesis inject` | 15 s |
| `SessionStart` | `startup\|resume` | `anamnesis sync` | async (no timeout) |
| `SessionEnd` | (none) | `anamnesis capture` | 120 s |
| `PreCompact` | (none) | `anamnesis capture --source precompact --no-sync` | 60 s |
`merge_hooks` installs these idempotently: it drops any prior Anamnesis hook groups (identified by the `ANAMNESIS_` prefix or the `anamnesis inject|sync|capture` markers), keeps every other key and any non-Anamnesis hooks, then inserts the current set. Whenever an existing `settings.json` is rewritten it is first copied to `settings.json.bak` (the prior backup is overwritten each time).
## The filtered-environment caveat [#the-filtered-environment-caveat]
This is the single most common source of confusion, so it is worth restating precisely.
When Claude Code spawns the MCP server (and the hook commands), it does **not** pass your interactive shell environment. Exporting `ANAMNESIS_GIT_REMOTE` in your `~/.zshrc` will let `anamnesis sync` work when you run it by hand in a terminal, but it will **not** reach the MCP server that Claude Code launches. There are exactly three ways a value reaches that server, in precedence order:
1. The inline `env` block in the user-scope MCP registration (what `anamnesis init` writes).
2. The machine-local `config.json` (the `remote` and `machine_id` fallbacks).
3. The built-in default.
Practical implications:
* The reflection variables (`ANAMNESIS_REFLECTION_*`, `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`) have **no** `config.json` fallback. If you want the LLM summarizer or reflector to run from inside a Claude Code session, you must add those keys to the MCP server's inline `env` block (or run `anamnesis reflect` by hand from a shell where they are exported). Running reflect by hand in your terminal is the simplest path.
* The `.env.example` file is for running the CLI and tooling **by hand**. Copying it to `.env` (which is git-ignored) does not make the MCP server pick it up; nothing in the code reads a `.env` file. It documents the variables for your own reference.
## The .env.example reference [#the-envexample-reference]
The shipped `.env.example` documents the variables for manual tooling. Nothing in it is required to run Anamnesis: every default is sensible for local use. The commented entries map one-to-one to the variables above:
```bash
# Where the local memory store + index live (default: ~/.anamnesis)
# ANAMNESIS_HOME=~/.anamnesis
# Machine-of-origin stamped on notes you write (default: this host's hostname).
# ANAMNESIS_MACHINE_ID=desktop-amsterdam
# Git remote used to sync the markdown memory store across your machines.
# ANAMNESIS_GIT_REMOTE=git@desktop.tailnet-name.ts.net:anamnesis-memory.git
# Reflection/compression model for the SessionEnd episodic summary.
# ANAMNESIS_REFLECTION_PROVIDER=heuristic # heuristic | deepseek | openai | local
# ANAMNESIS_REFLECTION_MODEL= # provider's model id
# ANAMNESIS_REFLECTION_BASE_URL= # OpenAI-compatible base, e.g. https://api.deepseek.com
# ANAMNESIS_REFLECTION_TIMEOUT=30 # seconds; SessionEnd capture is inline
# ANAMNESIS_REFLECTION_MAX_TOKENS=120000 # transcript is windowed above this
# DEEPSEEK_API_KEY=
# Or a provider-neutral key: ANAMNESIS_REFLECTION_API_KEY=
```
## Quick recipes [#quick-recipes]
Enable the LLM summarizer and reflector with DeepSeek (set in your shell for hand-run tooling):
```bash
export ANAMNESIS_REFLECTION_PROVIDER=deepseek
export ANAMNESIS_REFLECTION_MODEL=deepseek-chat
export ANAMNESIS_REFLECTION_BASE_URL=https://api.deepseek.com
export DEEPSEEK_API_KEY=sk-...
# Preview which projects would be distilled (dry-run, no LLM call):
uv run --project server anamnesis reflect
# Actually distill (requires the model/base-url/key above):
uv run --project server anamnesis reflect --apply
```
Lower the reflection threshold so smaller projects get distilled:
```bash
export ANAMNESIS_REFLECT_MIN_EPISODICS=3
```
Turn off native import for a single sync:
```bash
ANAMNESIS_IMPORT_NATIVE=0 uv run --project server anamnesis sync
```
Inspect the resolved init plan without writing anything:
```bash
uv run --project server anamnesis init --print
```
## See also [#see-also]
* [Install and setup](../guide/install)
* [Sync internals](../internals/sync)
* [Reflection internals](../internals/reflection)
* [CLI reference](./cli)
# MCP tools reference (/docs/reference/mcp-tools)
Anamnesis exposes its memory store to Claude Code through a [FastMCP](https://github.com/jlowin/fastmcp) server named `anamnesis`. The server is thin: it maps Model Context Protocol tool calls onto a `MemoryStore` (markdown source of truth plus a derived SQLite FTS5 index) and a git sync backend, and holds no state of its own. This page is the field-level reference for the five tools it registers.
Everything below is taken from `server/src/anamnesis/server.py` and the supporting modules `store.py`, `sync.py`, and `config.py`. For the design and concurrency model behind the server, see [the MCP server internals page](../internals/mcp-server). For the underlying recall pipeline, see [recall internals](../internals/recall).
## The five tools at a glance [#the-five-tools-at-a-glance]
The tools split into two groups by whether they mutate the store. Read-only tools carry a `readOnlyHint` annotation so a client can auto-approve them; the two writers are flagged for confirmation.
| Tool | Signature | Mutates store | Annotation | Auto-approvable |
| --------------- | ---------------------------------------------------------------- | ------------------- | ------------------------------------------- | --------------- |
| `memory_search` | `(query, project?, type?, scope?, k=8)` | no | `readOnlyHint=True, openWorldHint=False` | yes |
| `memory_list` | `(project?, type?, scope?)` | no | `readOnlyHint=True, openWorldHint=False` | yes |
| `memory_status` | `()` | no | `readOnlyHint=True, openWorldHint=False` | yes |
| `memory_write` | `(type, title, body, project="global", tags?, scope="portable")` | yes | `readOnlyHint=False, destructiveHint=False` | no (confirm) |
| `memory_sync` | `(force=False)` | yes (git + reindex) | `readOnlyHint=False, openWorldHint=True` | no (confirm) |
The annotations come straight from the `@mcp.tool(annotations=...)` decorators in `server.py`. The three read-only tools share one constant:
```python
_READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
```
`memory_write` is annotated `destructiveHint=False` (it only adds notes, it does not delete or overwrite existing ones), but it still has `readOnlyHint=False`, so it is not auto-approved. `memory_sync` is annotated `openWorldHint=True` because it talks to a git remote outside the local machine.
Markdown under `memory/` (and `local/`) is the source of truth. The SQLite index at `index.db` is fully derived and is rebuilt locally, so it is never synced. A note's parameters map directly onto the YAML front matter of its markdown file.
## Shared parameters [#shared-parameters]
Three filters recur across `memory_search` and `memory_list`. All three are optional and default to `None` (no filter):
* `project` (string): filters to notes whose `project` field equals this value exactly. Notes default to the `"global"` project when written without one.
* `type` (string): one of `"procedural"`, `"semantic"`, or `"episodic"`. These are the only values the SQLite schema accepts (`CHECK (type IN ('procedural','semantic','episodic'))`).
* `scope` (string): `"portable"` (synced to your other machines) or `"machine-local"` (stays on this machine only, never synced). These are the only values the schema accepts (`CHECK (scope IN ('portable','machine-local'))`).
`type` is typed as `MemoryType` in the pure functions, which is just an alias for `str`; the value is validated by the SQLite `CHECK` constraint at index time, not by the tool signature.
## The common return shape [#the-common-return-shape]
Every tool that returns notes renders each note with the internal `_memory_dict` helper. With the body included, one note is:
```json
{
"id": "01J...ULID",
"type": "procedural",
"title": "Run reflect safely",
"project": "anamnesis",
"machine_id": "desktop",
"scope": "portable",
"tags": ["reflect", "sync"],
"created_at": "2026-06-24T12:00:00+00:00",
"updated_at": "2026-06-24T12:00:00+00:00",
"body": "full markdown body here"
}
```
`memory_list` omits the `body` key (it is called with `include_body=False`); `memory_search` and `memory_write` include it. `created_at` and `updated_at` are ISO 8601 strings at second precision (UTC). `id` is a ULID generated at write time. `machine_id` is the machine of origin recorded on the note, not necessarily the current machine.
Note that the front matter holds more fields than the tool returns. Provenance fields (`prov_source`, `prov_model`, `prov_session`, `confidence`, `supersedes`) live in the markdown and the index but are not part of the MCP return dict.
***
## memory\_search [#memory_search]
Keyword search over the FTS5 index, ranked by BM25.
**Signature**
```python
memory_search(query: str, project: str | None = None,
type: str | None = None, scope: str | None = None,
k: int = 8) -> list[dict[str, object]]
```
**Docstring (verbatim)**
> Search memory by keyword (FTS5 BM25), optionally scoped by project/type/scope.
>
> Read-only. Returns up to `k` ranked notes, each with its body and metadata (id, type, project, machine of origin, scope, tags, timestamps). `scope` filters to "portable" (synced) or "machine-local" (this machine only).
**Parameters**
| Name | Type | Default | Notes |
| --------- | -------------- | -------- | ----------------------------------------------------------- |
| `query` | string | required | Free text. Tokenized to words; see the matching rule below. |
| `project` | string or null | `None` | Exact-match project filter. |
| `type` | string or null | `None` | `procedural`, `semantic`, or `episodic`. |
| `scope` | string or null | `None` | `portable` or `machine-local`. |
| `k` | int | `8` | Maximum number of ranked notes returned. |
**Returns** a list of up to `k` note dicts, each including `body`. Empty list if there are no word tokens in `query` (the matcher returns `""` and `search` short-circuits to `[]`).
**Matching rule.** The query is split into word tokens (`\w+`), each token is wrapped in quotes, and the tokens are joined with `OR`, then ranked by BM25. `OR` (not `AND`) is deliberate: ANDing every token requires one note to contain all of a natural-language query's words, which measured 0% recall on real paraphrase queries. `OR` plus BM25 surfaces the best-overlapping notes first and recovered recall to about 94% on the same eval set. FTS5-special characters (`-`, `:`, `*`, `"`, and so on) are neutralized so arbitrary text cannot break the query parser.
**Ranking and superseding.** Results are ordered by `bm25(memories_fts)`, then `updated_at DESC` as a tiebreak. Notes that another note has marked as superseded (via a `supersedes` field) are excluded from results.
**Example call**
```jsonc
// tool: memory_search
{
"query": "how do I run reflect without losing notes",
"project": "anamnesis",
"type": "procedural",
"k": 5
}
```
***
## memory\_list [#memory_list]
List notes newest-first, with metadata but no bodies.
**Signature**
```python
memory_list(project: str | None = None, type: str | None = None,
scope: str | None = None) -> list[dict[str, object]]
```
**Docstring (verbatim)**
> List memory notes newest-first (titles + metadata, no bodies).
>
> Read-only. Optionally scoped by project, type, and/or scope ("portable" vs "machine-local").
**Parameters**
| Name | Type | Default | Notes |
| --------- | -------------- | ------- | ---------------------------------------- |
| `project` | string or null | `None` | Exact-match project filter. |
| `type` | string or null | `None` | `procedural`, `semantic`, or `episodic`. |
| `scope` | string or null | `None` | `portable` or `machine-local`. |
**Returns** a list of note dicts ordered by `updated_at DESC, id DESC`, each **without** the `body` key. Unlike `memory_search`, there is no `k` cap: this returns every matching note.
Use `memory_list` to enumerate or audit notes (it is cheap, body-free, and ordered by recency). Use `memory_search` when you want the most relevant notes including their bodies.
***
## memory\_status [#memory_status]
Report store health and git sync state. Takes no parameters.
**Signature**
```python
memory_status() -> dict[str, object]
```
**Docstring (verbatim)**
> Report store health: counts by type/project, store paths, sync state.
>
> Read-only.
**Returns** a single dict combining `MemoryStore.stats()` with the sync backend's `state()`:
```json
{
"root": "/home/you/.anamnesis",
"db_path": "/home/you/.anamnesis/index.db",
"total": 142,
"by_type": { "procedural": 60, "semantic": 50, "episodic": 32 },
"by_project": { "global": 40, "anamnesis": 102 },
"by_scope": { "portable": 130, "machine-local": 12 },
"sync": {
"initialized": true,
"remote": "ssh://node.tailnet/~/anamnesis.git",
"head": "3237e8f",
"dirty": false,
"detail": "ok"
}
}
```
**Field reference**
| Field | Source | Meaning |
| ------------------ | ------------------- | ----------------------------------------------------------------------- |
| `root` | `store.root` | Store root, default `~/.anamnesis` (overridable with `ANAMNESIS_HOME`). |
| `db_path` | `store.db_path` | Path to the derived SQLite index, `/index.db`. |
| `total` | `stats.total` | Total indexed notes. |
| `by_type` | `stats.by_type` | Count per `type`. |
| `by_project` | `stats.by_project` | Count per `project`. |
| `by_scope` | `stats.by_scope` | Count per `scope`. |
| `sync.initialized` | `state.initialized` | `true` once `memory/` is a git repo. |
| `sync.remote` | `state.remote` | Configured remote URL, or `null` if none. |
| `sync.head` | `state.head` | Short HEAD commit hash, or `""` before the first commit. |
| `sync.dirty` | `state.dirty` | `true` if there are uncommitted changes in `memory/`. |
| `sync.detail` | `state.detail` | `"ok"`, or `"not initialized"` before the repo exists. |
***
## memory\_write [#memory_write]
Create a durable note: write the markdown file, then index it. This is a write tool and is not auto-approved.
**Signature**
```python
memory_write(type: str, title: str, body: str,
project: str = "global", tags: list[str] | None = None,
scope: str = "portable") -> dict[str, object]
```
**Docstring (verbatim)**
> Create a durable memory note: write the markdown file and index it.
>
> Use `type` = procedural (verified how-tos, decisions, fixes), semantic (facts, preferences, conventions), or episodic (what happened). `scope` = "portable" (default; syncs to your other machines) or "machine-local" (stays on this machine only, never synced). The note is tagged with this machine as its origin. Returns the created note's metadata. This modifies the store, so it is not auto-approved.
**Parameters**
| Name | Type | Default | Notes |
| --------- | ----------------------- | ------------ | -------------------------------------------------------------------------- |
| `type` | string | required | `procedural`, `semantic`, or `episodic`. Rejected by the schema otherwise. |
| `title` | string | required | Note title (indexed in FTS5). |
| `body` | string | required | Markdown body (indexed in FTS5). |
| `project` | string | `"global"` | Project key the note belongs to. |
| `tags` | list of strings or null | `None` | Stored as `tags` front matter and indexed; `None` becomes an empty list. |
| `scope` | string | `"portable"` | `portable` (synced) or `machine-local` (never synced). |
**Returns** the created note's dict, including `body`.
**What it does, in order.** The store generates a ULID `id`, sets `created_at` and `updated_at` to now (UTC, second precision), and records the current machine as `machine_id`. The markdown file is written to `/.md` under the tree chosen by `scope`: `memory/` for `portable`, `local/` for `machine-local`. Then the note is indexed into SQLite. If indexing raises, the just-written markdown file is removed so the store does not end up with an orphaned, unindexed file.
`scope="machine-local"` notes are written under `local/`, which sits **outside** the git-synced `memory/` tree, so they are never pushed to your other machines. Choose the scope deliberately: portable notes leave your machine on the next `memory_sync`.
`memory_write` always creates a new note (a fresh ULID); it never edits or overwrites an existing one. That is why its annotation is `destructiveHint=False`. Replacing a note in place (with a caller-supplied id) is a store-level operation (`MemoryStore.put`) used by the importer, not an MCP tool.
***
## memory\_sync [#memory_sync]
Run one git sync cycle, then rebuild the index. This is a write tool and talks to a remote, so it is not auto-approved.
**Signature**
```python
memory_sync(force: bool = False) -> dict[str, object]
```
**Docstring (verbatim)**
> Sync memory across machines: commit local notes, pull --rebase, push.
>
> Uses git over the remote in ANAMNESIS\_GIT\_REMOTE (a bare repo on your Tailscale mesh); with no remote set it just commits locally. On a conflicting edit it surfaces the conflict and keeps local edits rather than dropping either side. The `force` flag is reserved for future use.
**Parameters**
| Name | Type | Default | Notes |
| ------- | ---- | ------- | ----------------------------------------------------------------------------------- |
| `force` | bool | `False` | Reserved for future use. It is accepted but not read by the current implementation. |
**Returns**
```json
{
"pushed": true,
"pulled": 2,
"conflicted": false,
"head": "a1b2c3d",
"indexed": 142,
"detail": "synced"
}
```
| Field | Type | Meaning |
| ------------ | ------ | --------------------------------------------------------------------------------------------- |
| `pushed` | bool | Whether the push moved the remote (false if already up to date). |
| `pulled` | int | Number of commits integrated from the remote this cycle. |
| `conflicted` | bool | `true` if a rebase conflict was hit; the rebase is aborted and local edits are kept. |
| `head` | string | Short HEAD commit hash after the cycle. |
| `indexed` | int | Number of notes reindexed from markdown after the sync. |
| `detail` | string | Human-readable status, for example `"synced"` or `"committed locally; no remote configured"`. |
**What it does.** The backend runs `git add -A`, commits if there is anything staged, then (if a remote is configured) `git fetch origin`, integrates the remote with `git rebase origin/main`, and `git push -u origin main`. After git returns, the SQLite index is rebuilt from the (now updated) markdown with `store.reindex()`, because pulling can bring in notes from other machines and the index is derived.
On a conflicting edit the backend does not merge or drop either side. It aborts the rebase, keeps your local edits in place, does not push, and returns `conflicted=true` with the detail `"conflict on rebase; kept local edits, did not push - resolve and re-sync"`. Resolve the conflict by hand in `memory/`, then sync again.
The remote is resolved from `ANAMNESIS_GIT_REMOTE`, falling back to the per-store `config.json` written by `anamnesis init`. With no remote set anywhere, `memory_sync` only commits locally. See [the sync internals page](../internals/sync) and [configuration reference](./configuration) for the full resolution chain.
***
## How the server is launched [#how-the-server-is-launched]
The project ships a `.mcp.json` at the repo root that registers the server with Claude Code:
```json
{
"mcpServers": {
"anamnesis": {
"command": "uv",
"args": ["run", "--project", "server", "anamnesis", "serve"]
}
}
}
```
`anamnesis serve` is the console entry point that builds the FastMCP server over a store at `ANAMNESIS_HOME` (default `~/.anamnesis`) and runs it over stdio. `serve` is also the default subcommand when none is given.
Claude Code launches MCP servers with a filtered environment, so your shell exports are not inherited. Set `ANAMNESIS_HOME`, `ANAMNESIS_MACHINE_ID`, and `ANAMNESIS_GIT_REMOTE` in the `.mcp.json` `"env"` block, or rely on the per-store `config.json` fallback that `anamnesis init` writes. See the [configuration reference](./configuration).
To install and register everything in one command, run `uv tool install anamnesis-memory && anamnesis init` (see the [CLI reference](./cli)). This one-line install from the PyPI package is live and is the fastest path. Installing from source with `uv pip install -e ".[mcp,dev]"` in `server/` is the path for contributors and local development.
## See also [#see-also]
* [MCP server internals](../internals/mcp-server) - the pure functions behind each tool, annotations, and the concurrency model.
* [Recall internals](../internals/recall) - the FTS5 BM25 pipeline used by `memory_search`.
* [Sync internals](../internals/sync) - the git-over-Tailscale backend behind `memory_sync`.
* [Data model](../internals/data-model) - the full note schema and front matter fields.
* [Configuration reference](./configuration) - `ANAMNESIS_HOME`, `ANAMNESIS_MACHINE_ID`, `ANAMNESIS_GIT_REMOTE`, and the `config.json` fallback.
# Security and privacy (/docs/reference/security)
Anamnesis is a local-first memory layer. Your memory lives as plain markdown files on disk, the search index is a local SQLite database, and sync moves files between your own machines over your own network. The two places where data can leave a machine are (1) the optional reflection provider you point it at, and (2) the git remote you sync to. This page documents both precisely: what is filtered, what is sent, what is stored where, and what each protection does and does not cover.
## The short version [#the-short-version]
* Memory is markdown files in `~/.anamnesis/memory/` (portable) and `~/.anamnesis/local/` (machine-local). The SQLite index is `~/.anamnesis/index.db`.
* A conservative, deterministic redaction pass strips secret-shaped spans before any transcript or note text is sent to an LLM. It runs in capture summarization, reflection, and eval candidate generation.
* Only portable markdown under `memory/` syncs (via git). Machine-local notes under `local/` and the `index.db` file never leave the machine.
* The default summarizer is the heuristic, which is fully local and contacts no network. Redaction only matters once you configure an LLM provider.
* The git repository is public; the project's private documents are git-ignored and never pushed. This is an open-source (Apache-2.0) tool, so you control every endpoint it talks to.
Redaction is a best-effort secret filter, not a guarantee. It catches a fixed set of secret shapes (private keys, common API key prefixes, vendor tokens, Bearer tokens, and sensitive `key=value` pairs). A novel credential format that does not match those patterns can pass through to whatever LLM provider you have configured. Treat the reflection provider as a trusted third party, and prefer a provider whose data handling you accept.
## Where data lives [#where-data-lives]
The store root defaults to `~/.anamnesis`. Three things live under it, and each has a different trust and sync posture. The relevant code is `MemoryStore.__init__` in `server/src/anamnesis/store.py`.
```text
~/.anamnesis/
memory/ # SOURCE OF TRUTH, portable. A git repo. Syncs across machines.
/.md
local/ # SOURCE OF TRUTH, machine-local. NEVER synced. Outside the git repo.
/.md
index.db # DERIVED. SQLite (WAL + FTS5). NEVER synced. Rebuilt locally.
```
A note's scope is determined by which tree it lives in, and that mapping is authoritative. In `MemoryStore.reindex` the indexer walks `memory/` as `portable` and `local/` as `machine-local`, overwriting whatever the front matter claimed:
```python
for base, scope in ((self.memory_dir, "portable"), (self.local_dir, "machine-local")):
for path in sorted(base.rglob("*.md")):
mem = _deserialize(path.read_text(encoding="utf-8"))
mem.scope = scope
...
```
`MemoryStore._dir_for_scope` writes a note to `local/` if its scope is `machine-local`, otherwise to `memory/`. So a note marked `scope: machine-local` is physically placed outside the synced git tree and cannot be pushed.
## What syncs and what never leaves [#what-syncs-and-what-never-leaves]
| Data | Location | Syncs across machines? | Notes |
| ------------------- | ------------------------------ | ---------------------- | -------------------------------------------------------------------------------- |
| Portable notes | `~/.anamnesis/memory/` | Yes, via git | The source of truth that moves between machines. |
| Machine-local notes | `~/.anamnesis/local/` | No | Lives outside the git repo. Never pushed. |
| Search index | `~/.anamnesis/index.db` | No | Derived from markdown, rebuilt locally on each machine. |
| Reflection config | Environment variables / config | No | Provider URL, model, and API key are machine-local and never written into notes. |
Two design rules enforce this:
1. **Never sync the raw DB file.** The index is fully derived and is rebuilt locally with `MemoryStore.reindex`. `sync.py` syncs the `memory/` directory only and explicitly leaves the index out, with the rationale recorded in its module docstring: the SQLite index "is never synced: it lives outside `memory/` and is rebuilt locally, per the claude-brain corruption lesson." The repo `.gitignore` also blocks `*.db`, `*.sqlite`, `*-wal`, and `*-shm` so a stray copy cannot be committed.
2. **Machine-local notes stay out of the synced tree.** They are written to `local/`, which is not part of the git repo that `sync.py` operates on, so `git add -A` inside `memory/` never sees them.
Putting a note in `local/` is the mechanism for "this should never leave this machine." Anything you do not want on your other machines (or on the git remote) should be a machine-local note. Portable notes will be pushed to your remote the next time you sync.
## How sync moves data [#how-sync-moves-data]
Sync is git over your Tailscale mesh. `GitSyncBackend.sync` in `server/src/anamnesis/sync.py` runs an ordinary commit, then `git fetch origin`, `git rebase origin/main`, then `git push -u origin main` (the branch constant `_BRANCH` is `"main"`). Commits are authored as `anamnesis` with an email of `anamnesis@`, set per-invocation through `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env vars, so the machine that produced each note is recorded in history.
The remote is whatever git URL you configure (a bare repo on an always-on node, or another machine directly). Anamnesis does not host anything and does not phone home for sync; the data only travels to the remote you point it at, over the network you put it on. There is no cloud service in this path.
On a rebase conflict the v0 policy is to abort, keep local edits in place, and return a `SyncResult` with `conflicted=True` and the detail `"conflict on rebase; kept local edits, did not push - resolve and re-sync"`. It never silently drops or pushes over a conflict.
## Redaction: the secret filter before anything reaches an LLM [#redaction-the-secret-filter-before-anything-reaches-an-llm]
All redaction lives in `server/src/anamnesis/redact.py`. It is a pure, deterministic function, `redact(text) -> str`, that replaces secret-shaped spans with the literal token `[REDACTED]`. It is unit-tested with synthetic secrets only (`server/tests/test_redact.py`). It keeps ordinary prose and structure intact; it only rewrites spans that match one of its patterns.
### What it strips [#what-it-strips]
`redact()` first applies an ordered list of regex patterns (`_PATTERNS`), then a final `key=value` pass (`_KV`). The patterns, in execution order:
| What it catches | Pattern (real regex from `redact.py`) | Notes |
| -------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Private-key blocks (PEM) | `-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----` (DOTALL) | Multi-line; runs first so the whole block is masked as one span. Matches RSA, EC, OPENSSH, and plain `PRIVATE KEY` headers. |
| AWS access key IDs | `\bAKIA[0-9A-Z]{16}\b` | The `AKIA` prefix plus 16 uppercase alphanumeric chars. |
| `sk-` / `rk-` / `pk-` keys | `\b(?:sk\|rk\|pk)-[A-Za-z0-9]{12,}\b` | Covers OpenAI-style `sk-`, restricted `rk-`, and publishable `pk-` keys; needs at least 12 trailing chars. |
| GitHub tokens | `\bgh[posru]_[A-Za-z0-9]{20,}\b` | `gho_`, `ghp_`, `ghs_`, `ghr_`, `ghu_` token prefixes with 20+ trailing chars. |
| Slack tokens | `\bxox[baprs]-[A-Za-z0-9-]{10,}\b` | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-`, `xoxs-` bot/app/user tokens. |
| Bearer tokens | `(?i)\bBearer\s+[A-Za-z0-9._\-]{12,}` | Case-insensitive `Bearer` followed by a 12+ char token. |
After those, the `_KV` pattern masks sensitive `key=value` and `"key": "value"` pairs while preserving the key name so the text still reads sensibly. It is case-insensitive and matches these key names (optionally as the trailing segment of a longer name, so `DEEPSEEK_API_KEY` matches via the optional prefix group):
`password`, `passwd`, `secret`, `token`, `api_key` / `api-key` / `apikey`, `authorization`, `access_key` / `access-key`, `client_secret` / `client-secret`.
The value can be quoted (single or double) or bare up to the next whitespace, comma, `}`, or `)`. The replacement keeps the original key, separator, and any surrounding quotes, and substitutes `[REDACTED]` for the value. For example, `api_key="sk-abc123..."` becomes `api_key="[REDACTED]"`.
### What it does not catch [#what-it-does-not-catch]
This is a fixed-pattern allowlist of known secret shapes, not a general entropy or PII detector. It does **not** redact:
* Secrets in formats it does not recognize (for example a custom token that is not Bearer-prefixed and is not under a sensitive key name).
* Personal data, file paths, internal URLs, hostnames, names, or business content. These are ordinary prose to the filter and pass through.
* Anything outside the three LLM call sites below. Redaction is not applied to notes at rest, to sync, or to the dashboard. Notes are stored and synced verbatim.
Do not rely on redaction to make it safe to paste secrets into Claude Code. It reduces accidental leakage of common credential shapes to your reflection provider; it is not a sanitizer for the markdown you keep or sync. If a secret lands in a note, it stays in that note (and will sync if the note is portable) until you remove it.
### Where redaction is applied [#where-redaction-is-applied]
`redact()` is called at exactly three points. In each, the text is redacted first and the redacted result is then size-bounded by `_window(...)` before it is handed to the LLM client (the call is `_window(redact(...), max_chars)`, so redaction runs on the full input, not just the windowed slice):
1. **Capture summarization** (`server/src/anamnesis/llm_summarizer.py`, `LLMSummarizer.summarize`): `content = _window(redact(transcript), self.max_chars)`. The full session transcript is redacted before it is sent to summarize a session into an episodic note.
2. **Reflection** (`server/src/anamnesis/reflect.py`, `Reflector.reflect`): `content = _window(redact(_render_episodics(episodics)), self.max_chars)`. The concatenated episodic notes are redacted before being distilled into durable semantic/procedural notes.
3. **Eval candidate generation** (`server/src/anamnesis/eval.py`, `build_eval_candidates`): `content = _window(redact(f"# {note.title}\n{note.body}"), max_chars)`. Each sampled note is redacted before the LLM generates a paraphrase query for the recall eval.
The system prompts in all three pipelines also instruct the model to never echo secrets, API keys, tokens, or credentials in its output. That is a second, softer layer; the deterministic `redact()` on the input is the enforcement point.
### The default path sends nothing to an LLM [#the-default-path-sends-nothing-to-an-llm]
Redaction only matters once you opt into an LLM provider. The default summarizer is the deterministic `HeuristicSummarizer` (`server/src/anamnesis/capture.py`), selected when `ANAMNESIS_REFLECTION_PROVIDER` is unset or `heuristic`. It builds the episodic note from extracted facts (the first prompt, branch, files touched, last outcome) entirely in process and contacts no network. It does not call `redact()` because nothing leaves the machine to redact.
You switch to an LLM by setting the provider plus a model, base URL, and API key (see [Configuration](./configuration)). When configured, `_http_client` in `llm_summarizer.py` POSTs the redacted, size-bounded text to `/chat/completions` with an `Authorization: Bearer ` header. The provider, model, and URL come entirely from your config; nothing about any provider is hardcoded, and that endpoint is the only network destination in the reflection path. If the LLM call fails or returns an unparseable response, capture falls back to the heuristic so session teardown never breaks.
The reflection config (provider, base URL, model, and especially the API key) is read from machine-local environment variables and is never written into a note or synced. Keep those values out of files that live under `memory/`. The `.gitignore` already blocks `.env`, `*.pem`, `*.key`, and `secrets.*` in the repo, but your store root is separate, so do not commit or sync credentials there either.
## The public-repo, private-strategy boundary [#the-public-repo-private-strategy-boundary]
The Anamnesis repository at [github.com/oscardvs/anamnesis](https://github.com/oscardvs/anamnesis) is public and the local-first core is Apache-2.0. The project's `.gitignore` and `CLAUDE.md` draw a hard line between public code and private material: roadmap, research, business case, and any personal-data handling code are git-ignored and never pushed. The ignore rules cover `/docs/`, `/private/`, any nested `**/private/`, `*.private.md`, and `NOTES.local.md`.
For you as a user, the relevant guarantee is simpler and is a property of the architecture, not a policy: because Anamnesis is open source and local-first, there is no Anamnesis-operated server in the loop. The only places your data goes are endpoints you choose:
* The **git remote** you configure for sync (your own machine or node on your own mesh).
* The **LLM provider** you configure for summarization and reflection (and only after redaction).
If you set neither, nothing leaves the machine at all: capture uses the heuristic, and with no remote configured `sync()` returns `"committed locally; no remote configured"` after committing to the local git repo.
## Verifying the protections yourself [#verifying-the-protections-yourself]
Because everything is local files and an open codebase, you can audit each claim directly.
```bash
# See what is in the synced (portable) tree vs the machine-local tree:
ls -R ~/.anamnesis/memory
ls -R ~/.anamnesis/local
# Confirm the index is not in the git repo and is ignored:
git -C ~/.anamnesis/memory status --porcelain
git -C ~/.anamnesis/memory ls-files | grep -c 'index.db' # expect 0
# Inspect what the redaction filter actually does, against the source:
python -c "from anamnesis.redact import redact; print(redact('api_key=sk-ABCDEFGHIJKL token: ghp_0123456789ABCDEFGHIJ'))"
# Read the redaction patterns and the redact() function itself:
sed -n '13,49p' server/src/anamnesis/redact.py
```
The `python -c` snippet above needs the `anamnesis` package importable: run it from any environment with the PyPI package installed (for example `uv run --with anamnesis-memory python -c ...`), or from a source checkout installed with `uv pip install -e ".[mcp,dev]"` inside `server/`.
## Threat model summary [#threat-model-summary]
| Concern | Covered? | How |
| ------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Common secret shapes reaching the LLM provider | Mostly | `redact()` on transcript, episodics, and eval notes before the only network POST. |
| Novel/unknown secret formats reaching the LLM provider | No | Fixed pattern set; unrecognized shapes pass through. |
| Personal/business content reaching the LLM provider | No | Treated as prose; use the heuristic provider to send nothing. |
| Machine-local notes leaving the machine | Yes | Stored in `local/`, outside the synced git repo. |
| The SQLite index leaking or corrupting via sync | Yes | Never synced; derived and rebuilt locally; ignored by git. |
| Data going to a vendor you did not choose | Yes | No project-operated server; only your git remote and your configured LLM endpoint. |
| Credentials ending up in committed notes | Partly | Reflection config is env-only and never written to notes; but a secret you put in a note yourself stays and syncs if portable. |
## Related [#related]
* [Configuration](./configuration) - the environment variables that pick a provider, model, base URL, and API key.
* [Sync internals](../internals/sync) - how the git-over-Tailscale backend commits, rebases, and handles conflicts.
* [Reflection](../internals/reflection) - the distillation pass that consumes episodic notes.
* [Data model](../internals/data-model) - note types, scope, and provenance fields.