blueocean-vector
# BlueOcean Vector
*Shared, persistent memory for coding agents.*





The kind of memory that survives switching from Claude Code to Codex to Cursor mid-project — and survives you running out of tokens in one of them.
If you've ever burned through a context window, opened a different tool, and then spent ten minutes re-explaining what you were doing, this is for that problem. BlueOcean Vector runs one small server on your machine. Any MCP-capable agent can read from it and write to it. Whichever tool you open next just asks "what do we know about this project?" and picks up where the last one left off.
> [!TIP]
> Store a decision in Claude Code → open Codex tomorrow → it already knows *why* you chose Postgres over DynamoDB, not just that you did.
---
## Contents
- [Why it exists](#why-it-exists)
- [How this compares](#how-this-compares)
- [How it fits together](#how-it-fits-together)
- [Getting started](#getting-started)
- [Teaching agents to actually use it](#teaching-agents-to-actually-use-it)
- [Alternative: stdio](#alternative-stdio-per-agent-local-process)
- [The tools an agent gets](#the-tools-an-agent-gets)
- [Configuration](#configuration)
- [Admin CLI](#admin-cli)
- [Running the tests](#running-the-tests)
- [Usage telemetry](#usage-telemetry)
- [Security](#security)
- [Deploying beyond localhost](#deploying-beyond-localhost)
- [Gotchas](#a-few-gotchas-worth-knowing-before-you-touch-this)
- [License](#license)
---
## Why it exists
Every agent session starts from zero. You explain the project, the constraints, the "we tried that already, it didn't work" — and then the session ends and it's gone. Multiply that by every tool you use, and you're spending real tokens just re-establishing context that already existed an hour ago.
BlueOcean Vector is a small, boring fix: one shared memory store, one URL, and a common set of tools (`memory_store`, `memory_search`, `memory_summarize_session`, and a few more) that any MCP client can call. It doesn't try to be clever about what to remember — it just gives agents a place to put things down and pick them back up, scoped per project so a search in one codebase doesn't surface noise from another.
---
## How this compares
There's already a well-populated field of "memory for AI agents" projects. Worth being upfront about where this one actually sits, instead of pretending the space is empty.
| Project | How an agent talks to it | Who decides what's remembered | Semantic vector search |
|---|---|---|---|
| [mem0](https://github.com/mem0ai/mem0) | SDK / hosted API | Automatic — an LLM extracts facts on ingest | Yes, wrapped behind the extraction layer |
| [Zep / Graphiti](https://github.com/getzep/graphiti) | SDK, or an official MCP server | Automatic — entities/relationships extracted into a knowledge graph | Secondary to graph traversal |
| [Letta](https://github.com/letta-ai/letta) (formerly MemGPT) | Full stateful-agent platform, server + SDK | Semi-automatic — the agent's own LLM pages memory in/out | Yes, for archival memory |
| [Memorix](https://github.com/AVIDS2/memorix) | MCP-native, no server to run | Explicit — the calling agent writes | **Fallback only** (~1.8s), keyword search is primary |
| threadctx-mcp | MCP-native | Explicit + optional passive git capture | **Paid cloud tier** — local mode is keyword-only |
| **BlueOcean Vector** | MCP-native, one shared server | Explicit — the calling agent writes | **Primary and always-on** |
Two honest takeaways:
- **The "MCP-native, works with any client" niche isn't empty** — Memorix already lives there, with more built-in tools. What's different here is that vector search is the primary retrieval path rather than a fallback or something gated behind a paid tier, the default embedding model is genuinely multilingual (Thai+English tested), and it's built to run as one shared, persistent server rather than a zero-install per-agent tool — bearer-token auth, a documented path to ECS, Kubernetes-ready health probes, and real fixes for the concurrency problems a *shared* server actually hits.
- **No automatic extraction or consolidation** — unlike mem0, Graphiti, Letta, cognee, or LangMem, nothing here reads your conversation and decides what's worth remembering. That's a deliberate simplicity trade-off, not a missing feature: an agent has to explicitly call `memory_store`. If you want a system that reasons about what to keep on your behalf, one of the projects above will do that better than this will.
### Memory shouldn't try to hold a million lines
Some projects are a million lines of code. And no memory system — BlueOcean Vector included — should try to store all of it. Storing code is a code-search tool's job, not a memory server's.
BlueOcean's job is narrower and more useful: **remember what mattered, and where to find it.** It holds the decisions, the architecture, the "we tried that, it didn't work" — the condensed knowledge an agent would otherwise have to rediscover from a million lines — plus just enough context to point the agent back at the real code when it needs details.
The result is that memory grows with *what's actually worth remembering*, not with the size of the codebase. A million-line project can have a few thousand memory entries. That keeps retrieval cheap no matter how big the project gets.
### The token math
Reading memory back is where that distinction pays off. The cheapest alternative — a skill or plugin that dumps project notes into a `.remember` file an agent reads back — works great until the file outgrows the context window, then it silently stops being useful.
BlueOcean caps every search at a token budget (default **2000 tokens**, configurable via `BLUEOCEAN_MAX_TOKENS`). Semantic search pulls only the relevant entries, then splits the budget: ~60% for condensed summaries, ~40% for the full content of the top hits. Entries beyond the budget are truncated, never dumped wholesale.
| Approach | Cost per retrieval | Grows with memory size? |
|---|---|---|
| **BlueOcean Vector** (`memory_search`) | **capped** at the token budget (default 2000) | No — bounded, regardless of collection size |
| `.remember` file (read whole file) | equal to the whole file size | Yes — linear; eventually exceeds the context window |
| `.remember` file (agent reads one section) | equal to that section | Partial — but the agent must guess which section without relevance ranking |
A real search against a small demo project returned **121 tokens** for one summary + one full entry — a few percent of the 2000-token budget, and that budget never grows as the project accumulates memory. With a plain file, the same read costs the entire file every time, so a 5k-entry project (hundreds of thousands of tokens) is unreadable in one shot.
---
## How it fits together
```
┌────────────┐ ┌──────┐ ┌────────┐ ┌───────────────┐ ┌──────┐
│Claude Code │ │Cursor│ │ Codex │ │Gemini/Antigrav│ │ Kiro │ ...any MCP-http tool
└─────┬──────┘ └──┬───┘ └───┬────┘ └───────┬───────┘ └──┬───┘
└───────────┴─────────┴──────────────┴────────────┘
│ http://localhost:8765/mcp
┌───────────────────────────┐
│ blueocean-mcp │ Python MCP server
│ (one shared, persistent │ (docker compose)
│ server, not per-agent) │
└─────────────┬─────────────┘
│
┌───────────────────────────┐
│ Qdrant (vector DB) │ Docker locally → ECS Fargate in the cloud
└───────────────────────────┘
```
A few design choices worth knowing about:
| Choice | Why |
|---|---|
| **One server, reached by URL** | Every mainstream MCP client (and plenty of niche ones) has its own "add a remote server" command. Point them all at the same URL and none of them need bespoke config-file editing from us. |
| **Qdrant underneath, one collection per project** | Memory for `project-a` never leaks into a search for `project-b`. |
| **Multilingual by default** | Embedding model is `intfloat/multilingual-e5-large`, so project notes mixing Thai and English (or any other pair it covers) still search across both without extra setup. |
| **Token-budgeted reads** | `memory_search` returns short summaries first and only expands the top matches into full content until it hits a budget you set — agents stay cheap to run even against a memory store that's grown large. |
`stdio` transport also works if you'd rather each tool spawn its own local process instead of talking to the shared server — see [Alternative: stdio](#alternative-stdio-per-agent-local-process) below. The shared HTTP server is still the recommended path; stdio spins up a separate copy of the embedding model per agent.
---
## Getting started
```bash
# 1. Bring up Qdrant + the MCP server (both run in the background via docker compose)
./scripts/setup_local.sh
# 2. Register the URL with whichever agents you use
./scripts/register_mcp.sh
```
That's it. `setup_local.sh` starts both containers, waits for Qdrant to actually respond (not just "the process started"), copies `.env.example` to `.env` on first run, and syncs the Python package. `register_mcp.sh` then calls each tool's own `mcp add` CLI (or, for Cursor, edits `~/.cursor/mcp.json` directly, since Cursor's CLI only works while the app is open) to point it at `http://localhost:8765/mcp`.
For any other MCP-http-capable tool, including ones we've never heard of, just give it the same URL through that tool's own "add remote MCP server" feature:
```
http://localhost:8765/mcp
```
### Teaching agents to actually use it
Registering the server gets the tools *available*; it doesn't make an agent reach for them on its own. `scripts/install_skill.sh` installs a small skill — "check memory at the start of a session, write to it before you run low on context" — into whichever agents you use, so the habit is there without you repeating it in every prompt:
```bash
./scripts/install_skill.sh # interactive picker
./scripts/install_skill.sh all # install into every supported tool found
./scripts/install_skill.sh --list # see what's installed where
```
It's one canonical `SKILL.md`, symlinked into each tool's own skills directory — edit it once, every tool picks up the change. The repo carries a copy at `skills/blueocean-memory/SKILL.md`, and the installer seeds `~/.agents` from it on a machine that doesn't have it yet; an existing canonical file is never overwritten, so local edits survive.
To update an installed skill from the repository, use `./scripts/update.sh` as described in [Updating an existing installation](#updating-an-existing-installation). It compares the files and backs up the installed skill before replacing it when different. Running the installer again does not update an existing skill.
### Alternative: stdio (per-agent local process)
No Docker available, or you'd rather not run a shared server? Run:
```bash
uv run blueocean-mcp --transport stdio --qdrant-url http://localhost:6333
```
and point the tool's MCP config at the `command` (see `.venv/bin/blueocean-mcp`) instead of a `url`.
---
## Updating an existing installation
From your existing `blueocean-vector` checkout, commit or stash any local changes first, then run:
```bash
./scripts/update.sh
```
The script stops if the checkout has local changes, pulls from the current branch's upstream with `--ff-only`, syncs local Python dependencies, rebuilds the MCP server, and waits up to 180 seconds for the services to become healthy. It stops on failure; earlier successful steps are not rolled back.
After the runtime update succeeds, it compares `skills/blueocean-memory/SKILL.md` in the updated repository with `~/.agents/skills/blueocean-memory/SKILL.md`:
- **Identical:** skips the skill without rewriting it.
- **Different:** backs up the installed file as `SKILL.md.bak.<unique suffix>` beside it, then replaces it with the repository version. Local customizations remain in the backup; they are not merged automatically.
- **Missing:** creates the shared skill from the repository copy.
Agents linked to the shared skill receive the updated file. Start a new agent session to load the new instructions. Manually installed copies elsewhere are not updated; use `./scripts/install_skill.sh --list` to inspect your installation. The update script does not register new clients or create agent symlinks; use `./scripts/install_skill.sh all` for initial skill installation.
For a **stdio installation**, run:
```bash
./scripts/update.sh --stdio
```
This pulls code, syncs dependencies, and updates the skill without invoking Docker. Restart the MCP process through your client afterward.
If your checkout predates `scripts/update.sh`, first run `git pull --ff-only` to get it. Alternatively, update the runtime manually with these commands, one at a time (these do not update the installed skill):
```bash
# Check for local changes before pulling
git status --short
# Download the latest code from your branch's upstream
git pull --ff-only
# Rebuild and replace the shared MCP server with the updated code
docker compose up -d --build blueocean-mcp
# Update local Python dependencies for tools such as blueocean-admin
uv sync --extra dev
```
If you have local changes, commit or stash them before pulling. If `git pull --ff-only` fails, stop and resolve the Git issue before continuing. The MCP server is briefly unavailable while its container is replaced. A plain `docker compose restart` does not rebuild the image and therefore does not apply source-code changes.
Check that the services are ready after the update:
```bash
docker compose ps
curl -fsS http://localhost:8765/health
```
Wait for both services to show `healthy` and for `/health` to return `"status":"ok"`. If the server does not become healthy, inspect its logs:
```bash
docker compose logs --tail=100 blueocean-mcp
```
Reconnect the MCP client if it lost its connection during the update. You do not need to register the server again unless its URL or authentication token changed.
Memory persists in the `qdrant_storage` Docker volume, and telemetry and pricing data persist in `./data`. Keep the same checkout, Compose project name, and `.env` when updating. **Do not run `docker compose down -v` as an update step:** it deletes the Compose-managed volumes, including the stored memory. Keep `./data` as well.
---
## The tools an agent gets
| Tool | What it does |
|---|---|
| `memory_store` | Save an entry — content, a condensed summary, an importance score, and area/module tags |
| `memory_search` | Semantic search, token-budgeted: cheap summaries first, full content for what fits. Scope by area/module/importance/time, and by `kind` — `kind="session_summary"` for only session records, `exclude_kinds=["session_summary"]` to keep them out |
| `memory_get` | Fetch one entry's full content by ID |
| `memory_delete` | Remove one entry by ID |
| `memory_list_projects` | List every project that has a memory collection |
| `memory_manifest` | See what areas/modules exist before searching, so you scope the query sensibly |
| `memory_summarize_session` | Leave a condensed handoff note for whichever agent picks this up next. Lands in the `sessions` module of the given area unless you pass a `module`; the session id goes to metadata, not the manifest |
| `memory_stats` | Counts and distribution, mostly for admin/debugging |
A reasonable agent workflow: call `memory_manifest` then `memory_search` at the start of a session to load context cheaply; `memory_store` real decisions as you go (importance 5 for "why we chose X over Y", importance 3 for routine status); call `memory_summarize_session` before switching tools or running low on budget.
---
## Configuration
Everything lives in `.env` (copy `.env.example` to start). The defaults work for local, single-machine use; the interesting knobs are:
- `BLUEOCEAN_EMBEDDING` — `fastembed` (default, local and free), `openai`, or `bedrock`. Pin `BLUEOCEAN_EMBED_MODEL` too: vectors written with one model can't be meaningfully searched with another, so local and cloud need to agree on it.
- `BLUEOCEAN_QDRANT_URL` — where Qdrant lives.
- `BLUEOCEAN_MAX_TOKENS` / `BLUEOCEAN_TOP_K` — the default search budget.
- `BLUEOCEAN_AUTH_TOKEN` — unset by default (fine for `127.0.0.1`-only use). See [Security](#security) if you're exposing this beyond your own machine.
Transport (`streamable-http` vs `stdio`) is a CLI flag, not an env var — it's a "how do I run this" choice made at startup, not a persistent setting.
---
## Admin CLI
```bash
uv run blueocean-admin stats <project>
uv run blueocean-admin manifest <project>
uv run blueocean-admin list
uv run blueocean-admin export <project>
uv run blueocean-admin prune <project> --older-days 90 --max-importance 2 [--dry-run]
uv run blueocean-admin snapshot <project> [--out ./backups]
uv run blueocean-admin restore <project> <snapshot-file> --yes
uv run blueocean-admin generate-token --write-env
```
> [!WARNING]
> If more than one agent session shares a project, `prune` doesn't know that. It deletes whatever matches your filters, even entries another session wrote five minutes ago. Run with `--dry-run` first, and prefer narrow filters over a broad reset.
`export` only dumps payload as JSON (`with_vectors=False`) — restoring from it means re-embedding everything from scratch, not a real point-in-time restore. `snapshot`/`restore` use Qdrant's own native snapshot mechanism instead: vectors, payload, and index state, captured atomically. `snapshot` downloads the file to local disk and deletes the server-side copy once the download is confirmed intact (backups living only inside the same Qdrant volume they're backing up out of aren't backups). `restore` overwrites the project's current data, so it requires `--yes`.
Project names are validated strictly (`^[a-z0-9][a-z0-9_-]*$`, matching the directory-name convention this project already recommends) rather than silently normalized — two agents guessing slightly different spellings of the same project (`"Team A"` vs `"team-a"`) used to merge into one collection with no warning; now the mismatched one is rejected instead.
---
## Running the tests
Test files under `tests/` are standalone scripts (`if __name__ == "__main__":`), not `pytest`-discovered files — run them as modules:
```bash
uv run python -m tests.smoke
uv run python -m tests.auth
uv run python -m tests.mcp_e2e
uv run python -m tests.backup # real snapshot -> delete collection -> restore cycle
uv run python -m tests.health # /health diagnostics + the cloud-provider self-test TTL cache
```
`tests/auth.py` specifically checks that unauthenticated and wrong-token requests get rejected (401) and that a correct token works via both the header and the `?token=` query-param path.
---
## Usage telemetry
Every MCP tool call is recorded to a local SQLite event log: which tool, which
project, which agent (from the MCP `clientInfo` handshake), how long it took,
whether it failed, how many results a search returned, and what the embedding
cost. It is on by default and never leaves the machine.
It never stores query text, memory content, summaries, entry metadata, or
bearer tokens. A test asserts this by pushing unique sentinel strings through
every input and dumping every column to prove they are absent.
Turn it off with `BLUEOCEAN_TELEMETRY=0`, in which case no database file is
opened and the HTTP endpoints answer 503.
Three ways to read it:
```bash
# From the terminal (reads over HTTP; the server owns the file)
uv run blueocean-admin usage --days 7 --by tool
uv run blueocean-admin usage --unused # entries never retrieved
# In the browser
open "http://127.0.0.1:8765/dashboard?token=$BLUEOCEAN_AUTH_TOKEN"
```
Agents can call the `memory_usage` tool, which returns a compact summary
inside a token budget, with `view="unused" | "tools" | "errors"` for detail.
Embedding prices come from a small built-in table. To refresh them from
OpenRouter's public embedding price feed:
```bash
uv run blueocean-admin usage --refresh-prices
```
That is the only command in the project that sends anything off the machine.
The server never calls out on its own.
Note on the audit trail: destructive operations are recorded, and rows the
server observed itself are distinguished from rows a client reported. With a
single shared auth token this explains accidents; it cannot prove a row was
not forged.
---
## Security
No auth by default — reasonable for `127.0.0.1`-only local use, not reasonable the moment this is reachable from anywhere else.
> [!IMPORTANT]
> If you expose this server beyond localhost (a shared machine, the cloud), set `BLUEOCEAN_AUTH_TOKEN` before you do anything else.
```bash
uv run blueocean-admin generate-token --write-env
docker compose up -d --force-recreate blueocean-mcp
./scripts/register_mcp.sh # reads the token from .env, re-sends it to every tool
```
Not every tool can set a custom header when registering a remote server by URL, so the server accepts the token two ways and each client uses whichever it supports:
- `Authorization: Bearer <token>` — Claude Code, Gemini/Antigravity
- `?token=<token>` on the URL — Codex, Kiro, Cursor
`stdio` transport skips this entirely: it's a locally spawned subprocess, already gated by OS process-spawn permissions rather than sitting on the network.
`GET /health` is deliberately unauthenticated and checks that Qdrant is actually reachable, not just that the process is alive. It's what `docker-compose.yml`'s healthcheck polls. It also reports the active embedding provider/model, and for `openai`/`bedrock` (not `fastembed`, whose model load already gates process startup) validates credentials via a free control-plane call rather than the billed embed endpoint, caching the result for `BLUEOCEAN_HEALTH_EMBED_TTL` seconds (default 60) so a 10s probe interval doesn't turn into a provider API call on every hit:
```json
{"status": "ok", "qdrant": "reachable", "embedding": {"provider": "fastembed", "model": "intfloat/multilingual-e5-large", "ok": true}}
```
Set the token via `BLUEOCEAN_AUTH_TOKEN` (env var / `.env`), not the `--auth-token` CLI flag — a value passed as a CLI argument is visible to any other local user via `ps`. Request access logging is also off by default (`access_log=False`), since three of the five supported clients send the token as `?token=...` and a plain access log would put it in plaintext in your logs on every single request.
---
## Deploying beyond localhost
`docker compose up -d` runs two long-lived services: `qdrant` (port 6333) and `blueocean-mcp` (port 8765). For the cloud, the same two services move to ECS Fargate (or Qdrant Cloud plus a small Fargate/App Runner service for `blueocean-mcp`) — register the public URL with each tool exactly the way you would locally. The `Dockerfile` pins the embedding model so vectors produced in the cloud are compatible with ones produced on your laptop.
Kubernetes doesn't read `docker-compose.yml`'s `healthcheck:` — it needs its own probes in the Pod spec, but they can point at the same path:
```yaml
readinessProbe:
httpGet: { path: /health, port: 8765 }
livenessProbe:
httpGet: { path: /health, port: 8765 }
```
**Linux bind-mount ownership.** The container runs as uid 10001 (`appuser` in the `Dockerfile`) and writes its telemetry database and pricing file into the `./data` bind mount. On macOS, Docker Desktop maps bind-mount ownership to the host user, so this is invisible. On Linux the container's uid is the file's uid, and a `./data` directory created by your normal user (typically uid 1000) is not writable by 10001. Create and hand over the directory **before** the first `docker compose up`:
```bash
mkdir -p data && sudo chown -R 10001:10001 data
```
If the stack already started with a wrongly-owned `./data`, chown is not enough on its own: after a failed open, `TelemetryWriter` disables itself for the life of that process, so telemetry stays empty even once the permission is fixed. Chown, then restart the service:
```bash
sudo chown -R 10001:10001 data && docker compose restart blueocean-mcp
```
(`chgrp` + `chmod g+rwx` works too if you would rather keep the directory owned by yourself; the server only needs to create and write files inside it. Hosts that need to inspect `data/telemetry.db` should do it read-only — see the note in "Usage telemetry" about SQLite locking across bind mounts.)
---
## A few gotchas worth knowing before you touch this
- **`qdrant-client` is pinned to the Qdrant server's exact version** (see the image tag in `docker-compose.yml`). Qdrant versions its client and server in lockstep, and the API has changed between releases — `.search()` was removed in favor of `.query_points()` in 1.19. If you bump the server image, bump `qdrant-client` to match and re-run the test suite; don't jump several versions on real data without a snapshot first.
- **`mcp` is pinned `>=2.0.0,<3.0.0`**, tighter than most dependencies here. Its API (`mcp.server.mcpserver.MCPServer` and friends) has changed shape significantly between releases, and a loose constraint risks a Docker build silently resolving something incompatible — Docker builds don't use `uv.lock`.
- **Embedding provider and model are a matched pair.** Switch either one and old vectors become unsearchable garbage against new ones. Pin the model in `.env` rather than trusting a library default that might change out from under you.
- **Project vocabulary lives in [`CONTEXT.md`](CONTEXT.md), and one term is a live trap.** "Summary" covers two things that do not substitute for one another: a *Session Summary* is written by an agent and carries what was decided and why, while a *Session Trace* is composed by the server and records only what a session touched. Only the first exists today (`memory_summarize_session`); the second is a decided design, not shipped. Read the glossary before naming anything new in this codebase.
---
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 8 tools
Each tool targets a distinct memory operation: stats, store, search, get by ID, delete, list projects, manifest areas, and session summary. No two tools overlap in purpose, ensuring clear differentiation for an agent.
All tools follow the uniform 'memory_<verb>_<noun>' pattern with snake_case. Verbs are descriptive and consistent (stats, store, search, get, delete, list_projects, manifest, summarize_session), making the set predictable and easy to navigate.
With 8 tools, the surface covers the core memory management lifecycle (create, read, delete, search) plus administrative utilities (stats, manifest, project listing, session summary). This is well-scoped without being excessive or sparse.
The tool set covers the fundamental CRUD operations except for an explicit update/modify tool. While search and get provide read access, and store creates new entries, the absence of an update operation is a minor gap that agents may need to work around.