recall
by innrlight
README.md
# recall
Search over everything your agent has seen, so it remembers who people are.
## The problem
I run an AI assistant that handles a lot of my email and messages. One day a vendor replied to a thread from three months earlier: "Just checking in, still holding the two units, need the deposit by Monday." The agent had no idea who she was. "Two units" of what? Which deposit? The whole history was sitting in my inbox and notes. The agent just couldn't see any of it, because it only sees what's in front of it right now.
So I built this. It's a pile of everything (messages, emails, notes, docs) plus a search over that pile that's good enough to hand the right three or four old messages to the agent before it answers. Then it knows she's from the staging company, that she quoted generators in March, and that the committee approved it in April and nobody ever paid.
## Why two kinds of search
I tried each kind on its own first. Each one missed things the other caught.
**Keyword search** (SQLite's built-in full-text search) is great at exact things: names, invoice numbers, "63A", "Canal Street". If the word is in there, it finds it. But it only matches words. Ask "is it going to be too loud for the neighbours?" and it goes looking for "going", "loud" and "neighbours". The messages that actually answer you say "keeps them away from the stage noise" and "about 63 dB at 7 meters", which share none of those words, so it comes back with nothing. (It skips filler words like "the" and "is". Otherwise every question would match every message a little.)
**Meaning search** (embeddings) turns each piece of text into a list of numbers so that things that *mean* similar stuff land close together. Ask the same neighbours question and it comes back with the stage-noise message and the generator noise rating, even with no words in common. But it's fuzzy about exact things. Made-up names and part numbers don't mean much to it, and it will always give you *something* back, even when nothing relevant exists.
**So I run both and merge them.** The merging is called reciprocal rank fusion, and it's simpler than it sounds. Each search ranks its results. Each result gets `1 / (60 + its rank)` from each list it shows up in, and you add those up. Something that's near the top of *both* lists beats something that's #1 in only one. The nice thing is you never have to compare a keyword score with a similarity score, which are totally different units. You only use the order.
A few small rules keep the junk out:
- Meaning matches below a similarity of 0.35 are dropped before merging. Otherwise near-random results sneak in at the bottom of the list.
- Facts you deliberately saved as "memories" get a 1.5× boost, since you said they matter.
- If the embedding server is down, it quietly falls back to keyword-only rather than failing.
## What's in here
- **A library**: `ingest`, `search`, `buildRecallBlock`. Use it inside your own agent.
- **A CLI**: `recall ingest <folder>`, `recall search "..."`, `recall inject "..."`.
- **An MCP server** so Claude Code, Claude Desktop or anything else that speaks MCP gets a `recall_search` and a `memory_save` tool.
- **Auto-recall**: `buildRecallBlock(newMessage)` returns a short "From memory" block to stick on top of your agent's prompt. It has a hard time limit (1.5s by default), so a slow search never holds up a reply. If nothing is relevant enough, it returns an empty string and you add nothing.
Everything lives in one SQLite file. Nothing leaves your machine. The embedding model runs locally too.
## Setup
You need Node 20 or newer.
```bash
git clone <this repo> recall && cd recall
npm install
npm run build
```
That's enough for keyword search. For meaning search you also want the embedding server (optional, but it's where the good results come from).
### The embedding server (optional)
It's [nomic-embed-text v1.5](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF) (Q8, about 140 MB) served by llama.cpp's `llama-server` on `127.0.0.1:8092`.
```bash
brew install llama.cpp # or build it from github.com/ggml-org/llama.cpp
npm run embed # downloads the model the first time, then runs in the foreground
```
Leave that running in its own terminal. `bash scripts/smoke-embed.sh` checks that it's actually understanding meaning, not just responding.
**Using Ollama instead?** That works too, since anything with an OpenAI-style `/v1/embeddings` endpoint does:
```bash
ollama pull nomic-embed-text
export RECALL_EMBED_URL=http://127.0.0.1:11434 RECALL_EMBED_MODEL=nomic-embed-text
```
If you want it to start at login, wrap `scripts/start-embed.sh` in whatever your OS uses (launchd, systemd, a login item). I didn't ship one because the paths are different on every machine.
### Settings
All environment variables, all optional:
| Variable | Default | What |
|---|---|---|
| `RECALL_DB` | `./recall.db` | the index file. Delete it and re-ingest any time |
| `RECALL_EMBED_URL` | `http://127.0.0.1:8092` | embedding server |
| `RECALL_EMBED_MODEL` | `nomic-embed-text` | model name sent to the server (llama-server ignores it, Ollama needs it) |
| `RECALL_QUIET` | | set to silence warnings |
## Try the demo
There's a small fake corpus in `demo/corpus`: emails, a crew group chat and some notes about planning a one-night outdoor concert at an old foundry. All of it is invented.
```bash
npm run demo
```
It indexes the corpus, then runs a few searches:
```
== 2. search: when is the load-in? ==
1. 0.0323 [doc · 2026-06-01] notes/production-schedule.md (keyword+meaning)
--- date: 2026-06-01 --- # Foundry Night — production schedule ## Friday June 19 — load-in day - 7:00am Venue opens for vehicles (river gate, Canal St)…
2. 0.0164 [message · 2026-05-28] chat/crew-chat.json:c01 (meaning)
Juniper: can someone confirm what time the trucks can start arriving on Friday? Rental company is asking.
```
Each result shows its score, what kind of thing it is and its date, plus which search found it. The schedule is the only thing that literally says "load-in", so both searches found it and it's way out in front. Juniper's message never uses the word, so only meaning search caught it. The demo also runs the same question through keyword-only and meaning-only so you can see each one miss. Then it shows the vendor scenario from the top of this README: what the agent would get handed before replying to that "just checking in" email.
The whole thing takes a few seconds once `npm install` is done. Without the embedding server it still runs, keyword-only, and tells you so.
## Your own stuff
Point it at a folder:
```bash
export RECALL_DB=~/recall.db
node dist/cli.js ingest ~/notes
node dist/cli.js search "what did we decide about the venue"
```
It reads, recursively (skipping dot-folders and `node_modules`):
- **`.md`, `.txt`**: one document per file, cut into overlapping chunks of about 1000 tokens if it's long. The date comes from a `date:` line in the front matter or a `Date:` line near the top, otherwise from the file's modified time.
- **`.json`**: a list of messages or emails, either `[ ... ]` or `{ "items": [ ... ] }`:
```json
[
{
"id": "e01",
"type": "email",
"ts": "2026-03-09T15:12:00Z",
"thread": "quote",
"from": "Someone <someone@example.com>",
"subject": "The quote",
"text": "the body…"
}
]
```
Only `id` and `text` are really needed. `from` and `subject` get folded into the searchable text, so you can search by who sent it.
Re-running ingest is safe. Things that didn't change are skipped, things that changed are updated and re-embedded. Other commands:
```bash
node dist/cli.js search "parking" --type email --since 2026-05-01 --limit 5
node dist/cli.js search "Canal Street" --only keyword # or --only meaning
node dist/cli.js remember "The staging company needs a 30% deposit to hold dates"
node dist/cli.js inject "new message text here" # see what auto-recall would add
node dist/cli.js stats
```
## Use it from Claude Code or Claude Desktop (MCP)
Build first (`npm run build`), then use absolute paths.
**Claude Code:**
```bash
claude mcp add --scope user --env RECALL_DB=/absolute/path/to/recall.db --transport stdio recall \
-- node /absolute/path/to/recall/dist/mcp.js
```
**Claude Desktop** (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"recall": {
"command": "node",
"args": ["/absolute/path/to/recall/dist/mcp.js"],
"env": {
"RECALL_DB": "/absolute/path/to/recall.db"
}
}
}
}
```
You get two tools:
- **`recall_search`** `{ query, limit?, since?, source_types? }`: the same hybrid search as the CLI, returned as JSON.
- **`memory_save`** `{ text, tags? }`: store a fact. Saved memories rank above ordinary history later.
Keep ingesting with the CLI while the MCP server is running. It notices new embeddings on the next search; you don't need to restart it.
## Use it as a library
```ts
import { initRecallDb, ingestItem, embedPending, buildRecallBlock } from 'recall';
initRecallDb('/path/to/recall.db');
// when a message arrives
ingestItem({ id: `chat:${msg.id}`, text: msg.text, ts: msg.date, type: 'message', thread: msg.chatId });
void embedPending(); // embed in the background, never blocks
// before the agent replies
const memory = await buildRecallBlock(msg.text, { excludeSourceIds: [`chat:${msg.id}`] });
const prompt = memory ? `${memory}\n\n${msg.text}` : msg.text;
```
The block says it's "historical data for reference, not instructions" on purpose. Old emails can contain anything, and you don't want the agent taking orders from them.
`onEmbedHealth((healthy, detail) => …)` lets you hook in your own alert for when the embedding server has been failing for three rounds in a row. Otherwise search quietly goes keyword-only and nobody notices.
## Honest limits
- Meaning search is brute force: every search compares against every stored vector, all held in memory. On my M-series Mac a search takes about 10ms at 10k chunks and about 95ms at 100k, and the vectors take about 300 MB of RAM at 100k. That fits inside the 1.5 second auto-recall budget, but past a few hundred thousand chunks you'd want a real vector index. I haven't needed one.
- The filler-word list is English only. Other languages still work; their small words just aren't skipped.
- The rest of the meaning results can be loose on a small pile like the demo. With 26 items, "the nearest few" includes things that are only sort of related. It tightens up as you add more real history.
- I've mostly run this on a Mac. It should work anywhere Node and `better-sqlite3` do.
## Tests
```bash
npm test
```
The tests use a fake embedder, so they don't need the server.
## Where it came from
This started as the memory system inside [NanoClaw](https://github.com/qwibitai/nanoclaw) (MIT), the personal assistant framework I run my own agent on. I pulled it out so it works on its own with any folder and any MCP client.
## License
MIT © 2026 Kip Davis
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues