Metadata-Version: 2.5
Name: curu-memory
Version: 0.9.0
Summary: A shared knowledge base and GraphRAG store for ComfyUI and generative media, served to agents over MCP.
Project-URL: Repository, https://github.com/darth-veitcher/curu-memory
Project-URL: Documentation, https://github.com/darth-veitcher/curu-memory#readme
Project-URL: Changelog, https://github.com/darth-veitcher/curu-memory/blob/main/CHANGELOG.md
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.12
Requires-Dist: cryptography>=46
Requires-Dist: httpx2>=2.10
Requires-Dist: markdown-it-py>=4.0
Requires-Dist: pydantic>=2.12
Requires-Dist: pyyaml>=6.0
Provides-Extra: server
Requires-Dist: mcp==2.3.0; extra == 'server'
Requires-Dist: starlette>=1.0; extra == 'server'
Requires-Dist: uvicorn>=0.31.1; extra == 'server'
Description-Content-Type: text/markdown

# curu-memory

## What is this

curu-memory is a shared knowledge base for ComfyUI and generative media. It reads OKF concept files about workflows, models, parameters, prompt techniques and findings. Then it indexes them and serves them to agents through MCP and to persons through a command. An agent can search the knowledge, read a concept and follow its links to related concepts. The global knowledge stays in plain Markdown files that you keep in git. The files of a private space are encrypted in git.

## What you can do

| Task | Command | Section |
|---|---|---|
| Write a concept for the index | Add `<name>.md` with front matter | [Write a concept file](#write-a-concept-file) |
| Check concept files before you index them | `curu_memory.validate("knowledge")` | [Check concept files](#check-concept-files) |
| Add images, audio and video to a concept | Add a `media:` list to the front matter | [Add media to a concept](#add-media-to-a-concept) |
| Build the index from your concept files | `curu-memory index --concepts ./knowledge` | [Index your knowledge](#index-your-knowledge) |
| Keep team notes and personal notes apart from the global knowledge | `curu-memory index --space personal/me` | [Keep team and personal knowledge apart](#keep-team-and-personal-knowledge-apart) |
| Keep the team and the personal spaces encrypted on disk | `curu-memory key new ~/.config/curu-memory/kek` | [Keep private knowledge encrypted](#keep-private-knowledge-encrypted) |
| Encrypt the concept files of a private space for git | `curu-memory space encrypt --space team/look ./look-plain ./look` | [Encrypt the concept files of a private space](#encrypt-the-concept-files-of-a-private-space) |
| Edit an encrypted space with any editor, and commit the change | `curu-memory space checkout --space team/look ./look` | [Edit an encrypted space](#edit-an-encrypted-space) |
| Log in to a server for an edit through the server | `curu-memory login --server https://memory.example.com` | [Edit a space through the server](#edit-a-space-through-the-server) |
| Edit an encrypted space with no key file, through the server | `curu-memory space checkout --space team/look --server https://memory.example.com ./look` | [Edit a space through the server](#edit-a-space-through-the-server) |
| Sign the commits of an encrypted space | Set `CURU_MEMORY_ALLOWED_SIGNERS` | [Sign the commits of an encrypted space](#sign-the-commits-of-an-encrypted-space) |
| Read the plaintext changes of an encrypted space in `git diff` and `git log -p` | `curu-memory git install` | [Read the diff of an encrypted space](#read-the-diff-of-an-encrypted-space) |
| Merge two branches of an encrypted space | `git merge -S other` | [Merge the branches of an encrypted space](#merge-the-branches-of-an-encrypted-space) |
| Stop a push that puts plaintext into an encrypted space | `curu-memory space hook install` | [Check the commits before a push](#check-the-commits-before-a-push) |
| Remove the plaintext of a space from the git history | `git filter-repo --invert-paths --path look-plain` | [Rewrite the plaintext history](#rewrite-the-plaintext-history) |
| Change the key of the private spaces while the server answers | `curu-memory key rotate --old OLD --new NEW` | [Rotate the key](#rotate-the-key) |
| Give each person their own spaces, and see who reads which space | `curu-memory access person add me` | [Give each person their own spaces](#give-each-person-their-own-spaces) |
| Connect agents with tokens of Authentik or of the shipped rauthy | Set `CURU_MEMORY_OIDC_ISSUER` | [Connect agents with OIDC tokens](#connect-agents-with-oidc-tokens) |
| Give an agent a role in one space for some days | `curu-memory access grant scout --space team/look --role read` | [Give an agent its own access](#give-an-agent-its-own-access) |
| Search the knowledge from a terminal | `curu-memory search "VAE for WAN2.2 5B"` | [Search the knowledge](#search-the-knowledge) |
| Give the knowledge to a local agent | `curu-memory serve` | [Connect a local agent](#connect-a-local-agent) |
| Give the knowledge to a remote agent | `curu-memory serve --transport http` | [Connect a remote agent](#connect-a-remote-agent) |
| Run curu-memory, rauthy, an embedder and a TLS proxy on a server with Docker Compose | `deploy/stack/init.sh` | [Deploy the stack with TLS](#deploy-the-stack-with-tls) |
| Search with an image, a clip or a video, for example an output of a workflow | `curu-memory search --media output.png` | [Search with media](#search-with-media) |
| Search by meaning with EmbeddingGemma 2 | Set `CURU_MEMORY_EMBEDDER_URL` | [Search by meaning](#search-by-meaning) |
| Browse the knowledge in a web browser | `curu-memory browse` | [Browse the knowledge](#browse-the-knowledge) |
| Read a concept and its links | The `get_concept` and `concept_neighbours` tools | [Read a concept and follow its links](#read-a-concept-and-follow-its-links) |
| Find why a call failed | The `code` of the error | [Handle errors](#handle-errors) |
| Build and read the knowledge from a Python program | `curu_memory.open_knowledge()` | [Use curu-memory from Python](#use-curu-memory-from-python) |

## Install

You need Python 3.12 or later and [uv](https://docs.astral.sh/uv/).

```bash
uv tool install "curu-memory[server] @ git+https://github.com/darth-veitcher/curu-memory"
```

This installs the `curu-memory` command with the `server` [extra](#glossary). `serve` and `browse` need the extra. Without it, `index`, `search` and `access` work, and `serve` and `browse` stop with exit code 78.

To install a release from PyPI, give a version range:

```bash
uv tool install "curu-memory[server]>=0.9,<0.10"
```

A program that uses curu-memory as a library pins a range in its `pyproject.toml`, with no extra. A new minor version can change the Python API or the MCP tools, so the range stops before the next minor version.

## Quickstart

This example makes one concept, indexes it, searches it, and gives it to Claude Code. Make a directory for your concept files:

```bash
mkdir -p knowledge
```

Save this text as `knowledge/wan22-5b-vae.md`:

```markdown
---
type: Model Component
title: WAN2.2 5B VAE
description: "The VAE that the WAN2.2 5B model loads."
tags: [vae, wan]
status: verified
sources:
  - id: the-card
    resource: https://huggingface.co/Wan-AI
    author: person:owner
    last_modified: "2026-10-01"
---
The [WAN2.2 5B family](./wan22-5b.md "USES_COMPONENT") loads this VAE.

## Settings

Use the `wan2.2_vae.safetensors` file.
```

Then index the directory and search it:

```bash
curu-memory index --concepts ./knowledge
curu-memory search "VAE for WAN2.2 5B" --limit 5
```

The first command prints a report. `skipped: 0` means that each concept file is in the index. `broken links: 1` means that `wan22-5b.md` is not in the directory yet. The second command prints the concept, with its sources.

Then give the knowledge to Claude Code:

```bash
claude mcp add curu-memory -- curu-memory serve
```

The server reads the index in the same data directory as the index command. Claude Code can now use the `search_knowledge`, `search_knowledge_by_media`, `get_concept` and `concept_neighbours` tools.

## Write a concept file

A concept file is a Markdown file with YAML front matter between two `---` lines. The file in the [Quickstart](#quickstart) is an example. The front matter must have `description`. `type`, `title`, `tags`, `sources`, `status` and `media` are optional.

- The identifier of a concept is its file name without `.md`. In the example, it is `wan22-5b-vae`.
- A missing `title` becomes the identifier. A missing `type` becomes `Untyped`, and the index report gives a warning.
- A link to another concept is a Markdown link to its `.md` file. Its title in quotes is the link kind.
- The link kinds are `USES_COMPONENT`, `HAS_TURBO_VARIANT`, `SAME_LINEAGE_AS`, `SHARES_CLIP_TYPE_LABEL_WITH`, `EVIDENCED_BY`, `SHARES_GUARD_CONVENTION_WITH`, `DISTINCT_FAILURE_CLASS_FROM` and `RETRACTS_CLAIM_IN`.
- A link resolves by file name in all the subdirectories, so two concept files must not have the same name.
- Write each `##` heading for one topic. Search by meaning compares each section separately.

### Check concept files

Check a file or a directory before you index it. The check uses the rules of the index command, and it writes nothing.

```python
import curu_memory

for fault in curu_memory.validate("knowledge"):
    print(fault.path, fault.line, fault.message)
```

- `validate` gives a list of [faults](#glossary). An empty list means that the index command indexes each file of the directory, unless an identifier is also in another space. `validate` refuses an encrypted space with `InvalidArgument`, because it checks plaintext files only.
- `path` is relative to the directory that you give. For one file, it is the file name. `line` counts from the first `---` line, and it is None when the fault has no line. `message` is the reason that the index command reports.
- A warning of the index command is not a fault, for example a missing `type` or a link to a missing concept.
- A Markdown link in a code block still needs a link kind. A wiki-link `[[...]]` in code is text.
- `curu_memory.LINK_KINDS` holds the link kinds. `curu_memory.OKF_VERSION` is `0.2`, the version of the format.
- The package ships the format: `schema.json`, a JSON Schema of the front matter, and conformance fixtures. `curu_memory.conformance_fixtures()` gives the directory of the fixtures. Its parent directory holds `schema.json`.
- To test a parser in another language, give it each file of `valid/` and of `invalid/`. Compare its faults with `expected.toml`.

### Add media to a concept

Add example outputs, reference images, audio clips or videos to a concept with a `media` list. Each path is relative to the concept file:

```markdown
---
type: Finding
description: A CFG above 7 burns the colours of WAN2.2 5B.
media:
  - images/cfg-9-burnt.png
  - images/cfg-5-good.png
  - audio/cfg-notes.flac
  - video/cfg-9-motion.mp4
---
```

- The index command embeds each image, clip and video in the same space as the text, so a question in words can find it. This needs an embedder that accepts these media, for example EmbeddingGemma 2 on SGLang.
- An image must be a PNG, JPEG or WebP file. A clip must be a WAV, FLAC, MP3 or Ogg file. A video must be an MP4, MOV, WebM or MKV file. Each file must be 10 MB or less. The command reads the format from the first bytes of the file, not from its name.
- Keep one topic in each clip. A long clip with many topics matches each topic less well.
- The model sees 1 frame of a video each second, at most 32 frames. A longer video gets 32 frames, spread evenly, so a short event in it can be lost.
- Each path must stay in the concept directory, also through a symbolic link. Keep the media files in git with the concept files.
- A media file with a problem gives a warning. The concept stays in the index with its text.
- curu ignores the `media` key, so the same concept files still index in curu.

## Index your knowledge

Run the index command after each change to the concept files:

```bash
curu-memory index --concepts ./knowledge
```

You can also set `CURU_MEMORY_CONCEPTS_DIR` and run `curu-memory index` with no option. Add `--json` to get the report as a JSON object.

The report gives these values:

| Value | Meaning |
|---|---|
| `files read` | The `.md` files in the directory and its subdirectories. A `README.md` file and the files in a hidden directory, for example `.git`, are not concepts. |
| `concepts indexed` | The concepts in the new index. It is `files read` minus `skipped`. |
| `skipped` | The files that are not in the index. The report gives the file, the line and the reason for each file. |
| `warnings` | Problems that did not stop a file, for example a missing `type`. |
| `broken links` | Links to a concept that is in no space. A link to a concept of another space is not broken. The concept stays in the index. |
| `embedder` | The embedding model of the index, or `none` for an index of words only. |
| `media embedded` | The images, clips and videos that a search by meaning can find. |
| `media skipped` | The images, clips and videos that a search by meaning cannot find. Each one is also a warning, with the path and the reason. |
| `index` | The path of the index file in the data directory. |

The command exits with 0 when it skipped no file, and with 1 when it skipped one or more files. The command skips a file for one of these causes:

- The file is not UTF-8 text, or it has no front matter.
- The front matter has no closing `---` line, is not valid YAML, or is not a YAML mapping.
- The front matter has no `description`.
- The body has a `[[wiki-link]]`, or a link with no kind or an unknown kind.
- Two files have the same name. The command skips both files.
- The identifier of the concept is in another space. The reason names that space.

A media file with a problem gives a warning. The command does not skip the concept. It does not put the file in the index for one of these causes:

- The file does not exist, or the path is not a file.
- The path goes outside the concept directory, or it is an absolute path.
- The file is larger than 10 MB, or it is not an image, a clip or a video of a known format.
- The `media` value is not a list of paths. The command ignores the full list, and `media skipped` does not count it.

When no embedder is set, or the embedder did not embed a file, the file has no embedding. It stays in the index, and the browser shows it or plays it. A search by meaning cannot find it, and the report gives a warning. An embedder that accepts only text gives this warning for each file.

The command sends each image, clip and video to the embedder one time, with no task prompt. It keeps each vector in an embedding cache. The next run sends only the changed text and media. The same files always give the same index. The command writes a new index file and then replaces the old one. A server that runs at that time uses the old index until the new index is complete. If the embedder fails on a text, the old index stays.

## Keep team and personal knowledge apart

Give each part of the knowledge its own space. Index each directory into its space:

```bash
curu-memory index --concepts ./knowledge
curu-memory index --concepts ./look --space team/look
curu-memory index --concepts ./me --space personal/me
```

The directory of a team space or a personal space is an encrypted space. Make it with `curu-memory space encrypt`, as [Encrypt the concept files of a private space](#encrypt-the-concept-files-of-a-private-space) says.

- A space is `global`, `team/<slug>` or `personal/<handle>`. The default is `global`.
- The [team slug](#glossary) is the short name of the team, and the [handle](#glossary) is the short name of the person. Each has 1 to 40 characters from `a` to `z`, `0` to `9` and `-`. The first character is a letter.
- Each space has its own index file. An index run changes only the index file of its space. The index of a team space or a personal space is encrypted. See [Keep private knowledge encrypted](#keep-private-knowledge-encrypted).
- The global space uses the embedding cache. A team space or a personal space does not use it, so each index run of that space embeds its text again.
- A concept identifier must be unique in all spaces. The command skips a concept whose identifier is in another space, and the report names that space.
- All spaces must use one embedder model. When the embedder model is not the model of another space, the command stops with exit code 1 and changes no index. The command accepts a space with no embeddings. A search of that space uses words only.
- Only one index run can run at a time. A second run stops with exit code 1 and the message "Another index run holds the lock".
- A bad space name stops the command with exit code 2.

A caller reads only its readable spaces: the spaces where its principal has a role. A caller with no principal reads `global` only. See [Give each person their own spaces](#give-each-person-their-own-spaces). A search finds concepts in each readable space, and each result names its space. `concept_neighbours` and the browser resolve a link to a concept in another readable space.

## Keep private knowledge encrypted

The index of each [private space](#glossary) (a team space or a personal space) is encrypted on disk. A copy of the data directory then does not show the private knowledge. Make a [key file](#glossary) one time, outside the data directory:

```bash
curu-memory key new ~/.config/curu-memory/kek
```

Then name it in the environment of each `index`, `serve`, `browse` and `search` process. A Python program that calls `build_space` or `open_knowledge` also needs it:

```bash
export CURU_MEMORY_KEK_FILE=~/.config/curu-memory/kek
```

- The command writes one line with mode 0600: 32 random bytes in base64url. Only you can read the file. It does not replace a file that exists, because the old key opens the old indexes.
- Keep the key file out of the data directory, out of each repository and out of each backup of the data. A copy of the data and the key together is a copy of the private knowledge.
- Keep a copy of the key file in a safe place, for example a password manager. Without the key file, nobody can read the private indexes. If you lose it, delete each `*.sqlite.enc` file in the data directory. Then make a new key file, and index each private space again.
- `index` of a private space stops with exit code 78 when the key file is not set or does not exist. It also stops when the file holds no key. It stops when the key file is in the data directory, has another hard link, belongs to another user, or other users can read it. It also stops when it cannot read the file. The message names the cause.
- A read of a private space with a wrong or missing key file gives the error `index_unreadable`. The message names the space and the cause. For a missing or unsafe key file, the message names no path, and the log of the process names the full cause.
- The global space is not encrypted. A read of it needs no key file. An index run reads each other space for its checks. Thus each index run needs the key file when the data directory has a private space. An index run of a private space checks the key file first, so a bad key file gives exit code 78. In an index run of the global space, a wrong, missing or unsafe key file gives exit code 1 and the error `index_unreadable`.
- The index of a private space is `team--look.sqlite.enc` or `personal--me.sqlite.enc`. An older version of curu-memory wrote `team--look.sqlite` as plaintext. A read refuses that file. The next index run of the space deletes it. Index each private space again after the upgrade.
- Version 0.5.0 wrote an encrypted index of an older format. A read refuses it with `index_unreadable`. An index run also stops on it, because it reads each other space. Delete each `*.sqlite.enc` file of 0.5.0. Then index each private space again.
- An index run of a private space sends its text only to an embedder on loopback or with `https://`. It stops with exit code 78 for an `http://` embedder on another computer.

> **Warning:** The encryption protects a stolen copy of the data directory and of git. It does not protect against a person who has the key file and a copy of the data directory or of git.

### Encrypt the concept files of a private space

Encrypt the concept files of a private space, so that git and each remote hold only ciphertext. Run the command one time for each space, on the server, with the key file of the server:

```bash
curu-memory space encrypt --space team/look ./look-plain ./look
curu-memory index --concepts ./look --space team/look
```

- `./look` must be new or empty. `./look-plain` does not change. Put `./look` in git in its place.
- `./look` holds `space.json`, `.gitattributes` and the directory `objects/`. Each concept file and each media file becomes one object with a random name of 26 characters. The object holds the path, so the file names show no concept.
- The command ignores hidden files and `README.md`. It names each other file that it does not encrypt, and gives exit code 1. Such a file is, for example, a `.txt` file, a symbolic link or a file larger than 10 MB. It still writes the encrypted space, with the other files, and its space record. To add a named file, delete `./look` and `spaces/team--look.json`, and run the command again.
- Each space gets its own [space key](#glossary). The data directory holds it in the [space record](#glossary) `spaces/team--look.json`, under the key of the key file. `space.json` is a copy of the record. The index refuses a `space.json` of another space, another generation or another current space key than the record.
- The index decrypts the objects in memory, and writes no plaintext to disk. It refuses a changed object, an object under another name or of another space, and two objects of the same path. It also refuses each file beside `space.json` and `objects/` that is not hidden, for example a plaintext `notes.md`. It names the first such file or object and gives exit code 1. Remove it, and run the index again.
- The index of a private space refuses a directory of plaintext concept files, and names `space encrypt`. The index of `global` refuses an encrypted space. Each gives exit code 78.
- The command refuses `global`, which stays plaintext, and a target that is not empty, with exit code 2. A second run for the same space into a new directory gives exit code 78. The command also gives exit code 1 when an index run holds the lock.
- The old plaintext files stay in the git history until you rewrite it, for example with `git filter-repo`. A copy on a git host can stay after a rewrite, for example in old pull requests and forks. Treat that plaintext as known to others, or move the space to a new repository.

To upgrade a space that version 0.8.0 encrypted, encrypt it again. The new version reads only objects of the new format. Do these steps on the server, with the key file of the server:

1. Run version 0.8.0 to make a checkout: `uvx --from 'curu-memory==0.8.0' curu-memory space checkout --space team/look --to ~/look-plain ./look`. The checkout must be outside each git work tree.
2. Delete the record `spaces/team--look.json` of the data directory, and the directory `./look` of the encrypted space.
3. With the new version, run `curu-memory space encrypt --space team/look ~/look-plain ./look`.
4. Commit the space with a signature: `git add --all look`, then `git commit -S`.
5. Lock the checkout: `curu-memory space lock ~/look-plain`.

The space then has a new space key. The old objects stay in the git history under the old key. After the upgrade, `git log -p` shows a locked line for each object of 0.8.0 in older commits. This is correct. Do not encrypt the space again.

These facts still show in git:

- The number of objects, and the size of each object in a small range. A file of up to 64 KiB shows its size to within 1 KiB. A larger file shows its size to within about 3 %.
- How often each object changes, and the time and the author of each commit. Write commit messages that name no concept.
- The space name and the key identifiers in `space.json`.

The output of `index` and of `space encrypt` names the paths of private files. Keep that output and its log private.

### Edit an encrypted space

Edit the files of an encrypted space with any editor. Decrypt the space into a [checkout](#glossary): a private directory. Change the files there. Then commit the change. For a space in git, first do the steps in [Sign the commits of an encrypted space](#sign-the-commits-of-an-encrypted-space):

```bash
curu-memory space checkout --space team/look ./look
curu-memory space status --space team/look
curu-memory space commit --space team/look
curu-memory space lock --space team/look
```

- `space checkout` prints the checkout. Edit the files there. The checkout has mode 700, and each file has mode 600.
- The default checkout is `$XDG_RUNTIME_DIR/curu-memory/checkouts/team--look` when `XDG_RUNTIME_DIR` is set. Else, on macOS, it is in `~/Library/Caches/curu-memory/checkouts/`. Else it is in `~/.cache/curu-memory/checkouts/`. `--to DIR` gives another new or empty directory. `space checkout` refuses a directory in a git work tree or in the space, because a `git add` could then commit the plaintext. For the default checkout on Linux with no `XDG_RUNTIME_DIR`, `space checkout` gives a warning, because a backup of the home directory can copy `~/.cache`.
- On macOS, the command asks Spotlight and Time Machine to skip each checkout. When Time Machine cannot skip it, the command gives a warning.
- `space status` prints one line for each changed, new and deleted path, for example `changed: 'notes/quokka.md'`. No line means no change. It also says when the space changed since `space checkout`, for example after a commit of another person.
- `space commit` encrypts only the new and the changed files. A moved file keeps its object, and a deleted file loses its object. In git, the command makes one signed commit with the message `curu-memory: update an encrypted space`, which names no path. It prints the number of changed, new, deleted and moved files, and the new commit. Outside git, it makes no commit and gives a warning. With no change, it makes no commit.
- When git cannot sign, `space commit` stops with exit code 78 after it writes the objects. Set up the signature, then commit the space with `git commit -S`.
- `space commit` stops with exit code 1, and changes no object, when another person changed an object since `space checkout`. Lock this checkout with `space lock`. Then make a new checkout. Change the file again. It also stops for a merge or a rebase in progress, and for conflict markers. It stops for a symbolic link, a special file, a file larger than 10 MB, a hidden file and a bad path. It also stops for a new path that another object holds, or that differs from a path only in case. Another commit of the same space at the same time also stops it.
- `space lock` deletes the checkout. It refuses a directory that is not a checkout. The delete is a normal delete, not a secure erase.

> **Warning:** The plaintext of a checkout is on disk until `space lock`. An editor can keep copies in other places. Examples are the local history of VS Code or JetBrains, and the swap and backup files of vim or Emacs. Use disk encryption, for example FileVault. Turn off these copies for the checkout.

### Edit a space through the server

Edit an encrypted space with no key file. The curu-memory server decrypts and encrypts each object after it checks your [role](#glossary) on the space. The space key stays on the server. Log in one time. Then check out with `--server`, and commit with the same `--server`:

```bash
curu-memory login --server https://memory.example.com
export CURU_MEMORY_ALLOWED_SIGNERS=~/.config/curu-memory/allowed_signers
curu-memory space checkout --space team/look --server https://memory.example.com ./look
curu-memory space commit --space team/look --server https://memory.example.com
```

`login` prints the issuer, an address and a code. Open the address in a web browser on any device. Log in there. Enter the code. The command then prints `logged in to https://memory.example.com`.

- `login` finds the issuer in the metadata of the server. The metadata must name the address of the server with `/mcp`, so a server cannot start a login for another server. `--issuer URL` gives the issuer when the metadata of the server names none. In the basic mode, the server accepts only the static token, so use `CURU_MEMORY_SERVER_TOKEN`.
- `login` asks for read and write access. `--read-only` asks for read access only, and a commit then stops with exit code 78.
- `login` stops with exit code 1 when you deny the code, or when the code expires before you enter it. Run `login` again.
- The [token cache](#glossary) keeps the tokens, one entry for each server. Its default is `$XDG_CONFIG_HOME/curu-memory/login/tokens.json` when `XDG_CONFIG_HOME` is set. Else it is `~/.config/curu-memory/login/tokens.json`. `CURU_MEMORY_TOKEN_CACHE` gives another file. The file has mode 600, and its directory has mode 700.
- The commands refuse a token cache that other users can read or write, or that another user owns. They also refuse a link, and a cache that is not valid. They also refuse such a directory of the cache. For a file, delete it, then log in again. For a directory, set its mode to 700.
- The commands renew an access token that expires in less than 60 seconds. When the issuer refuses the renewal, the command deletes the entry and stops with exit code 78. Log in again. When the issuer does not answer, the command keeps the entry and stops with exit code 78.
- The commands send a token of the cache only to the server of its entry, and to the issuer of the entry. The audience of the token must name the address of the server with `/mcp`. They print no token.
- `curu-memory logout --server https://memory.example.com` deletes the entry of that server, and prints `logged out of https://memory.example.com`.
- `CURU_MEMORY_SERVER_TOKEN` has priority over the cache. Use it for a token that you got in another way, for example the static token. Each command prints the server that gets it.
- The issuer, its [device endpoint](#glossary), its [token endpoint](#glossary) and the address that you open must use `https://`, except on `127.0.0.1`, `::1` or `localhost`. They must be on the host of the issuer. Else `login` stops with exit code 78.

> **Warning:** Enter only a code that you started yourself with `curu-memory login`. A person who sends you a code and an address can get a token of your account when you enter that code.

- `--server` is the address of the server, with no `/mcp`. It must start with `https://`. Plain `http://` is only for a server on `127.0.0.1`, `::1` or `localhost`. For another address, the command stops with exit code 78 and sends no token.
- In OIDC mode, the token is an access token of the provider for you or for your agent. A commit with a token of a person needs the scope `knowledge:write`. A token with `knowledge:read` or with no known scope reads only. In the basic mode (the static token), the token is the `CURU_MEMORY_HTTP_TOKEN` of the server.
- A checkout needs a read role on the space, and a commit needs a write role. A member of a team has a write role on the team space. An agent has the weaker of two roles: the role of its [grant](#glossary) and the role of its person.
- A commit of the checkout needs the `--server` of the checkout. A commit with no `--server` or with another `--server` stops with exit code 2 and sends nothing. Thus a change of the state file cannot send your token and your files to another server. `space status` and `space lock` need no server and no token.
- Your computer still does each check of [Edit an encrypted space](#edit-an-encrypted-space) and of [Sign the commits of an encrypted space](#sign-the-commits-of-an-encrypted-space). A `space.json` with another generation or another space key than the record of the server stops the command with exit code 78.
- The command stops with exit code 78 for a refused token, no role or a read role on a commit. Exit code 78 is also for no record on the server, a record that the server cannot open, or no answer. The server gives the same answer for no role and for a space that does not exist. An object that the server refuses stops the command with exit code 1.
- The command uses no proxy of the environment or of the system, so the token and the files go only to the server.
- The command uses TLS 1.3 or later and the certificates that the system trusts. For another certificate authority, set `SSL_CERT_FILE` to a file of its root certificates. That file then replaces the certificates of the system.
- Each request with a valid token writes one line to the [audit log](#read-the-audit-log) of the server. A request with a wrong Host header or a wrong content type writes no line.

The server accepts the static token only when the operator turns on the setting. Set it in the environment of `serve`:

```bash
export CURU_MEMORY_SERVER_UNLOCK_STATIC_TOKEN=1
```

- Each holder of the static token then has the roles of `CURU_MEMORY_PRINCIPAL` on each space. Keep the static token as safe as the key file.
- The setting accepts `1`, `true` or `yes`, and `0`, `false` or `no`. Another value stops `serve` with exit code 78.
- Without the setting, the server refuses each request of the static token with HTTP 403. In OIDC mode, the setting has no effect, because each token has its own person.

### Sign the commits of an encrypted space

A space in git opens only at a signed commit. `index`, `space checkout` and `space commit` verify the signature of the last commit, and refuse an uncommitted change of the space, with exit code 78. Thus a person with write access to the git host cannot add, change or delete an object without a valid signature.

1. In the git repository of the space, sign each commit with an SSH key:

   ```bash
   git config gpg.format ssh
   git config user.signingkey ~/.ssh/id_ed25519
   git config commit.gpgsign true
   ```

2. Write the file of allowed signers. Each line is an email, `namespaces="git"` and a public key:

   ```bash
   echo "me@example.com namespaces=\"git\" $(cat ~/.ssh/id_ed25519.pub)" > ~/.config/curu-memory/allowed_signers
   ```

3. Name the file in the environment of `index`, `space checkout` and `space commit`: `export CURU_MEMORY_ALLOWED_SIGNERS=~/.config/curu-memory/allowed_signers`.

- Only an SSH signature of a key in the file of allowed signers passes. git checks a GPG signature against a keyring, not against this file, so a GPG signature does not pass.
- Keep the file of allowed signers outside the repository. The commands refuse a file inside the work tree, because a person who can push could then add a key of their own. A relative path in `CURU_MEMORY_ALLOWED_SIGNERS` starts at the current directory.
- A commit must hold the `space.json` of the space. An ignored file in the space is an uncommitted file. Thus a space that git ignores does not open.
- A space is in git when its directory or a parent directory holds `.git`. With no `CURU_MEMORY_ALLOWED_SIGNERS`, `index`, `space checkout` and `space commit` stop with exit code 78. With no git on the `PATH`, `index`, `space checkout`, `space commit` and `space status` stop with exit code 78.
- A space outside git has no history to check. The report of `index` then has the warning `not in git: no signature of the space was checked`.
- The check does not find a roll back of the whole branch to an older signed commit. Read the git log to find it.

### Read the diff of an encrypted space

See the plaintext changes of an encrypted space in `git diff`, `git log -p` and `git show`. Set up each clone one time:

```bash
curu-memory git install
git log -p -- look/
```

- `git install` writes the [textconv](#glossary) of the [diff driver](#glossary) `curu` to the `.git/config` of this clone only, with the absolute path of curu-memory. Run it again after you move curu-memory.
- After `git install --server https://memory.example.com`, the diff decrypts through the server, with the token of `curu-memory login` or of `CURU_MEMORY_SERVER_TOKEN`. After `git install` with no `--server`, the diff uses the key file.
- The text of each object starts with the lines `space: <space>` and `path: <path>`. git shows them for a new, a deleted or a moved file, and for a change near the start of a file. A media file shows `media: <media type>, <size> bytes`, not its bytes.
- When the diff cannot open an object, it shows `curu-memory: locked object <object> (<hash>): <reason>`, and continues. The reason is, for example, no role on the space or no key file. It is also a space key that no space of the repository holds, or that more than one space holds. `<hash>` is the first 16 characters of the SHA-256 of the object file. Thus two different locked objects give two different lines, and a change always shows. When the settings are not valid, the line has no hash.
- A file that is not an object of a space shows as it is, also when a `.gitattributes` names the driver `curu` for it.
- The diff shows each control character of a text, but not the tab or the line end, as an escape, for example `\x1b`. Thus a text cannot change the terminal.
- `git install` turns off the cache of the textconv, because git keeps that cache in refs that a push can send. `space status` stops with exit code 78 when the cache is on. Run `git install` again.
- The diff decrypts each object in memory, and writes no file.
- git starts curu-memory once for each side of each changed object. One start takes about 0.1 to 0.3 seconds, so a `git log -p` of many commits is slow. A `git log -p` of 20 commits takes about 15 seconds with the key file, and more through the server. Give it a path or a range, for example `git log -p -5 -- look/`.

### Merge the branches of an encrypted space

Merge two branches of an encrypted space with git. `curu-memory git install` also sets up the [merge driver](#glossary) `curu`. Merge with a signature:

```bash
git merge -S other
```

- When both branches changed a file, the driver decrypts the three versions in memory and merges them. It writes no plaintext file. Changes of different lines merge with no conflict when one or more unchanged lines separate them. Changes of adjacent lines, and two different inserts at the same place, are a conflict, as in git.
- When a file changed on one branch only, git takes that version, and the driver does not run.
- The driver merges the path apart from the text. A move on one branch and a change on the other merge. Two different moves of one file are a conflict, and the file keeps the path of your branch. On a terminal, the message names both paths. When the output is not a terminal, for example a log file, the message names only the object, because a path can show a concept.
- A change of the same line on both branches is a conflict. git reports `CONFLICT (content)`, and the object then holds the text with the conflict markers `<<<<<<< ours`, `=======` and `>>>>>>> theirs`.
- A media file that both branches changed is a conflict. The file keeps the version of your branch.
- When the driver cannot open a version, for example with no key file, it reports a conflict and keeps your version. It does the same for a result larger than 10 MB. It also does so when the lines of the base, multiplied by the lines of the larger side, are more than 2,000,000. Two versions of about 1,400 lines each are at that limit.
- A version under an old space key is a conflict too. Such a key is neither the key of the base nor the current key. The version can be an old one that a person put back.
- curu-memory cannot resolve a conflict yet. Stop the merge with `git merge --abort`. Then make the change of one branch on the other branch through a checkout, and merge again.
- The merge commit needs a signature, as each commit of an encrypted space. Use `git merge -S`, or `git config commit.gpgsign true`. The index refuses a merge commit with no signature.
- Two branches can give two objects the same path, for example with two new files, or with a move and a new file. git then merges with no conflict, but the index refuses the space, because two objects hold one path. Run `git reset --hard ORIG_HEAD`. Then rename the file on one branch, and merge again.
- The signature of the merge commit is your signature. The index checks only the signature of the last commit, so your signature is the only check of the commits of the other branch. Read them with `git log -p` before you merge.

### Check the commits before a push

Stop a push that puts plaintext into an encrypted space. Install the [pre-push hook](#glossary) one time in each clone:

```bash
curu-memory space hook install
```

- Before each push, the hook checks each commit that the push sends. It reads the full tree of each commit. In a directory with `space.json`, each file must be `space.json`, the `.gitattributes` of the space or an object. Each other file stops the push. A symbolic link, a submodule, a file that is not a regular or an executable file, and a space inside a space also stop it.
- The message names the commit and the file, for example `3f2a9c1d0b7e: 'look/notes.md': it is not an object of the encrypted space, so it can be plaintext`. The line starts with the first 12 characters of the commit identifier. Remove the file from that commit, for example with `git rebase -i`. Then push again.
- The hook calls curu-memory by its absolute path. Run `space hook install` again after you move curu-memory. It replaces its own hook.
- The command refuses to replace a hook that it did not write. If it refuses, add the line `curu-memory space check --pre-push "$@"` to your hook. It also refuses a hook that is a symbolic link.
- A person can push with `--no-verify`, so run the same check in the CI of the repository of the space: `curu-memory space check origin/main..HEAD`. CI needs the full history, for example `fetch-depth: 0` of `actions/checkout`. Without it, git cannot find `origin/main`, and the check stops with exit code 1 and the message `git rev-list failed`.
- `curu-memory space check --all` checks each commit of each [ref](#glossary). The check reads the commits, not the work tree. It cannot find plaintext outside the directory of a space.
- Exit code 1 means a file that breaks a rule, or a git error. Exit code 2 means a revision that starts with `-`, or no git work tree.

### Rewrite the plaintext history

Remove the old plaintext files of a space from the whole git history after `space encrypt`. Install [git filter-repo](#glossary) first. The old plaintext was in a git remote, so treat it as known to others, also after the rewrite. Do these steps in this order:

1. Make a new [mirror clone](#glossary): `git clone --mirror <url> look.git`. Go into it: `cd look.git`.
2. Write the file `../words.txt`, outside the mirror. Write one line for each word that the commit messages must lose, for example `Zebroquokka==>(removed)`.
3. Remove the plaintext directory and the words: `git filter-repo --invert-paths --path look-plain --replace-message ../words.txt`. Then delete `../words.txt`.
4. Find each branch and each tag whose name shows content: `git for-each-ref --format='%(refname)'`. Rename each one, for example `git branch -m notes-on-zebroquokka notes-1`, or delete it.
5. Check each commit. Make a normal clone of `look.git`. In the clone, run `curu-memory space check --all`.
6. Push the rewritten history: `git push --force --mirror <url>`.
7. In a new clone, add a signed commit: `git commit --allow-empty -S -m "curu-memory: sign the rewritten history"`. Then push it. The rewrite removes each signature of a rewritten commit. Then each person deletes their clone and makes a new one.
8. Ask the git host to remove its copies of the old commits, for example the refs of old pull requests and its caches. If it cannot, move the space to a new repository.

- A clone from before the rewrite can push the old history back. The hook and the check of CI do not stop it, because the old plaintext is outside the directory of a space. Delete each old clone.

### Rotate the key

Rotate the key to change the key file of the private indexes. The rotation encrypts each private index again under a new data key. With the previous key file, the server answers during the rotation. Do these steps in this order:

1. Make a new key file: `curu-memory key new ~/.config/curu-memory/kek-2`.
2. Restart the server with the new key file and the old key file. The old key file is the [previous key file](#glossary):

   ```bash
   export CURU_MEMORY_KEK_FILE=~/.config/curu-memory/kek-2
   export CURU_MEMORY_KEK_FILE_PREVIOUS=~/.config/curu-memory/kek
   ```

3. Rotate each private index: `curu-memory key rotate --old ~/.config/curu-memory/kek --new ~/.config/curu-memory/kek-2`.
4. When the command prints its total line with `failed 0`, remove `CURU_MEMORY_KEK_FILE_PREVIOUS`. Then restart the server.

During the rotation, each read with the two key files opens each index with the key that the index names. An index run also reads each other space in this way. Each index run writes under `CURU_MEMORY_KEK_FILE` only.

The command also wraps the space key of each encrypted space again under the new key. It changes no object, so git sees no change. It prints one line for each private index, one line for each encrypted space, for example `space team/look: rotated`, and a total, for example `rotated 2, skipped 0, failed 0`:

- `rotated`: the index is now under the new key, with a new data key.
- `skipped`: the new key already opens the index. Thus you can run the command again after a stop.
- `failed`: the old key does not open the index, or the index has an older format. Or a person or a fault changed the file, or the index is plaintext. The command does not change the file. Index that space again.

For a `space` line, `rotated` means that the new key wraps the same space key. `skipped` means that the new key opens the record already. `failed` means that the old key does not open the record, and the record does not change.

The command holds the index lock, so it stops with exit code 1 when an index run is active. It gives exit code 1 when a space failed. It stops with exit code 78 for a missing or unsafe key file. It also stops with 78 when the old key and the new key are the same key. It does not read the concept files and does not call the embedder. It warns when `CURU_MEMORY_KEK_FILE` does not hold the new key, because each index run writes under that key file.

If a person steals the key file, rotate the key at once, with the four steps. Then destroy the old key file, each copy of it, and each backup of the data directory from before the rotation. The rotation changes each data key of an index too, so you need no index run. The rotation does not change a space key or the `space.json` in git. A person with the old key file and a copy of git can read each encrypted space. Treat its contents as known, and encrypt each space again as [Limits](#limits) says.

If you lose the key file, a rotation cannot help. Delete each `*.sqlite.enc` file, make a new key file, and index each private space again.

> **Warning:** A rotation does not change a backup. Each backup from before the rotation opens with the old key. Keep the old key file while you keep those backups, or destroy both together.

## Give each person their own spaces

Register each person and each team. The first person is an [instance owner](#glossary):

```bash
curu-memory access person add me
curu-memory access person add guest
curu-memory access team add look
curu-memory access member add look guest
```

Then set `CURU_MEMORY_PRINCIPAL` to the handle of the person of each `serve`, `browse` or `search` process. For example, set `CURU_MEMORY_PRINCIPAL=me` in the environment of the MCP client. A registered [principal](#glossary) reads only the spaces where it has a [role](#glossary). With no principal, a process reads `global` only:

| Space | Role of each person |
|---|---|
| `global` | `admin` for an instance owner, else `read`. |
| `team/<slug>` | `admin` for an instance owner, `write` for a member of the team, else no role. |
| `personal/<handle>` | `admin` for that person only. An instance owner has no role in the personal space of another person. |

- Without `CURU_MEMORY_PRINCIPAL`, a process reads `global` only.
- A `CURU_MEMORY_PRINCIPAL` that names no registered person stops `serve`, `browse` and `search` with exit code 78.
- A value that is not a valid handle stops each command with exit code 78.
- A running server reads the access store at each request. When you remove a person, that person reads nothing from the next request. A tool then gives the error `access_refused`, and the browser shows the page "No access" with HTTP 403. A media request gets HTTP 404. Each such request writes a refused line to the audit log.
- `curu-memory access person add HANDLE --owner` registers another instance owner. The command refuses to remove the last instance owner.
- `curu-memory access person remove`, `access team remove` and `access member remove` remove a person, a team or a member. When you remove a person or a team, the command also removes its team memberships.
- A removed person or team keeps the index of its space. The command says so. Delete its index file in the data directory, for example `personal--me.sqlite.enc` or `team--look.sqlite.enc`. Else a new person or team with the same handle or slug reads it.
- A bad handle or slug gives exit code 2. A change that the rules refuse, for example a new member of a team that does not exist, gives exit code 1.

Show the persons, the teams and the role of each person in each space:

```bash
curu-memory access show
curu-memory access show --json
```

The access store is `access.sqlite` in the data directory. It holds handles and slugs, and no token and no secret. The roles `write` and `admin` give no more access than `read` now. A later version uses them when persons and agents can change knowledge.

### Read the audit log

`audit.jsonl` in the data directory gets one JSON line for each change of access and each refused read. A refused read is a request for a concept, its links, its page or its media in a space without a role. A request of a principal with no role in any space is also a refused read. The caller gets the same answer as for a concept that does not exist.

| Key | Value |
|---|---|
| `time` | The UTC time, for example `2026-10-31T21:00:00Z`. |
| `principal` | `person:<handle>`, `agent:<name>`, `anonymous` for a process with no principal or for a refused token, or `operator` for the command `access`. |
| `acts_for` | The person that an agent acts for, or `null`. |
| `action` | For example `person.add`, `person.link`, `member.remove`, `agent.add`, `grant.add`, `grant.revoke`, `get_concept`, `concept_neighbours`, `page`, `media` or `token`. For an edit through the server: `space_info`, `space_decrypt` or `space_encrypt`. |
| `space` | The space of the change, of the grant or of the refused read. `null` for a change of a person or an agent, for a principal with no role in any space, and for a refused token. Also `null` for an edit through the server that the server refuses before it reads the space. |
| `outcome` | `changed` or `refused`. For an edit through the server, `allowed` or `refused`. |
| `reason` | For a change, the message that the command prints, for example `added the person me`. For an edit through the server, `ok`, `no role`, `no record`, `read only`, `static token off`, `bad request`, `too large`, `object refused` or `record refused`. For a refused read, `no role in the space` or `no role in any space`. For a refused token, the reason of the token check, for example `expired`, `wrong audience` or `unknown subject`. |
| `subject` | The handle, the slug or the agent name of a change, or `<slug>:<handle>` for a member change. `null` for a refused read or a refused token. |
| `count` | Only for an edit through the server: the number of objects or files in the request. 0 for `space_info`, and for a request that the server refuses before it counts the items. |
| `token` | Only for an edit through the server: `oidc` or `static`, the kind of the token. |

A line never holds a token, a question, a concept identifier, a title, media, a path or an object name. curu-memory makes the file with mode 0600. If curu-memory cannot write a line, it writes an error to stderr, and the request gets its normal answer. Rotate it with `logrotate` and its `copytruncate` option.

## Search the knowledge

```bash
curu-memory search "VAE for WAN2.2 5B" --limit 5
curu-memory search "turbo" --type "Model Component" --tag wan
```

- `--limit` sets the number of results, from 1 to 50. The default is 10.
- `--type` keeps only concepts of that OKF type. Repeat it for more types.
- `--tag` keeps only concepts that have the tag. Repeat it, and each concept must have all the tags.
- `--json` prints the same JSON object as the `search_knowledge` tool.

Each result gives the identifier, the space, the title, the type, the description, the tags and the sources. The `status` is the `status` of the front matter. The `score` comes from [reciprocal rank fusion](#glossary), from about 0.006 to 0.018. A higher score is a better match. The best match of words alone has a score of about 0.016. A concept that is first in words and in meaning has a score of about 0.018.

`matched_unit` is the unit that matched the meaning best. `summary` is the title and the description. `overview` is the text before the first heading. `image.1` is an image at the first place of the `media` list. `audio.2` is a clip at the second place, and `video.3` is a video at the third place. Other values are the slug of a `##` heading. A repeated slug gets a number, for example `notes-2`. A `## Summary` heading is `summary-2`. `matched_kind` is `text` for the summary, the overview or a section, `image` for an image, `audio` for a clip and `video` for a video. Both are null when only the words matched the concept, for example in `words_only` mode.

The `mode` line says how the search worked. `hybrid` means words and meaning. `words_only` means words only, and the `notice` line says why. With two or more spaces, a space whose vectors this embedder cannot use is searched by words only. An example is a space with no vectors. When another space can use its vectors, the `mode` stays `hybrid`, and the `notice` names the spaces of words. The `notice` line also shows when nothing matched. A name such as `sdxl_base_1.0` or `wan2.2_vae.safetensors` matches as one word.

The printed result gives the `matched` line of each concept that matched by meaning, for example `matched: image.1 (image)`.

## Search with media

Give an image, an audio clip or a video in place of a question, for example an output of a workflow that has a fault:

```bash
curu-memory search --media output.png --limit 5
curu-memory search --media voice.flac
curu-memory search --media motion.mp4
```

- The file must be a PNG, JPEG, WebP, WAV, FLAC, MP3, Ogg, MP4, MOV, WebM or MKV file of 10 MB or less. The command reads the kind from the first bytes.
- An image finds the concepts whose images or texts are nearest in meaning. A clip finds the concepts whose clips or texts are nearest, and a video finds videos and texts. Each kind of question finds only its own kind of media. `matched_kind` says which kind matched.
- Media has no words, so only the meaning counts. The `mode` is `media`. The `score` is from about 0.009 to 0.016. The first result has about 0.016.
- Give a question or `--media`, not both. Both, or neither, stop the command with exit code 2.
- A bad file stops the command with exit code 2. A bad file is a missing file, a file larger than 10 MB, or a file of an unknown format. The tool gives the error `invalid_argument`, and `details.field` is `media`. See [Handle errors](#handle-errors).
- The search needs an embedder that accepts the kind of the file. Without one, the result is empty, and the `notice` says why.
- `--limit`, `--type`, `--tag` and `--json` work as for a question in words.

An agent uses the tool `search_knowledge_by_media`. It gives the file as [base64](#glossary) text or as a base64 [data URI](#glossary), for example `data:image/png;base64,...`. The base64 text can have line breaks. It never gives a path. Over HTTP, a request can hold a file of 10 MB as base64.

## Search by meaning

Without an embedder, curu-memory searches by words only. To search by meaning too, run an [embedder](#glossary) that serves `POST /v1/embeddings`. SGLang, vLLM, Ollama and llama-server serve this API. The default model is [EmbeddingGemma 2](https://huggingface.co/google/embeddinggemma-2) from Google. It changes text, code, images, video and audio into embeddings that you can compare with each other. It is free to use under Apache 2.0.

For example, serve EmbeddingGemma 2 with SGLang on port 30000:

```bash
sglang serve --model-path google/embeddinggemma-2 --host 127.0.0.1
```

Then set the URL before you index, before you search and before you start the server:

```bash
export CURU_MEMORY_EMBEDDER_URL=http://127.0.0.1:30000
curu-memory index --concepts ./knowledge
```

Ollama gives the same model a different name, so set the name too:

```bash
ollama pull embeddinggemma-2
export CURU_MEMORY_EMBEDDER_URL=http://127.0.0.1:11434
export CURU_MEMORY_EMBEDDER_MODEL=embeddinggemma-2
```

- `CURU_MEMORY_EMBEDDER_URL` is the base URL of the embedder. A URL that ends with `/v1` also works.
- `CURU_MEMORY_EMBEDDER_MODEL` is the model name that the embedder serves. The default is `google/embeddinggemma-2`. Without a URL, this setting stops each command with exit code 78.
- `CURU_MEMORY_EMBEDDER_TOKEN` is an optional bearer token for the embedder.
- `CURU_MEMORY_EMBEDDER_TIMEOUT` is the time in seconds before a search uses words only. The default is 5. The index command uses the same limit for each request of 32 texts or 4 media files. Increase it for a slow embedder.
- `CURU_MEMORY_EMBEDDER_MIN_SIMILARITY` is the lowest [cosine similarity](#glossary) that counts as a match of meaning, from -1 to 1. The default is 0.68 for EmbeddingGemma and 0.35 for other models. A higher value gives fewer matches by meaning. A lower value gives more matches, and an unrelated question can get results. A value that is not a number from -1 to 1 stops each command with exit code 78.
- `CURU_MEMORY_EMBEDDER_MIN_IMAGE_SIMILARITY` is the same limit for a match between a question in words and an image. The default is 0.68 for EmbeddingGemma and 0.35 for other models. A higher value gives fewer matches with images. A lower value gives more matches, and an unrelated question can match an image. A value that is not a number from -1 to 1 stops each command with exit code 78.
- `CURU_MEMORY_EMBEDDER_MIN_AUDIO_SIMILARITY` is the same limit for a match between a question in words and a clip. The default is 0.68 for EmbeddingGemma and 0.35 for other models. A higher value gives fewer matches with clips. A lower value gives more matches, and an unrelated question can match a clip. A value that is not a number from -1 to 1 stops each command with exit code 78.
- `CURU_MEMORY_EMBEDDER_MIN_VIDEO_SIMILARITY` is the same limit for a match between a question in words and a video. The default is 0.71 for EmbeddingGemma and 0.35 for other models. A higher value gives fewer matches with videos. A lower value gives more matches, and a question can match a video with a near motion. Descriptions of the test videos scored from about 0.73 with their own videos, and wrong videos reached about 0.70. A value that is not a number from -1 to 1 stops each command with exit code 78.
- `CURU_MEMORY_EMBEDDER_MIN_IMAGE_TO_IMAGE_SIMILARITY` is the limit when you search with an image and the unit is an image. The default is 0.89 for EmbeddingGemma and 0.35 for other models. Images of the same content score above about 0.93. Different images can score up to about 0.85.
- `CURU_MEMORY_EMBEDDER_MIN_IMAGE_TO_TEXT_SIMILARITY` is the limit when you search with an image and the unit is a text. The default is 0.70 for EmbeddingGemma and 0.35 for other models. Correct descriptions of the test images scored from about 0.75. Wrong descriptions and unrelated concepts reached about 0.69.
- `CURU_MEMORY_EMBEDDER_MIN_AUDIO_TO_AUDIO_SIMILARITY` is the limit when you search with a clip and the unit is a clip. The default is 0.87 for EmbeddingGemma and 0.35 for other models. Clips of the same speech scored above about 0.94. Different speech scored up to about 0.80.
- `CURU_MEMORY_EMBEDDER_MIN_AUDIO_TO_TEXT_SIMILARITY` is the limit when you search with a clip and the unit is a text. The default is 0.70 for EmbeddingGemma and 0.35 for other models. Correct descriptions of the test clips scored from about 0.73. Wrong descriptions reached about 0.69, and unrelated concepts about 0.68.
- `CURU_MEMORY_EMBEDDER_MIN_VIDEO_TO_VIDEO_SIMILARITY` is the limit when you search with a video and the unit is a video. The default is 0.92 for EmbeddingGemma and 0.35 for other models. Videos of the same content scored above about 0.96. Different videos scored up to about 0.89.
- `CURU_MEMORY_EMBEDDER_MIN_VIDEO_TO_TEXT_SIMILARITY` is the limit when you search with a video and the unit is a text. The default is 0.73 for EmbeddingGemma and 0.35 for other models. Correct descriptions of the test videos scored from 0.737. Wrong descriptions reached 0.728 in an earlier measurement, and unrelated concepts 0.719.
- Each of these six settings must be a number from -1 to 1. A higher value gives fewer matches, and a lower value gives more. A value that is not a number from -1 to 1 stops each command with exit code 78.

### Set the task prompts

EmbeddingGemma needs a [task prompt](#glossary) around each text. curu-memory adds the prompts that Google gives for the model when the model name has `embeddinggemma` in it:

| Text | Prompt |
|---|---|
| A question | `task: search result \| query: {text}` |
| A section of a concept | `title: {title} \| text: {text}` |

`{text}` is the text. `{title}` is the title of the concept, and only the prompt of a section can use it. A different model gets the text with no prompt. Set `CURU_MEMORY_EMBEDDER_QUERY_PROMPT` for the prompt of a question, and `CURU_MEMORY_EMBEDDER_DOCUMENT_PROMPT` for the prompt of a section. Each prompt must contain `{text}`, or the command stops with exit code 78.

A search uses words only and says why in these cases:

- No embedder is set.
- The index has no embeddings, or it comes from a different model or other prompts.
- The embedder fails or does not answer in time.

An index from version 0.1.0 has no record of its prompts, so it counts as an index with no prompts. To use a different model or other prompts, run the index command again. The index command keeps an embedding cache in the data directory, so a second run sends only the changed text and media to the embedder.

## Connect a local agent

A local agent starts curu-memory itself and talks to it on stdio:

```bash
curu-memory serve
```

Do not run this command by hand. Give it to the MCP client, as in the [Quickstart](#quickstart). The server reads the index of the data directory. Messages for a person go to stderr. Set `CURU_MEMORY_PRINCIPAL` in the environment of the MCP client, so that the agent reads your personal and team spaces. To give the agent less than your access, see [Limit a local agent to one grant](#limit-a-local-agent-to-one-grant).

### Limit a local agent to one grant

A local agent on stdio reads as `CURU_MEMORY_PRINCIPAL`. To give it less, start it as the agent of one grant:

```bash
curu-memory serve --grant 3
```

The process then reads only the space of that grant. A grant that is not live stops the command with exit code 78. This limit is not a security boundary: a process can start with no `--grant`. With `--transport http`, `serve` refuses `--grant` with exit code 2, because the token of each request decides.

## Connect a remote agent

A remote agent connects over streamable HTTP. The server refuses each request without the token:

```bash
export CURU_MEMORY_HTTP_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
export CURU_MEMORY_OIDC_RESOURCE=https://memory.example.com/mcp
curu-memory serve --transport http --host 0.0.0.0 --port 6474
```

- The token must have 32 characters or more. Without it, the server does not start and exits with 78.
- `--host` sets the address. The default is `127.0.0.1`, which only this computer can reach.
- curu-memory serves plain HTTP only. On an address other than `127.0.0.1`, `::1` or `localhost`, put a [TLS proxy](#glossary) in front of it. Set `CURU_MEMORY_OIDC_RESOURCE` to the `https://` address of the proxy, with `/mcp`. Without an `https://` value, the server does not start and exits with 78.
- On such an address, the server accepts only the Host header of `CURU_MEMORY_OIDC_RESOURCE`. The Host header is the server name in the request. A request with a valid token and another Host header gets HTTP 421. A request with another Origin header gets HTTP 403.
- `--port` sets the port, from 1 to 65535. The default is 6474.
- A request without the token, or with a different token, gets HTTP 401 and no knowledge.

Then connect the agent with the same token:

```bash
claude mcp add --transport http curu-memory https://memory.example.com/mcp --header "Authorization: Bearer $CURU_MEMORY_HTTP_TOKEN"
```

To run the server with rauthy and a TLS proxy in one step, see [Deploy the stack with TLS](#deploy-the-stack-with-tls).

## Connect agents with OIDC tokens

Use OIDC tokens on HTTP, so that each person has their own identity. curu-memory checks each [access token](#glossary) with the keys of one [OIDC provider](#glossary). It never issues a token, and it keeps no password.

1. Register each person, and link the person to the [issuer](#glossary) and the [subject](#glossary) (`sub`) of their tokens. For a new person, use `add`:

   ```bash
   curu-memory access person add me --issuer https://auth.example.com/application/o/curu-memory/ --subject 4f0c2a
   ```

   For a person that exists, use `link`. A new link replaces the old link of the person:

   ```bash
   curu-memory access person link me --issuer https://auth.example.com/application/o/curu-memory/ --subject 4f0c2a
   ```

   Give `--issuer` the same text as `CURU_MEMORY_OIDC_ISSUER` and as the `iss` of the tokens, with the same last `/`.

2. Set the settings in the table below. Set `CURU_MEMORY_OIDC_RESOURCE` to the address that the MCP clients use, for example `https://memory.example.com/mcp`. Then start the server:

   ```bash
   curu-memory serve --transport http --host 0.0.0.0 --port 6474
   ```

3. Connect the MCP client with no token. The MCP client finds the provider from the metadata of the server, and asks the person to log in:

   ```bash
   claude mcp add --transport http curu-memory https://memory.example.com/mcp
   ```

| Setting | Meaning | Default |
|---|---|---|
| `CURU_MEMORY_OIDC_ISSUER` | The issuer URL of the provider, as in the `iss` of a token. It turns on the OIDC mode. Plain `http` is only for an issuer on `127.0.0.1`, `::1` or `localhost`. | none |
| `CURU_MEMORY_OIDC_RESOURCE` | The [canonical URI](#glossary) of the server: the address that the MCP clients use, with `/mcp`. On a network address it must start with `https://`. | `http://<host>:<port>/mcp` of `serve` |
| `CURU_MEMORY_OIDC_AUDIENCE` | The value that the [audience](#glossary) (`aud`) of a token must hold. | the resource |
| `CURU_MEMORY_OIDC_TOKEN_TYPES` | The accepted `typ` values of the token header, separated by commas. | `at+jwt` |
| `CURU_MEMORY_OIDC_BASIC_CLIENT_ID` | The client identifier for the exchange of an app password. Empty turns the exchange off. See [Let an agent send its app password](#let-an-agent-send-its-app-password). | none |

A bad value stops each command with exit code 78.

- The server publishes its metadata at `/.well-known/oauth-protected-resource/mcp`. It names the provider.
- A request with no token, or with a refused token, gets HTTP 401. The `WWW-Authenticate` header names the metadata URL.
- curu-memory refuses a token with a bad signature, the algorithm `none` or an [HMAC](#glossary) algorithm.
- It also refuses a token of another issuer or audience. It refuses a token whose header has a `typ` that is not in the list. A token with no `typ` passes this check.
- It also refuses an expired token, and a token that is valid for more than 1 hour. It refuses a token that names no registered person and no registered agent.
- Each refused token writes a line to the audit log, with the reason and no part of the token.
- curu-memory reads the keys of the provider again for an unknown key, at most one time each 60 seconds. After a failed read, it waits 10 seconds.
- With `CURU_MEMORY_OIDC_ISSUER`, `serve` ignores `CURU_MEMORY_HTTP_TOKEN`, and writes a notice to stderr. The static token is the basic mode only when you use no OIDC provider. The browser has no OIDC login, so `browse` still uses the static token.
- A token of an agent, for example a token of the [client credentials grant](#glossary), reads only the spaces of its grants. See [Give an agent its own access](#give-an-agent-its-own-access). A token of no person and no agent gets HTTP 401.

### Use Authentik

Authentik puts the client identifier of its [OAuth client](#glossary) in `aud`, and writes the token type `JWT`. Make one provider only for curu-memory, so that no token of another application has that `aud`:

1. In Authentik, make an OAuth2/OpenID provider and an application for curu-memory. An Authentik [blueprint](#glossary) can also make them.
2. Select a [signing key](#glossary) for the provider, for example `authentik Self-signed Certificate`. Without a signing key, Authentik signs with HS256, and curu-memory refuses each token.
3. Add the [redirect URI](#glossary) of your MCP client to the provider.
4. Find the client identifier and the issuer of the provider. The issuer is `https://<authentik host>/application/o/<application slug>/`.
5. Set `CURU_MEMORY_OIDC_ISSUER` to the issuer, `CURU_MEMORY_OIDC_AUDIENCE` to the client identifier, and `CURU_MEMORY_OIDC_TOKEN_TYPES` to `at+jwt,JWT`.
6. Find your `sub`. The subject mode of the provider sets it. The default is a hash of your user identifier, which you can read in one of your tokens. With the subject mode "Based on the User's username", your `sub` is your user name.
7. Link your person to your `sub` with `curu-memory access person link`.

With the type `JWT`, or with no type, curu-memory also requires the claims `azp` and `scope`. An ID token of Authentik has no `scope`, so curu-memory refuses an ID token that an MCP client sends as an access token.

### Give an agent its own access

An agent acts for one person, and reads only the spaces of its grants. First, register the agent. Then give it a grant:

```bash
curu-memory access agent add scout --person me --issuer https://auth.example.com/application/o/curu-memory/ --subject 9b1e7d
curu-memory access grant scout --space team/look --role read --days 30
```

- Use `--subject` for an Authentik service account: its `sub` is in each of its tokens. Use `--client` for a rauthy client, whose tokens have no `sub`. A token with a `sub` is the agent of a client only when the `sub` is the client identifier. Thus a person that logs in through that client is not the agent.
- In a space, an agent gets the weaker of two roles: its strongest live grant, and the current role of its person. When the person loses a role, the agent loses it too.
- A grant lasts 90 days by default, and at most 366 days. The command refuses a grant that is stronger than the role of the person.
- `access grant` prints the identifier of the grant. End a grant with `curu-memory access revoke <id>`. From its next request, the agent reads that space only through its other live grants for that space, if it has some.
- `curu-memory access agent remove <name>` removes an agent and its grants. When you remove a person, its agents go too.
- `curu-memory access show` lists each agent and its live grants.
- An agent with no live grant gets the error `access_refused`. Each audit line of an agent names its person in the key `acts_for`.
- A token that matches more than one person or agent gets HTTP 401, with the audit reason `more than one principal`.

#### Let an agent send its app password

Many agent tools can send only a fixed header, and an Authentik token expires after some minutes. Set `CURU_MEMORY_OIDC_BASIC_CLIENT_ID` to the client identifier of the provider, for example `curu-memory`. Then an agent can send the user name and the app password of its Authentik service account, in the header `Authorization: Basic <base64 of user name:app password>`.

- curu-memory sends them to the token endpoint of the issuer, with the client credentials grant. Then it checks the token as each other token.
- curu-memory keeps the token until 30 seconds before its `exp` or its `expires_in`, whichever comes first, and at most 1 hour. It finds the kept token by the SHA-256 of the header, so it keeps no password.
- A refused exchange gets HTTP 401, and the audit log gets a line with the reason `basic exchange refused`. No line holds the user name or the password.
- curu-memory keeps a refused header for 10 seconds, so a repeat of it sends no request to the issuer.
- At most 4 exchanges run at the same time. More Basic requests wait, and bearer tokens do not wait for them.
- The token endpoint must use `https` off loopback.
- The app password goes to curu-memory with each request, so put TLS in front of curu-memory. Leave the setting empty where no agent needs it.

### Use the shipped rauthy

If you have no OIDC provider, use the rauthy of `deploy/rauthy/`. rauthy is an OIDC provider in one container, with no database server. Run these commands from the root of the repository:

```bash
deploy/rauthy/init.sh
docker compose -f deploy/rauthy/compose.yaml up -d
deploy/rauthy/setup.sh
```

- `init.sh` writes `deploy/rauthy/local/` with new random secrets, one time. Git ignores that directory.
- rauthy listens on `127.0.0.1:6480`, and its issuer is `http://localhost:6480/auth/v1/`.
- `setup.sh` also puts the canonical URI in each token of the client `curu-memory-cli`, which `curu-memory login` uses. A code of that client is valid for 5 minutes.
- `setup.sh` lets the OAuth client `curu-memory` ask for tokens for `http://127.0.0.1:6474/mcp`. Set `CURU_MEMORY_OIDC_RESOURCE` before `init.sh` and `setup.sh` for another address.
- The OAuth client `curu-memory` uses the [authorization code grant](#glossary) with [PKCE](#glossary) (S256). Its redirect URIs are `http://localhost:6476/callback` and `http://127.0.0.1:6476/callback`. An MCP client that uses another redirect URI registers its own OAuth client.
- The first start writes the password of `admin@localhost` to the log of the container. Read it with `docker compose -f deploy/rauthy/compose.yaml logs rauthy`.
- Make your user in the admin pages of rauthy. A user needs a given name. Your `sub` is the user identifier that rauthy shows.
- An access token of the OAuth client `curu-memory` is valid for 10 minutes.

See [deploy/rauthy/README.md](deploy/rauthy/README.md) for a server on a network, behind a TLS proxy.

## Deploy the stack with TLS

Deploy curu-memory, rauthy, an embedder and the TLS proxy Caddy together on a server with Docker. The stack serves three host names over HTTPS: the MCP server, the browser and rauthy. Caddy gets their certificates from Let's Encrypt. You need a server with ports 80 and 443 free, and a DNS name for each of the three host names. The stack needs Docker Compose 2.20 or later. Run these commands from the root of a clone:

```bash
MEMORY_HOST=memory.example.com BROWSE_HOST=browse.example.com AUTH_HOST=auth.example.com ACME_EMAIL=me@example.com CONCEPTS_DIR=/srv/knowledge deploy/stack/init.sh
docker compose -f deploy/stack/compose.yaml --env-file deploy/stack/local/.env up -d --build
deploy/stack/setup.sh
```

- `init.sh` writes `deploy/stack/local/` one time, with new random secrets. It refuses to run again while `local/` exists.
- The first start builds the global space from `CONCEPTS_DIR`. Each start builds it again.
- Each host name accepts TLS 1.3 only, and sends [HSTS](#glossary). Plain HTTP gets a redirect to HTTPS.
- Only Caddy publishes ports. Inside the stack, curu-memory reaches rauthy at its `https://` address through Caddy.
- `setup.sh` lets the OAuth client `curu-memory` ask for tokens for `https://<MEMORY_HOST>/mcp`.
- The stack has its own embedder, so a search uses words and meaning. The first start downloads one fixed version of the model from Hugging Face, about 1.5 GB, into a Docker volume. The index waits until the model loads. A new build of the image keeps the model.
- The embedder needs about 4 GB of memory, and its limit is 6 GB. `EMBEDDER_MEMORY` changes the limit. It also uses at most 2 CPUs. `EMBEDDER_CPUS` changes that limit.
- curu-memory sends private text to Caddy over TLS 1.3, on a port that the stack does not publish. Caddy sends it to the embedder as plain HTTP on a private network. Only Caddy and the embedder share this network, and it has no route out of the server.
- The stack waits up to 300 seconds for each request to the embedder, because an embedder on a CPU is slow. If the embedder does not answer, a search can wait this time before it uses words only. `CURU_MEMORY_EMBEDDER_TIMEOUT` changes the time.
- If Hugging Face asks for a login to download the model, set `HF_TOKEN` before `init.sh`. Use a token of an account that accepted the licence of the model. Without the token, the log of the service `embedder-fetch` names `HF_TOKEN`, and the services `index`, `serve` and `browse` do not start.
- To use an embedder that you already run, set `CURU_MEMORY_EMBEDDER_URL` to its `https://` address before `init.sh`. The stack then starts no embedder and downloads nothing. `init.sh` refuses an `http://` address, because private text goes to the embedder.
- To search by words only, set `EMBEDDER=none` before `init.sh`. The stack then starts no embedder.
- The web browser asks for a user name and a password. Type any user name, and the value of `CURU_MEMORY_HTTP_TOKEN` in `deploy/stack/local/curu-memory.env` as the password. The browser has no OIDC login.
- The stack without an embedder uses less than 1 GB of memory.

To test the stack on one computer, use local mode. Caddy then uses its own certificate authority (CA) for `memory.localhost`, `browse.localhost` and `auth.localhost`. Give the directory of your concept files:

```bash
STACK_MODE=local CONCEPTS_DIR=$PWD/knowledge deploy/stack/init.sh
```

Then start the stack with the `docker compose` command above. Then run `deploy/stack/setup.sh`. It copies the root certificate of the CA to `deploy/stack/local/caddy-root.crt`. Give that file to each client, for example `curl --cacert deploy/stack/local/caddy-root.crt https://memory.localhost/mcp`. [deploy/stack/README.md](deploy/stack/README.md) gives the other settings, the trust of the CA and the staging certificates of Let's Encrypt.

## Read a concept and follow its links

The four tools are read-only:

| Tool | Input | Result |
|---|---|---|
| `search_knowledge` | `question`, and optional `limit`, `types` and `tags` | The matching concepts, the `mode` and the `notice`, as in [Search the knowledge](#search-the-knowledge). |
| `search_knowledge_by_media` | `media` as base64 text or a base64 `data:` URI, and optional `limit`, `types` and `tags` | The same object as `search_knowledge`, with the `mode` `media`. See [Search with media](#search-with-media). |
| `get_concept` | `id` | The fields of one concept, its space, its path, its full Markdown body and its `media` list. Each `media` item gives the `path` relative to the concept directory, the [`sha256`](#glossary) and the [`mime`](#glossary) type of an image, a clip or a video. |
| `concept_neighbours` | `id` | The links from the concept (`outgoing`) and to the concept (`incoming`). |

Each neighbour gives the identifier, the link kind and the title of the other concept. `resolved` is false for a link to a concept that is not in a space that the caller can read. Such a link has no title. Use these tools in sequence: search, read the best concept, then follow its links for more context.

## Browse the knowledge

A person can search the knowledge and follow the links of a concept in a web browser:

```bash
curu-memory browse
```

- `--host` sets the address. The default is `127.0.0.1`, which only this computer can reach.
- `--port` sets the port, from 1 to 65535. The default is 6475.

Open `http://127.0.0.1:6475/`. Type a question, and the page lists the same concepts, in the same order, as the `search_knowledge` tool. Each result shows its title, type, description, tags and score. The score, the mode and the notice mean the same as in [Search the knowledge](#search-the-knowledge). Select a result to open its page.

- The page of a concept shows its fields, its sources, its body and its links in both directions.
- The page of a concept also shows each image and plays each clip and video of its `media` list that is in the index. The caption is the path relative to the concept directory. The index holds a copy of each file, so the browser does not need the concept directory. Run the index command again to show a changed file.
- A link to another concept in the index opens the page of that concept.
- A link to a missing concept is crossed out and has no address.

Without an index, each page says "No index" and gets HTTP 503. Run `curu-memory index`, then load the page again.

The browser only reads. It accepts GET and HEAD only, and each other method gets HTTP 405. Each media file has the address `/media/<sha256>`, from the SHA-256 of its bytes. The browser answers a Range request, so a web browser can seek in a clip. Each other address under `/media/` gets HTTP 404. The browser never reads a file path from a request. HTML and scripts in a concept show as text and never run.

To open the browser to other computers, put a TLS proxy in front of it. Then set the token and the address of the proxy:

```bash
export CURU_MEMORY_HTTP_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
export CURU_MEMORY_BROWSE_URL=https://browse.example.com/
curu-memory browse --host 0.0.0.0
```

The web browser then asks for a user name and a password. Type any user name, and the token as the password. Without a token of 32 characters or more, the command does not start and exits with 78. Without an `https://` value in `CURU_MEMORY_BROWSE_URL`, it also exits with 78.

## Handle errors

A tool failure is an error result. Its text is one JSON object with `code`, `message` and `details`:

```json
{"code": "concept_not_found", "message": "No concept has the identifier 'x'. Use an identifier from search_knowledge.", "details": {"id": "x"}}
```

| Code | Cause | What to do |
|---|---|---|
| `index_missing` | The data directory has no index. | Run `curu-memory index`. |
| `concept_not_found` | No concept has the identifier. | Use an identifier from `search_knowledge`. |
| `index_unreadable` | A team space or a personal space cannot be read. The cause is a missing or wrong key file, a bad previous key file, a changed file, or an index of an older version. The message names the space. | Set `CURU_MEMORY_KEK_FILE` to the key file of the space. During a rotation, also set `CURU_MEMORY_KEK_FILE_PREVIOUS`. After a rotation, remove it. Or index the space again. |
| `access_refused` | The principal can read no space, for example a person that the operator removed, or an agent with no live grant. | Ask the operator to register the person, or to give the agent a grant. |
| `invalid_argument` | A value does not obey a rule, for example a question of only spaces. `details.field` names the field. | Correct the value. |

The tool schema refuses some values first, for example `limit: 51` or an empty question. For these values, the MCP SDK gives its own validation error, not one of these codes.

The `curu-memory` command uses these exit codes:

| Exit code | Meaning |
|---|---|
| 0 | Success. |
| 1 | `index` skipped a file, the embedder failed, another index run held the lock, or the embedder model was not the model of another space. `search` found no index, or no readable space. `access` refused a change. `key rotate` could not rotate a space, or an index run held the lock. `space commit` refused a change, a file, a merge or the lock. `login` got a denied code, or the code expired. `git merge` found a conflict, or an object or a setting that is not valid. `space checkout` or `space commit` got an object that the server refused. `space check` found a file that breaks a rule, or git failed. `space hook install` refused a hook that it did not write, or a hook that is a symbolic link. |
| 2 | A usage error, for example `--limit 51`. `space check` got a revision that starts with `-`, or `space check` or `space hook` ran outside a git work tree. `git install`, `git textconv` or `git merge` ran outside a git work tree. |
| 78 | A configuration error, for example HTTP with no token, `serve` with no `server` extra, or a private space with no safe key file. `key new` refused a path. `key rotate` got a missing or unsafe key file, or the same key twice. `serve` or `browse` on a network address has no `https://` address of a TLS proxy. A space in git has no valid signature, an uncommitted change, no `CURU_MEMORY_ALLOWED_SIGNERS`, or no git on the `PATH`. An edit through the server has no `CURU_MEMORY_SERVER_TOKEN` and no login, a token for another audience, or an `http://` address that is not loopback. It also got a refused token, no role, no record, a read role on a commit, or no answer. `login` or `logout` got a bad address, no metadata, a refused client, no answer, or a token cache that is not safe. `git install` got a bad `--server` address. `space status` found the cache of the textconv on. |

## Use curu-memory from Python

Use the library to build a space and to read it from a Python program, with no server. Import each name from `curu_memory` only. The names of `curu_memory.__all__` are the public API. Each other name can change in any release. The public API starts with version 0.3.0. `validate` starts with 0.4.0. `IndexUnreadable` starts with 0.5.0. The index format 2 starts with 0.6.0. A program pins the current minor version, for example `curu-memory>=0.9,<0.10`.

```python
from pathlib import Path

import curu_memory

report = curu_memory.build_space(Path("knowledge"))
print(report.concepts_indexed, len(report.skipped))

knowledge = curu_memory.open_knowledge()
for hit in knowledge.search("VAE for WAN2.2 5B", limit=3).results:
    print(hit.id, hit.space)

concept = knowledge.get_concept("wan22-5b-vae")
links = knowledge.neighbours(concept.id)
```

- `build_space` builds one space, as the index command does. `space=` names the space, and the default is `global`. A skipped file is not an error. The fields of the report are the values of [Index your knowledge](#index-your-knowledge), except `index`, for example `concepts_indexed`. `skipped` holds a (path, reason) pair for each skipped file.
- `open_knowledge` reads as one [principal](#glossary). Give `person="<handle>"` to read the spaces of that person. Give `grant=<id>` to read only the space of that grant. With neither, the library reads `global` only.
- An unknown, ended or revoked grant does not stop `open_knowledge`. Each read then raises `NoReadableSpace`. `serve --grant` stops at the start for such a grant.
- Each read gets the readable spaces again. A removed role or a revoked grant stops the next read.
- `search`, `get_concept` and `neighbours` give the same results as the tools `search_knowledge`, `get_concept` and `concept_neighbours`. Each hit gives its `space`.
- Each refused read writes the line in the audit log that the server writes, with the name of the matching tool as the action.
- `data_dir=` replaces the data directory. `env=` gives the `CURU_MEMORY_*` variables as a dictionary, for example the embedder settings of [Search by meaning](#search-by-meaning). With no `env`, the library reads the environment of the process.
- The library does not use `CURU_MEMORY_PRINCIPAL` to select the principal. A value that is not a valid handle raises `ConfigError`.
- The principal is not a security boundary in one process. The boundary is who can read the data directory.

Each error is in `curu_memory`:

| Call | Cause | Error |
|---|---|---|
| `build_space` | The concept directory does not exist, or the space name is not valid. `details.field` names the argument. | `InvalidArgument` |
| `build_space` | Another index run holds the lock. | `IndexLocked` |
| `build_space` | The embedder model is not the model of another space. | `IndexRefused` |
| `build_space` | The embedder failed. The old index stays. | `EmbedderFailed` |
| `build_space` | Another private space in the data directory cannot be read. The message names the space. | `IndexUnreadable` |
| `build_space`, `open_knowledge` | A `CURU_MEMORY_*` variable is not valid. | `ConfigError` |
| `build_space` | A private space has no safe key file, or its embedder is not on loopback and does not use TLS. | `ConfigError` |
| `validate` | The path does not exist. `details.field` is `path`. | `InvalidArgument` |
| `open_knowledge` | No person has the handle, or `person` and `grant` are both given. | `InvalidArgument` |
| each read | The principal can read no space. | `NoReadableSpace` |
| `get_concept`, `neighbours` | No readable space holds the concept. | `ConceptNotFound` |
| `search` | The question is empty or too long, or the limit is not from 1 to 50. | `InvalidArgument` |
| each read | No readable space has an index. | `IndexMissing` |
| each read | A readable private space cannot be decrypted. The message names the space. | `IndexUnreadable` |

Each error except `IndexLocked` and `ConfigError` is a `CuruMemoryError`. Its `code` is the code that a tool gives, for example `concept_not_found`.

## Limits

- On stdio, in the browser and with the static token, one process reads as one person (`CURU_MEMORY_PRINCIPAL`). In Python, each `Knowledge` reads as the principal of its `open_knowledge`. On stdio, `serve --grant` makes the process read as the agent of one grant. Only the OIDC mode of HTTP gives each request its own person or agent.
- The token cache is a file, not the keyring of the operating system. A person with your user account can read it.
- An Authentik provider for `login` needs the [device login](#glossary), the scopes `knowledge:read` and `knowledge:write`, and the canonical URI in the audience of its tokens. The repository ships no Authentik blueprint for it.
- A rotation reads and writes each whole private index, one space at a time. It does not change a backup, a concept file, a space key or a `space.json`.
- The key file is the only source of the key. An OS keyring and a key management service are not supported.
- A search of a private space sends its question to the embedder. The check of loopback or TLS is for an index run only, so use a loopback or TLS embedder for the server too.
- A reader decrypts the whole index of a private space into memory. A private space with many media files uses memory for each file.
- The browser has no OIDC login.
- Agents and persons cannot write knowledge through curu-memory yet. Edit the concept files, then run the index command.
- The browser accepts one static token. Without `CURU_MEMORY_OIDC_ISSUER`, the HTTP transport also accepts only one static token. Neither serves TLS itself, so a TLS proxy must stand in front of each on a network.
- The embedder of the stack runs on the CPU, so an index run of many concepts takes some minutes. The embedder image is about 2.1 GB, and the model adds about 1.5 GB.
- The browser has no log-out button. Close the web browser to forget the token.
- The browser shows the links of a concept as lists, not as a picture of the graph.
- Search compares each embedding with the question one by one. This is fast for some thousands of concepts. It becomes slow at about 100,000 concepts.
- A match of meaning needs a [cosine similarity](#glossary) of 0.68 or more with EmbeddingGemma 2. The measured margin is small: unrelated questions reached 0.67, and known answers started at 0.70. A real question with few shared words can miss its answer by meaning. With another model, the limit is 0.35. Some models give lower values for good matches, so measure the limit and set `CURU_MEMORY_EMBEDDER_MIN_SIMILARITY`.
- A match between a question in words and an image also needs 0.68 or more with EmbeddingGemma 2. Simple test images scored from 0.74 with their descriptions, and wrong images reached 0.66. Real outputs of ComfyUI can score lower. If an image that you describe well does not match, measure and set `CURU_MEMORY_EMBEDDER_MIN_IMAGE_SIMILARITY`.
- A search with an image uses 0.89 for image to image and 0.70 for image to text with EmbeddingGemma 2. The margin for image to text is small: a wrong description of a test image reached 0.695, and an unrelated curu concept reached 0.684.
- A search with a clip uses 0.87 for clip to clip and 0.70 for clip to text. The margin for clip to text is also small: a wrong description reached 0.692. A correct description scored as low as 0.712 in an earlier measurement. These limits come from changed copies of speech clips. Music, sound effects and other recordings can score lower.
- A search with a video uses 0.92 for video to video and 0.73 for video to text. The margin for video to text is the smallest. A wrong description reached 0.728 in an earlier measurement, and a correct description scored as low as 0.737. These limits come from changed copies of short animations. Real outputs of ComfyUI can score lower.
- A question cannot hold words and media together. The browser cannot search with media. The list of search results does not show media. Each kind of media question finds only its own kind of media and texts.
- A match between a question in words and a video needs 0.71 or more with EmbeddingGemma 2. The test videos are short animations of shapes. A question about motion can match a video with a near motion.
- A match between a question in words and a clip needs 0.68 or more with EmbeddingGemma 2. The limit comes from speech clips. Speech test clips scored from 0.73 with their own questions. Wrong clips and unrelated questions reached 0.64. Music and sound effects can score lower.
- The index holds a copy of each media file. One hundred files of 2 MB make the index about 200 MB larger.
- There is no file watcher. Run the index command after each change.
- `validate` does not read the index, so it does not find an identifier that is also in another space. For one file, it does not find a duplicate identifier.
- The Python API cannot search with media, and it cannot write a concept. Use `curu-memory search --media` or the tool `search_knowledge_by_media` for media.

## Technical notes

- The first start of the stack downloads the model of its embedder, because `docker compose up` is a command that a person types. An operator can also give an embedder that runs already, and the stack then downloads nothing. See [ADR-023](project-management/ADRs/ADR-023-the-stack-embedder-downloads-its-weights-on-compose-up.md).
- The index is one SQLite file for each space in the data directory, for example `~/.local/share/curu-memory/global.sqlite`. The file of `team/look` is `team--look.sqlite.enc`. `CURU_MEMORY_DATA_DIR` changes the directory. `XDG_DATA_HOME` also changes the default. See [ADR-001](project-management/ADRs/ADR-001-sqlite-index-per-tier-space.md). The space names and the readable spaces come from [ADR-016](project-management/ADRs/ADR-016-spaces-roles-and-agent-grants.md).
- The index lock uses `fcntl.flock`, so curu-memory runs on Linux and macOS, not on Windows.
- The merge driver compares lines with `difflib` of Python, not with the diff of git. Thus a conflict can hold other lines between its markers than `git merge-file` gives. See [ADR-025](project-management/ADRs/ADR-025-the-object-header-names-the-object-and-the-merge-stays-in-memory.md) and [specs/029-git-merge-driver/contracts/cli.md](specs/029-git-merge-driver/contracts/cli.md).
- The login uses the device login of RFC 8628, and follows [ADR-024](project-management/ADRs/ADR-024-curu-memory-is-the-encryption-overlay-of-a-private-space.md). The contract is [specs/027-device-login/contracts/cli.md](specs/027-device-login/contracts/cli.md).
- The OIDC mode follows [ADR-015](project-management/ADRs/ADR-015-oidc-resource-server.md) and [ADR-017](project-management/ADRs/ADR-017-oidc-settings-for-authentik.md). The contract is [specs/012-oidc-tokens-with-the-shipped-rauthy/contracts/oidc.md](specs/012-oidc-tokens-with-the-shipped-rauthy/contracts/oidc.md).
- Agents, grants and the Basic exchange follow [ADR-018](project-management/ADRs/ADR-018-agents-grants-and-the-basic-exchange.md). The contract is [specs/013-agent-grants/contracts/agents.md](specs/013-agent-grants/contracts/agents.md). The exchange sends `grant_type=client_credentials`, `client_id`, `username`, `password` and `scope=openid profile`. The cache keeps at most 1,000 tokens, and it removes the oldest first.
- curu-memory accepts keys of the algorithms RS256, RS384, RS512, PS256, ES256 and EdDSA. curu-memory refuses each token that a key of another algorithm signs, for example ES384.
- curu-memory reads the keys from `<issuer>/.well-known/openid-configuration` and its `jwks_uri`. The document must name the same issuer, and `jwks_uri` must use `https` off loopback. The clock skew of the token check is 60 seconds.
- The browser makes its pages on the server, with no JavaScript. A [content security policy](#glossary) blocks each script. See [ADR-004](project-management/ADRs/ADR-004-server-rendered-read-only-browser.md).
- The limit of the match of meaning comes from a measurement of EmbeddingGemma 2. See [ADR-005](project-management/ADRs/ADR-005-similarity-limit-for-each-model.md). The limit for images has its own measurement. See [ADR-008](project-management/ADRs/ADR-008-image-similarity-limit.md). The limits for audio and for video also have their own measurements. See [ADR-011](project-management/ADRs/ADR-011-audio-in-concepts.md) and [ADR-013](project-management/ADRs/ADR-013-video-in-concepts.md). A search with media has its own limits and its own measurement, and one tool takes all media. See [ADR-010](project-management/ADRs/ADR-010-search-with-an-image.md), [ADR-012](project-management/ADRs/ADR-012-one-tool-for-a-media-question.md) and [ADR-014](project-management/ADRs/ADR-014-limits-of-a-video-question.md).
- curu-memory sends each image to the embedder in an item `{"image": "data:..."}`, each clip in an item `{"audio": "data:..."}` and each video in an item `{"video": "data:..."}`. The value is a `data:` URI: a text that holds the file bytes in base64, with their MIME type. See [ADR-007](project-management/ADRs/ADR-007-media-references-and-transport.md). The index keeps the bytes of each media file one time for each SHA-256. See [ADR-009](project-management/ADRs/ADR-009-media-bytes-in-the-index.md).
- The keys and the encryption of private knowledge follow [ADR-002](project-management/ADRs/ADR-002-server-held-envelope-keys.md). Each index run of a private space makes a new [data key](#glossary). The key in the key file wraps it with AES key wrap, and AES-256-GCM encrypts the index. The format is in [specs/019-encrypted-private-indexes/contracts/encrypted-index.md](specs/019-encrypted-private-indexes/contracts/encrypted-index.md). The audit line of a refused read names a hidden private space too, so that check decrypts it. See [ADR-021](project-management/ADRs/ADR-021-the-audit-check-of-a-hidden-private-space.md).
- The word match uses SQLite FTS5 (the full-text search of SQLite) with the `unicode61` tokenizer (the rule that splits a text into words). The tokenizer keeps `.`, `_` and `-` in a word. A word of three or more characters also matches as a prefix.
- Search fuses the two rankings with reciprocal rank fusion, with k = 60. The lower of the two values counts one tenth. So ten rivals that are a little worse in both rankings do not push out the best answer of one ranking. See [ADR-006](project-management/ADRs/ADR-006-damped-rank-fusion.md). The identifier breaks a tie, so the order is stable.
- The concept rules are the same as the rules of the curu index, so each curu concept file indexes here. The index command and `validate` use one implementation of them. The schema and the fixtures are in `src/curu_memory/okf_v0_2/`. See [specs/017-the-okf-v0-2-schema/contracts/okf.md](specs/017-the-okf-v0-2-schema/contracts/okf.md). The copy of the curu concepts in `tests/fixtures/eval_corpus/` is a frozen evaluation corpus, not a reference of the format. See [ADR-020](project-management/ADRs/ADR-020-a-frozen-evaluation-corpus.md).
- The tool contracts are in [specs/001-okf-index-mcp-search/contracts/](specs/001-okf-index-mcp-search/contracts/).
- curu-memory uses the official MCP Python SDK. It never loads model weights.
- curu uses curu-memory as a library, and the version range of a release follows a version policy. See [ADR-019](project-management/ADRs/ADR-019-curu-uses-curu-memory-as-a-library.md). `tests/contract/public_api.txt` holds the names of `curu_memory.__all__`. The contract is [specs/015-the-public-library-api/contracts/public-api.md](specs/015-the-public-library-api/contracts/public-api.md).
- The core install has `pydantic`, `httpx2`, `markdown-it-py`, `pyyaml` and `cryptography`. The `server` extra adds `mcp`, `starlette` and `uvicorn`.
- `curu_memory.TOOL_SCHEMA_VERSION` is the version of the MCP tool schemas and the error codes. The value is 1 for the tools of 0.3.0, and 2 since the error code `index_unreadable`. It goes up by 1 in each pull request that changes a tool, a field or an error code. A change of a description or a title does not change it. It is not the package version.
- A Python program that imports `curu_memory` can compare `TOOL_SCHEMA_VERSION` with the version that it expects. A different value means that a tool, a field or an error code is different. The MCP server does not send the value. `tests/contract/tool_schemas.json` holds the schemas of the current version.

## Develop

```bash
git clone https://github.com/darth-veitcher/curu-memory
cd curu-memory
uv sync
uv run pytest
```

`uv sync` installs the `server` extra, because the development group names it. The default tests need no network, no GPU and no model. They use a fake embedder and the frozen evaluation corpus in `tests/fixtures/eval_corpus/`, a copy of 183 curu concept files. The corpus is for the search measurements only. The shipped fixtures are the reference of the format. The tests with a real embedder have the `integration` marker. With no GPU embedder server, start the [development embedder](#glossary) first, as [tools/embedder_server/README.md](tools/embedder_server/README.md) says. Then run:

```bash
CURU_MEMORY_EMBEDDER_URL=http://127.0.0.1:30000 CURU_MEMORY_EMBEDDER_TIMEOUT=300 uv run pytest -m integration
```

The rauthy test of the integration tier also needs Docker and a free port 6480.

The source distribution on PyPI holds the tests too. Some tests read files of the repository that it leaves out, for example the workflows and the ADRs. These tests have the `repository` marker. In the source distribution, each of them skips and names the missing files. To leave them out, run `uv run pytest -m "not integration and not repository"`, because this option replaces the default `-m`.

To measure the search on the evaluation corpus, run the parity report. It compares the search of curu-memory with the search of curu. It needs no network and no model, because it reads the frozen vectors of EmbeddingGemma 2 in `tests/fixtures/eval_vectors/`:

```bash
uv run tools/parity.py report
```

The report gives recall@1, recall@5 and recall@10 of curu-memory. Recall@k is the part of the judged answers in the first k results, from 0 to 1. The report gives the mean of the 25 judged questions. A higher value is better: 1 means that each judged answer is in the first k results.

curu sends its results for the same questions in a file. Put it at `tests/fixtures/eval_vectors/curu_rankings.json`. The file contains the first 10 concepts that curu finds for each question. Without this file, the report gives only the line of curu-memory. When the file exists, the report also gives these items:

- The recall of curu.
- Each question where curu-memory has a lower recall@5 than curu.
- Each judged answer with a different 2-hop reach: the concepts within 2 links of it, in both directions.

The report exits with 1 when a lower question is not in `tests/fixtures/eval_vectors/accepted.toml`. Each question in that file names the decision record that accepts it. The report also exits with 1, and gives the cause, in these conditions:

- A frozen vector is missing.
- `curu_rankings.json` is for a different corpus.
- `curu_rankings.json` has a question that is not judged.

Without `curu_rankings.json`, it exits with 0.

When the corpus, the model, the task prompts or the units of a concept change, make the frozen vectors again. Start the [development embedder](tools/embedder_server/README.md). On a Mac, add `--device mps`, because the CPU is much slower. Then run:

```bash
CURU_MEMORY_EMBEDDER_URL=http://127.0.0.1:30000 uv run tools/parity.py vectors --device mps
```

Before a pull request, run the gates:

```bash
uv run ruff check --fix && uv run ruff format
uv run ty check
beacon doctor --strict
```

The CI runs the same gates on each pull request and on each push to `main`. A release starts when the owner pushes a tag `vX.Y.Z` on `main`. The release workflow checks the tag, then publishes the package to PyPI.

`AGENTS.md` gives the rules for contributors and agents.

## Glossary

- **Agent**: a program that uses a language model and calls tools, for example Claude Code. A registered agent acts for one person, and reads only the spaces of its grants.
- **Concept**: one OKF file about one topic, for example a model, a node or a finding.
- **Clip**: an audio file: an item of the `media` list of a concept, or the file that you search with.
- **Base64**: a text form of bytes that uses only letters, digits, `+`, `/` and `=`.
- **Browser**: the read-only web pages that `curu-memory browse` serves.
- **Video**: a video file: an item of the `media` list of a concept, or the file that you search with. The formats are MP4, MOV, WebM and MKV.
- **CI**: a workflow that runs checks on each pull request and each push. Examples are the GitHub workflow of this repository, and the workflow of the repository of a space.
- **Content security policy**: an HTTP header that tells the web browser which scripts, styles, images, audio and video it can load.
- **Cosine similarity**: a value from -1 to 1 that tells how near two embeddings are. A higher value is a nearer meaning.
- **Data URI**: a text that holds the bytes of a file in base64, with their MIME type, for example `data:image/png;base64,...`.
- **Data directory**: the directory of the index, the embedding cache, the access store `access.sqlite` and the audit log `audit.jsonl`. The default is `~/.local/share/curu-memory`, or `$XDG_DATA_HOME/curu-memory`. `CURU_MEMORY_DATA_DIR` changes it.
- **Data key**: the random key that encrypts one private index. Each index run of a private space makes a new one.
- **Development embedder**: the server in `tools/embedder_server/` that serves EmbeddingGemma 2 on the CPU or a GPU, for the integration tier and the frozen vectors.
- **Conformance fixtures**: the valid and invalid concept files that the package ships, with the expected faults in `expected.toml`.
- **Frozen vectors**: the stored embeddings of the evaluation corpus and the judged questions in `tests/fixtures/eval_vectors/`.
- **Judged question**: a question of `search_eval.toml` or `meaning_eval.toml` with its correct concepts.
- **Lower question**: a judged question where curu-memory has a lower recall@5 than curu.
- **Recall@k**: the part of the judged answers of a question in the first k results, from 0 to 1.
- **2-hop reach**: the concepts within 2 links of a concept, in both directions.
- **Evaluation corpus**: the frozen copy of 183 curu concept files in `tests/fixtures/eval_corpus/`, for the search measurements.
- **Extra**: a named set of optional packages of a Python package, for example `server` in `curu-memory[server]`.
- **Embedder**: a server that changes a text, an image, a clip or a video into an embedding.
- **Embedding**: a list of numbers that holds the meaning of a text, an image, a clip or a video. Items with a near meaning have near embeddings.
- **Fault**: one problem that makes the index command skip a concept file. `validate` gives each fault with its `path`, `line` and `message`.
- **GraphRAG**: retrieval that uses a search and the links between concepts to give an agent its context.
- **Index**: the searchable copy of the concept files that the index command makes.
- **JSON Schema**: a standard JSON document that gives the fields and the types of a JSON or YAML value.
- **Key rotation**: a run of `curu-memory key rotate`. It encrypts each private index again under a new key file and a new data key.
- **Previous key file**: the old key file during a key rotation. `CURU_MEMORY_KEK_FILE_PREVIOUS` names it. Each read also opens an index under its key, and no index run writes with it.
- **Key file**: the file that holds the key-encryption key. This key encrypts the data key of each private index. `curu-memory key new` makes the file, and `CURU_MEMORY_KEK_FILE` names it.
- **Link kind**: the type of a link between two concepts, for example `USES_COMPONENT`.
- **MCP**: the Model Context Protocol, which lets an agent find and call tools.
- **Minor version**: the second number of `X.Y.Z`. Below 1.0, a new minor version can remove or change a name or a tool field.
- **OKF**: Open Knowledge Format, the format of the concept files: YAML front matter and a Markdown body.
- **MIME type**: the name of a file format, for example `image/png`.
- **PyPI**: the Python Package Index, the public server of Python packages.
- **Reciprocal rank fusion**: a method that joins two rankings. Each ranking gives 1 / (60 + rank) to each concept. The best of the two values counts in full, and the other counts one tenth.
- **Task prompt**: a fixed text that curu-memory puts around a question or a section before it sends the text to the embedder.
- **Stdio**: the standard input and output of a process. A local MCP client talks to the server through them.
- **Streamable HTTP**: the MCP transport over HTTP, for remote agents.
- **HSTS**: HTTP Strict Transport Security, a response header. The web browser then uses only HTTPS for that host name, for one year in the stack.
- **TLS proxy**: a server, for example Caddy, that accepts HTTPS from clients and sends plain HTTP to curu-memory on a private network.
- **Wiki-link**: a link of the form `[[name]]`. OKF does not accept it outside code.
- **Web browser**: the program that shows web pages, for example Firefox.
- **SHA-256**: a hash of 64 hexadecimal characters that the bytes of a file give. Different bytes give a different SHA-256.
- **Unit**: one part of a concept that has its own embedding: the summary, the overview, a `##` section, an image, a clip or a video.
- **Version range**: the lowest version and the first version to exclude, for example `>=0.9,<0.10`.
- **Space**: a part of the knowledge with its own index: `global`, `team/<slug>` or `personal/<handle>`. Each request reads only the spaces that its caller can read.
- **Team slug**: the short name of a team in a space name, for example `look` in `team/look`.
- **Instance owner**: a person with `admin` on `global` and on each team space. The first registered person is an instance owner.
- **Principal**: the person or the agent of a request, or `anonymous` with no principal. It comes from `CURU_MEMORY_PRINCIPAL`, `serve --grant`, the OIDC token on HTTP or `open_knowledge`.
- **Private space**: a team space or a personal space. Its index is encrypted on disk, and its concept files are an encrypted space.
- **Encrypted space**: a directory with `space.json`, `.gitattributes` and `objects/`. Each object holds one concept file or media file, under the space key.
- **Space key**: the AES-256 key of one encrypted space.
- **Space record**: the file of one encrypted space in `spaces/` of the data directory, for example `spaces/team--look.json`. It holds the space key, wrapped by the key of the key file.
- **Wrap**: to encrypt a key with another key.
- **Token cache**: the file of the tokens of `curu-memory login`, one entry for each server, with mode 600.
- **Device login**: a login of OAuth for a program with no web browser. The program shows a code, and the person enters it in a web browser on any device.
- **Textconv**: a program that git runs to change a file to text before a diff.
- **Merge driver**: a program that git runs to merge the versions of a file, which `merge=curu` in `.gitattributes` selects.
- **Diff driver**: a name in `.gitattributes`, for example `diff=curu`, that selects the textconv of a file.
- **Edit through the server**: the curu-memory server decrypts and encrypts the objects of a space after a role check. The space key never leaves the server.
- **Checkout**: the private directory of the plaintext files of one encrypted space, with `.curu-checkout.json`.
- **Allowed signers**: the file, in the format of git, of the public keys that can sign a commit of a private space.
- **Signed commit**: a git commit with a signature that git verifies.
- **Pre-push hook**: a script in `.git/hooks/` that git runs before each push. A result other than 0 stops the push.
- **Ref**: a name of a commit, for example a branch or a tag.
- **Mirror clone**: a clone of each ref, with no work tree.
- **git filter-repo**: a tool that rewrites the git history.
- **Conflict markers**: the lines that git writes in a file after a merge conflict, for example `<<<<<<< ours`. Each starts with 7 or more `<`, `=` or `>` characters. The attribute `conflict-marker-size` in `.gitattributes` makes them longer.
- **Operator**: the person who runs `curu-memory access` and `curu-memory index` on the computer of the server.
- **Access store**: `access.sqlite`, with the persons, the teams, the members, the agents and the grants.
- **Audit log**: `audit.jsonl`, with one line for each change of access, each refused read and each refused token.
- **OIDC provider**: a service that logs persons in and issues tokens, for example Authentik or rauthy. OIDC is OpenID Connect.
- **Issuer**: the URL of an OIDC provider, as in the `iss` claim of its tokens.
- **Subject**: the identifier of a person or a service account at an issuer, as in the `sub` claim of a token.
- **Audience**: the `aud` claim of a token: the service or the OAuth client that the token is for.
- **Access token**: a signed JWT (JSON Web Token) that a client sends in the `Authorization` header. A claim is one field of a token.
- **ID token**: a token that tells a client who logged in. It is not for a service such as curu-memory.
- **OAuth client**: a program that the provider knows by its client identifier. An MCP client uses an OAuth client.
- **Signing key**: the key that signs the tokens of a provider. curu-memory checks each signature with the public part.
- **HMAC**: signatures with a shared secret, for example HS256. A service cannot check them without the secret, so curu-memory refuses them.
- **Canonical URI**: the one address of the server that tokens name, for example `https://memory.example.com/mcp`.
- **OAuth grant**: the method that a client uses to get a token, for example the client credentials grant.
- **Grant**: one role of one agent in one space, for 1 to 366 days. A grant is live from its start until it expires or the operator revokes it.
- **Service account**: an Authentik user for a program, not for a person.
- **App password**: a password that Authentik makes for one user, for a program.
- **Device endpoint**: the URL of the issuer that gives the codes of the device login. The discovery document of the issuer names it.
- **Token endpoint**: the URL of the issuer that gives tokens. The discovery document of the issuer names it.
- **Basic header**: `Authorization: Basic` followed by the base64 of `user name:password`.
- **Authorization code grant**: the person logs in through the web browser, and the client gets a token for that person.
- **PKCE**: a check that only the client that started a login can get its token.
- **Client credentials grant**: a program gets a token with its own secret, with no person.
- **Blueprint**: a YAML file that Authentik reads to make its objects, for example a provider and an application.
- **Redirect URI**: the address where the provider sends the person back after the login.
- **Role**: `read`, `write` or `admin` of one person or one agent in one space.
- **Handle**: the short name of a person in a space name, for example `me` in `personal/me`.
