@bli-cockpit/memory-mcp
v0.1.35
Published
BLI Memory — an MCP server for the memory layer BLI owns (save, search, update, forget).
Readme
@bli-cockpit/memory-mcp — BLI Memory
An MCP server for BLI Memory, the memory layer BLI owns: one table in our own Postgres, four operations, no vendor in the path.
Prompt hooks also record content-free outcome, duration and actual vector-cache
observations for cockpit ops --memory. These are hook-body timings, not host
acceptance or retrieval-quality scores. Explicit experiments use
bli-memory-mcp hook prompt --probe; stderr confirms probe stats_excluded
and neither ordinary-use counter records the probe. See the repository's
docs/runbooks/memory-hook-measurement.md for coverage and timing boundaries.
Four tools:
| Tool | What it does | Annotations |
|---|---|---|
| search_memory | Search one space by meaning, by keyword, and by recency at once. | read-only, idempotent |
| save_memory | Remember something durable. customId upserts. extract: true distils atomic facts first. | additive |
| update_memory | Replace what a memory says, keeping the old version as history. | additive |
| forget_memory | Tombstone a memory by id, by exact text, or by the closest match ≥ 0.85. | destructive |
search_memory and save_memory keep the names and argument names the hosted
Supermemory MCP used, so an agent, a skill or a habit pointed at those words
keeps working. update_memory is new — the vendor had no update tool at all,
and correction there meant forget-then-save, which loses the link between the
two.
Plus three Claude Code hooks, on the same bin — see below.
Install
Nobody installs this by hand. It ships as a dependency of
@bli-cockpit/cli, and cockpit memory install (which cockpit doctor
and the daily sync tick both run) registers it with both hosts. A standalone
install is for development:
npm i -g @bli-cockpit/memory-mcpThe server prints the exact block each host wants, so nothing has to be retyped:
bli-memory-mcp --print-config # both, with headings
bli-memory-mcp --print-config=claude # the install contract (see below)
bli-memory-mcp --print-config=codex # ~/.codex/config.toml--print-config --claude prints the install contract,
bli-memory-install-config.v1 — the MCP server entry and the three hooks with
their timeouts, because this bin is the only thing that knows which hook
subcommands it implements:
{
"schema": "bli-memory-install-config.v1",
"server_id": "bli-memory",
"mcp_server": { "command": "bli-memory-mcp", "args": [], "env": {} },
"hooks": [
{ "event": "SessionStart", "command": "bli-memory-mcp hook session-start", "timeout_seconds": 30 },
{ "event": "UserPromptSubmit", "command": "bli-memory-mcp hook prompt", "timeout_seconds": 5 },
{ "event": "Stop", "command": "bli-memory-mcp hook stop", "timeout_seconds": 30 }
],
"permissions_allow": ["mcp__bli-memory__search_memory"]
}command is the bare bin name; cockpit memory install replaces it with the
absolute path it resolved on that machine, in every one of those strings. It is
never npx -y @bli-cockpit/memory-mcp, which would re-resolve the package from
the registry on every agent launch.
By hand: mcp_server goes under mcpServers.bli-memory in ~/.claude.json,
each hook under hooks.<event> in ~/.claude/settings.json, and the bin path
has to be absolute or on your PATH.
The three hooks
bli-memory-mcp hook session-start # SessionStart
bli-memory-mcp hook prompt # UserPromptSubmit
bli-memory-mcp hook stop # StopEach reads Claude Code's JSON payload on stdin and:
| Hook | Reads | Calls | Prints |
|---|---|---|---|
| session-start | cwd | POST /api/memory/v4/profile | <bli-memory-context> with User Profile + Recent Context, ◪ per line |
| prompt | prompt | POST /api/memory/search (top 5, ≥ 0.55 similarity) — unless the prompt is trivial, see below | <bli-memory-recall>, ◪ per line |
| stop | transcript_path | POST /api/memory/save in extract mode — the door answers 202 queued and distils after the response (BLI-3730) | nothing, ever |
Four rules they all keep, because a hook runs on every turn of every session on every machine:
- Never block. Each has a deadline below the timeout the installer registers — 8 s / 2 s / 15 s against 30 / 5 / 30 — including a hard deadline on the stdin read. On any deadline it prints nothing and gives the host its process back. The prompt hook's budget is sized to the PERSON waiting with a prompt already typed, not to the host's 5 s ceiling (BLI-3730): nobody notices a recall that gives up under its timeout, everybody notices a turn that stalls before the model starts.
- Always exit 0. Claude Code reads exit 2 as "block this prompt" and any other non-zero as an error it shows the person. An unpaired machine, an outage, a malformed payload: all quiet in the transcript.
- Print nothing, or one whole block. No "no memories found" line — the model would reason about it, and an empty record and an unreachable store would look the same to it. Zero hits is data and prints nothing.
- Do not search for a prompt with nothing in it (BLI-3881,
hooks/trivial-prompt.ts). "ok", "continue", "y", "thanks", anything under twelve characters, and any/slashcommand are answered withskipped {"reason":"trivial_prompt"}— exit 0, nothing printed, no door call. Those turns carry no subject to recall against, so the embed is of a stopword and the floor throws the neighbours away at the far end, after the person has already waited. The rule is a CLOSED LIST plus a length, decided from the text alone, with one override: a short prompt naming an identifier (BLI-3580,search.ts,v0.2.83) is always searched, because that is the single best query the lexical channel receives. Each skip is counted asskipped_trivialin the hook stats file, so a hook that stops searching can never be mistaken for a hook that broke. - One stderr receipt per run, on every branch, one of three shapes
(BLI-3664,
hooks/log-line.ts):[bli-memory] prompt ok {"hits":0,"elapsed_ms":140,"deadline_ms":2000},[bli-memory] prompt skipped {"reason":"trivial_prompt"}, or[bli-memory] prompt failed {"reason":"dashboard_unreachable","elapsed_ms":1502,"deadline_ms":2000}.reasononskipped/failedis always one of a closed set — the four network reasonstimeout,http_<status>,dashboard_unreachable,bad_response(Tower answered with a body that was not readable JSON); the sixStdinFailurereasonsno_stdin,stdin_empty,stdin_timeout,stdin_too_large,stdin_unreadable,stdin_malformed— each its own literal, never folded intobad_response, because a malformed payload from the host never touched the network (BLI-3700);unpaired,no_prompt,trivial_prompt(BLI-3881, below), plus three Stop-only local skips (reentrant,no_transcript,redaction_failed) — never a stack trace, never Tower's own error text, never a prompt, a memory, a path or a token. A reasontoLogReasonhas not been taught becomesunknown_internal:<name>, never a silentbad_response. Afailedstop also gets a one-linesystemMessage(the same JSON-on-stdout shape the Supermemory plugin's own banner uses), because Claude Code does not surface a hook's stderr anywhere a person reads it and a failed save means the turn was not kept. A failed RECALL is silent (BLI-3730): it costs the person nothing and a banner on every prompt during an outage is noise they cannot act on.
hook stop sends the last user/assistant exchange (at most 12 KB, tail-read so
a 40 MB transcript costs nothing) through the collector's own redactor —
redactSecretLikeContent from @bli-cockpit/telemetry-core, the same function
that masks every uploaded transcript — and lets the librarian's
extract/reconcile decide what is worth keeping. A re-entrant Stop
(stop_hook_active: true) saves nothing.
Since BLI-3730 the door queues that work: it writes one durable
memory_extract_jobs row, answers 202, and distils after the response, so the
hook reports ok {"chars":…,"masked":…,"queued":true} and never claims a save
it cannot see. saved is absent on that branch rather than 0 — "accepted,
pending" and "nothing was worth keeping" must never read as the same line. The
outcome lands on the job row and in Tower's own
[bli-memory] extract job done {added, updated, unchanged, conflicted,
extract_ms, reconcile_ms}.
The resident daemon (BLI-3884)
bli-memory-mcp daemon # one per OS user; the prompt hook starts it for youA prompt hook is a fresh process that opens a fresh TLS connection and proves a
device token before it asks anything: 107 ms of boot, 131-709 ms of connection
setup and 65-165 ms of gate, none of it the question. The daemon holds all
three warm and answers prompt and session-start by running the same code
the hook runs — there is no second implementation of recall.
| | |
|---|---|
| endpoint | ~/.local/state/bli-cockpit/memory-daemon.sock (0600), or \\.\pipe\bli-memory-<user> on Windows |
| protocol | one line of JSON in, one out: {event, payload, cwd} → {stdout, outcome}; ping asks if it is alive |
| the token | never on the wire — the daemon reads this machine's session file itself |
| the fallback | any failure at all → the hook takes the direct path, unchanged, and says why on stderr |
| the connect budget | 50 ms, inside the hook's own unchanged HOOK_BUDGETS deadline |
| who starts it | the prompt hook, detached, at most once an hour per machine — no launchd job, no scheduled task |
| when it stops | 2 hours idle, or when a second instance finds it already running (that one exits 0, daemon_already_running) |
| an upgrade | every request carries the caller's version; a daemon that does not match stands down and the hook starts a current one |
| off switch | BLI_MEMORY_DAEMON=0 |
Measured on an Apple Silicon Mac against production, 20 real prompts each way (2026-09-06): p50 934 ms direct → 734 ms via the daemon. The remainder is ~92 ms of Node boot the host imposes plus the door's own 468-599 ms; the ticket's 400 ms target is a server-side question, not a daemon one.
Every hook run records which route answered — via: daemon or via: direct on
the stderr receipt, in the hourly counts file, on the heartbeat, and as one line
per machine on cockpit ops --memory. cockpit doctor carries a
memory-daemon row that is present-or-absent-and-named and never red, and
cockpit memory status says whether the socket answers.
Auth
None to set up. The server reads the collector's existing paired session at
~/.config/bli-cockpit/session.json — the one cockpit login wrote — and
sends that device token to Tower's memory doors. If the machine is not paired
it says so, in one sentence, on stderr, and exits 1.
Optional environment overrides:
| Variable | Effect |
|---|---|
| BLI_MEMORY_DEVICE_TOKEN | Use this token instead of the session file (CI, containers). |
| BLI_MEMORY_DASHBOARD_URL | Point at another Tower deployment. |
| BLI_MEMORY_CONTAINER_TAG | Force the memory space, instead of deriving it. |
| BLI_MEMORY_ISOLATE_WORKTREES=1 | Give each worktree its own space rather than sharing the repo's. |
The space a memory lands in
The container tag is computed exactly the way the old vendor plugin computed
it — repo_<name>__<sha256(normalised git remote)[0:16]> — because that is
what makes the switch invisible: the memories a repo had yesterday are the
memories it has today. The remote is the identity, so every worktree of one
repo shares a space. A folder that is not a git repository still gets its own
space (dir_<name>__<sha16>), because a folder is a legitimate workspace.
One repository can still have TWO tags on the shelf — the vendor's, kept
verbatim when its memories were imported, and the one derived above. Tower
reconciles them server-side (agent_memory_container_aliases, BLI-3620):
search_memory is answered from every tag that names the same space, and
save_memory lands in the canonical one. That is why a saved memory can report
a space you did not pass — the tool names where the row actually went, not what
it asked for.
The stdio contract
Search text separates remembered facts from Tower documents, messages and
issues. Tower matches include their source, ID, full supporting text and an
openable link when supplied. Cosine similarity is labelled only when a memory
row supplies it; a Tower fused score or missing value is never shown as zero
percent similarity. structuredContent retains the original rows and scope.
stdout carries the JSON-RPC stream and nothing else. Every operational
line — the startup banner, an auth failure, a warning — goes to stderr. The one
exception is --print-config, which prints a config block to stdout and exits
without opening a transport.
Two things worth knowing
Zero matches is data; a transport failure is not. A search that found
nothing returns text saying the record is silent. A search that could not run
returns isError and names why. An agent that cannot tell those apart will
confidently report the first when the second happened.
The old plugin's auto-approve regex does not match this server. The vendor's
Claude Code plugin ships a PreToolUse hook matching
^mcp__(?:plugin_supermemory_|claude_ai_)?supermemory__(.+)$ and auto-allowing
its read-only tools. Our server is named bli-memory, so tool ids read
mcp__bli-memory__search_memory and that hook does not fire — reads will
prompt for permission until BLI ships its own plugin (with its own approve
hook) through bli-agents-marketplace. Renaming this server to contain
"supermemory" to inherit somebody else's auto-approve would be a worse trade
than one extra prompt.
Development
npm run build --workspace=@bli-cockpit/memory-mcp
npm test --workspace=@bli-cockpit/memory-mcpPublish: npm publish --workspace=@bli-cockpit/memory-mcp --access public --tag latest.
Architecture, the trap list and the trial plan: docs/architecture/agent-memory.md.
Live A/B: agent experience
Every BLI Memory prompt and session-start recall line and recall banner carries
[bli]; Supermemory uses ◪. Copy the observed marker into the experience
line. --store both prints [bli]+◪; it is a joint opinion, not a win credited
to each store. Hook budgets are unchanged.
Run cockpit memory log win --store bli "The dated decision answered the question directly"
(or MCP memory_experience with store, verdict, reason). Verdicts are
win, loss, noise; stores are bli, supermemory, both. Reasons must be
one line, at most 500 characters: opinions about tools only, never prompts,
memory bodies or secrets. --reason-stdin accepts the reason on stdin;
--json returns the entry ID, local receipt and shipped status.
The append-only ~/.codex/AGENT-EXPERIENCE.md format is
- YYYY-MM-DD HH:MM ICT · <agent> · <project> · <store> <marker> · <verdict>: <reason>.
Each receipt has a UUID sidecar in ~/.codex/agent-experience-outbox/ with
shipped: false until Tower acknowledges it. A failed request leaves the
receipt queued. Sync ships at most 20 entries per tick, stops on failure and
never fails collection. A lost response is retried under the same UUID.
POST /api/memory/experience accepts {id?, store, verdict, reason, created_at?}
with a signed-in cookie or paired device token. Person and device are derived
from authentication. It returns 201 for insertion, 200 for an identical retry,
400 for invalid input, 409 for conflicting UUID content and 502 for a failed
insert. The timestamp preserves when an offline opinion was recorded.
Authenticated people can insert their own and read all opinions; only the
service role can update or delete. RLS is enabled and forced.
cockpit ops --memory and GET /api/ops/memory-usage report EXPERIENCE:
7/30-day rolling totals by store and person and the last five reasons per store
verbatim within the 30-day window. MCP ops_status(memory: true) and JARVIS
readOps expose the same aggregation. The reader pages in timestamp/UUID order,
counts requested, returned and unique rows, and names capped or failed reads.
Multiple requests are not a database snapshot. This is self-reported experience;
counts do not themselves establish quality, unbiased sampling or cost.
Edward's decision rule: "if BLI Memory wins the experience log for two weeks and stays ten times cheaper, kill the subscription"
