---
title: "Giving Claude a Persistent Memory"
date: "2026-05-15"
publish_date: "2026-08-03"
excerpt: "Claude Code's context gets compacted — important context just disappears. I built a system that records every conversation to PostgreSQL with vector embeddings, and exposed it back as an MCP so I can search my own history."
tags: ["claude-code", "mcp", "pgvector", "ollama", "bedrock", "postgresql", "python"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-05-15-giving-claude-a-persistent-memory"
approved: true
---

# Giving Claude a Persistent Memory

![A robot filing glowing memory orbs into jars along an endless candlelit archive corridor](./images/giving-claude-a-persistent-memory-header.png)

There is a character in a J.M. Barrie play — a butler named Crichton, entirely unremarkable in the drawing room — who becomes, almost quietly, the most indispensable person on the island after the shipwreck. He was always the most competent one. Normal social order just made it impossible to notice.

![An impeccably composed butler standing in a vast candlelit Edwardian library, holding a single softly glowing index card](images/giving-claude-a-persistent-memory-1.png)

I think about that when context compaction kicks in mid-session and I watch several hours of careful problem-solving disappear into a summary paragraph. The work is gone. The reasoning is gone. What remains is a précis written by a version of the assistant that no longer has access to what actually happened.

So I built a memory system. This is how it works.

## The Problem With Compaction

Claude Code has a finite context window. When a session runs long, the harness compacts earlier messages into a summary and continues. The summary is accurate enough for most purposes. But it loses the texture: the specific error message that unlocked a debugging session, the particular wording of a constraint that shaped an architectural decision, the thing we tried first that didn't work and why.

There is no API to retrieve what was compacted from within the session. The only countermeasure is to record everything *before* it disappears.

## The Stop Hook

Claude Code exposes a hooks system. You can register commands that fire on various lifecycle events — one of which is `Stop`, which fires after every response. (Hooks are configured in `~/.claude/settings.json`, available in Claude Code via the Claude desktop tooling.) The hook receives a JSON payload on stdin containing, amongst other things, the path to the current session transcript.

```json
{
  "session_id": "3f047a9d-bdf4-430f-b27f-ec4d68998899",
  "transcript_path": "~/.claude/projects/.../transcript.jsonl",
  "cwd": "/Users/nakomis/repos/nakomis/claude-chats",
  "git_branch": "main"
}
```

The transcript is a newline-delimited JSON file. Every message is a line — each with a UUID, role, and content field. The hook reads it, finds messages not yet stored (deduped by `message_uuid`), embeds them, and persists them to PostgreSQL. It always exits 0 — a non-zero exit would block the stop flow. Exceptions are caught, logged to stderr, and swallowed so Claude is never impacted by hook failures.

The hook fires on `UserPromptSubmit` as well as `Stop`, which means the database stays roughly current even mid-session. Incrementality is cheap: the hook fetches already-stored message UUIDs for the session and skips them.

A separate bulk-import script handles historical sessions and backfilling — the incremental hook only covers new messages from the point of installation onwards.

Registration goes in `~/.claude/settings.json`:

```json
{
  "hooks": {
    "Stop": [{"type": "command", "command": "uv run --project \"/path/to/hook\" record-conversation", "timeout": 120}],
    "UserPromptSubmit": [{"type": "command", "command": "uv run --project \"/path/to/hook\" record-conversation", "timeout": 120}]
  }
}
```

## The Schema

PostgreSQL with the pgvector extension. Two tables:

```sql
-- Requires:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE conversations (
    id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    session_id  TEXT        NOT NULL UNIQUE,
    project_path TEXT       NOT NULL DEFAULT '',
    git_branch  TEXT        NOT NULL DEFAULT '',
    name        TEXT,
    started_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE messages (
    id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    conversation_id UUID        NOT NULL REFERENCES conversations (id) ON DELETE CASCADE,
    message_uuid    TEXT        NOT NULL UNIQUE,
    role            TEXT        NOT NULL CHECK (role IN ('user', 'assistant')),
    content         TEXT        NOT NULL,
    content_tsv     TSVECTOR    GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
    embedding       vector(1024),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    sequence_num    INTEGER     NOT NULL,
    UNIQUE (conversation_id, sequence_num)
);
```

The `embedding` column is a 1024-dimensional vector — this matches the Ollama and Bedrock Titan v2 models used in this setup. If you use a different model (nomic-embed-text outputs 768 dimensions; Cohere embed-v4 outputs 1536), adjust the column definition and index accordingly. Changing dimensions after the fact requires a migration and full re-embedding. Nearest-neighbour search uses an ivfflat index with cosine distance:

```sql
CREATE INDEX messages_embedding_idx
    ON messages USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);

CREATE INDEX messages_conversation_seq_idx
    ON messages (conversation_id, sequence_num);
```

A note on the `lists` parameter: 100 is reasonable for a corpus in the tens of thousands. Tune it as the corpus grows, and run `ANALYZE messages` after bulk inserts to keep the index statistics current.

There is also a full-text search index backed by the generated `tsvector` column above:

```sql
CREATE INDEX messages_content_tsv_idx
    ON messages USING GIN (content_tsv);
```

The two search paths combine via Reciprocal Rank Fusion in the hybrid mode, which is what `search_memory` uses by default.

The database runs in a Docker container on port 5433. Nothing exotic — `pgvector/pgvector:pg17`, started with a docker-compose file, init.sql mounted at the standard entrypoint path. The whole thing runs locally on my laptop, except when I'm at a client site, in which case the vectors come from Bedrock and the database is wherever I decide to put it. If the database is remote, use TLS and strict network ACLs — and keep in mind that a purge mechanism (delete by session_id) is useful if you ever need to redact a conversation. Embeddings can leak information about their source text even without the original text being stored in plain form.

## The Embedding Provider Problem

This is where the interesting decisions live.

Embeddings are expensive to change. They are baked into the database at write time. If you switch provider, the new query vectors come from a different embedding space and will not match the stored ones — cosine similarity will return nonsense, not because the content is irrelevant but because you are comparing measurements made in entirely different coordinate systems. Every stored message would need to be re-embedded from scratch.

So the provider choice is consequential.

The code supports three:

```python
PROVIDER   = os.environ.get("CLAUDE_CHATS_PROVIDER",   "ollama").lower()
_DEFAULT_MODELS: dict[str, str] = {
    "ollama":  "mxbai-embed-large",
    "bedrock": "amazon.titan-embed-text-v2:0",
    "openai":  "text-embedding-3-small",
}
```

**Ollama / mxbai-embed-large** is the local option. The model is 670 MB, runs on-device, no API calls, no usage costs. On a modern laptop with AVX2, a single embedding takes 20–50 ms. Entirely private: the text never leaves the machine. This is what I use at home.

**OpenAI text-embedding-3-small** is the work option. By default this model produces 1536-dimensional vectors, but the OpenAI API supports a `dimensions` parameter; the embed.py in this project requests `dimensions=1024` to match the schema. Faster, good quality, and the text goes to OpenAI — which is where the corporate privacy agreement matters. If your organisation has a data processing agreement with OpenAI, this is fine. If it doesn't, it isn't.

**AWS Bedrock (Titan v2)** is the third path. Your AWS credentials, your AWS account, your data. Useful as a middle ground: cloud-hosted compute but within an environment you control. Also useful when the machine running the hook is too slow for local inference (more on this in a moment).

## The Bakeoff

Before committing to a model, I ran a proper evaluation. Seven models — four Ollama, one Bedrock Titan, two Cohere on Bedrock — over the full message corpus (about 29,000 messages). The metric was recall@10 on a hand-labelled query set of around 50 queries: conversations I remembered having, phrased as questions, with the relevant session IDs marked as ground truth. Models with different output dimensions were evaluated against separate per-model corpora, each table created fresh with the matching `vector(N)` column — no stuffing 1536-d vectors into a 1024 column. Messages were embedded as raw content, one message per vector. Embedding ran on an M-series MacBook with the Ollama models, and via API for the Bedrock models.

The results, ranked by recall@10:

| Model | Recall@10 | Notes |
|---|---|---|
| amazon.titan-embed-text-v2:0 | 0.542 | Best retrieval quality |
| cohere.embed-v4:0 | 0.509 | 1536-dim, throttle-prone |
| bge-large | 0.500 | 670 MB, 1024-dim |
| cohere.embed-english-v3 | 0.498 | Lower coverage (skips) |
| bge-m3 | 0.491 | Same size as bge-large |
| mxbai-embed-large | 0.486 | Default Ollama model |
| nomic-embed-text:v1.5 | 0.413 | 768-dim |

Titan V2 came top by a meaningful margin. The query set was built partly using mxbai candidates (which biases against Titan), so the gap is real, not an artefact. Titan is also the cheapest of the cloud options at roughly $0.00002 per 1000 tokens — about 15 pence per month at typical session volume (May 2026 pricing; see the repo README for current assumptions).

The practical lesson: for a personal project running on a laptop, the quality difference between Titan and the Ollama models is real but not enormous. For a server environment where local inference is impractical — say, an old HP MicroServer with a 2-core AMD Turion and no AVX2 — Titan wins on both quality and latency. Six seconds per embedding on that hardware, 40 messages per session, 120-second hook timeout: the maths does not work.

## The Local Model Privacy Case

The security angle on running Ollama at work deserves its own treatment.

The instinct — and I have felt it — is to just install Ollama in a Docker container on your work laptop and not mention it. It runs locally. Nobody needs to know. You get local inference, total privacy, and you avoid the conversation.

This is the same energy as the person who installed some unapproved open-source tool on the accounts server because it made running payroll so much easier. They were not wrong about the productivity argument. They were wrong about the process.

The privacy case for local models is genuinely strong. When you send conversation text to an API — even one with a corporate data processing agreement — the text leaves your machine, crosses a network, and touches a third-party system. With Ollama, none of that happens. The model weights are on your disk, the inference is on your CPU, and the resulting vector is the only thing that goes into the database. That argument is strong enough to just *make*. Raise it with your security team. Ask for an approved deployment. The alternative — a shadow AI stack that the security team doesn't know about — is genuinely bad and usually ends with an incident report rather than a policy update.

If the answer is no, OpenAI or Bedrock with a signed DPA is meaningfully better than nothing. The privacy is imperfect but the provenance is documented and the risk is understood.

## The MCP

With data in the database, the retrieval side is a FastMCP server with three tools:

```python
@mcp.tool()
def search_memory(query: str, limit: int = 5,
                  project_path: str | None = None,
                  mode: str = "hybrid") -> dict:
    ...

@mcp.tool()
def get_conversation(session_id: str,
                     start_seq: int | None = None,
                     end_seq: int | None = None) -> dict:
    ...

@mcp.tool()
def list_recent_sessions(limit: int = 10,
                         project_path: str | None = None) -> list[dict]:
    ...
```

`search_memory` supports three modes. Hybrid (default) runs both vector similarity and full-text search, then combines scores with Reciprocal Rank Fusion (k=60 for both legs; see `search.py` in the repo to adjust). Semantic-only uses the embedding for concept-level retrieval — phrasing the query as a sentence that *would appear* in the target conversation ("HyDE", Hypothetical Document Embeddings) improves results noticeably. Full-text is plain PostgreSQL `tsvector` — reliable for exact phrases, error messages, and anything where you remember the specific words.

When hybrid misses, full-text with a verbatim distinctive phrase is usually the fallback. They are not the same tool pointed at the same problem — they cover different failure modes: semantic framing differences versus exact-phrase recall.

`get_conversation` takes a session ID and returns the full transcript, with optional sequence number windowing for long conversations. `list_recent_sessions` does what it says.

The whole thing is registered via `claude mcp add conversation-memory` with the required environment variables — at minimum `DATABASE_URL`, `CLAUDE_CHATS_PROVIDER`, and either `OLLAMA_MODEL` or `BEDROCK_REGION` depending on your provider. The hook and MCP use the same `embed.py` — same provider, same model, same dimensionality.

## The Provider Mismatch Incident

Earlier today, semantic search was returning near-zero scores for everything. Full-text still worked; the data was clearly there. But cosine similarity was broken — the search embedding and the stored embeddings were not from the same space.

The diagnosis: `meta-mcp/config.toml` had `CLAUDE_CHATS_PROVIDER = "ollama"` for the conversation-memory MCP. The stored vectors were Bedrock Titan, embedded by the hook which had `CLAUDE_CHATS_PROVIDER = "bedrock"`. The MCP was generating query embeddings with `mxbai-embed-large` and comparing them against Titan vectors. The two embedding spaces are completely different mathematical objects — a cosine similarity of effectively 0.0 is the correct answer when comparing them, just not the useful one.

Fix: update the MCP config to `CLAUDE_CHATS_PROVIDER = "bedrock"`. Semantic search immediately restored. No re-embedding required because the stored data was fine all along.

The provider must match — between the hook that writes and the MCP that reads — or you are measuring the wrong thing.

![Two near-identical ornate vault doors side by side, a single glowing key suspended between them not quite fitting either](images/giving-claude-a-persistent-memory-2.png)

## What I Actually Learned

A few things that did not appear obvious until I built it:

**Hook timeout is the practical limit for local inference on low-core hardware.** On a 2-core server with no AVX2, you will hit the 120-second ceiling before the session is fully embedded. Bedrock with parallel workers sidesteps this entirely.

**HyDE works.** Phrasing the search query as a sentence that would *appear* in the target conversation, rather than keywords, consistently produces better semantic matches. For example, querying "We ended up deciding to use a connection pool after hitting timeout errors" retrieves the relevant session more reliably than "connection pool timeout". The first is closer to how the actual dialogue is phrased.

**Provider mismatch is silent and plausible-looking.** The scores cluster around 0.015 or so, which looks like "no results" rather than "completely broken". You might conclude that the relevant conversation was never recorded. It was; the query was just in a different language.

The repo is at [github.com/nakomis/claude-chats](https://github.com/nakomis/claude-chats). `install.sh` does the full setup. Set `CLAUDE_CHATS_PROVIDER` before running.

---

*Martin Harris writes about connecting real hardware to abstract cloud infrastructure. If you've ever soldered something to an API Gateway, you're in the right place.*

{{donate}}