memspine is an HTTP server that stores an agent's events per tenant and returns them by embedding, by keyword, or by the links between them. The caller supplies the embeddings; the server never calls a model. This page states what each operation guarantees, what was measured and how, and where the service stops.
curl -fsSL https://memspine.com/install.sh | sh
Installs two binaries into ~/.local/bin, checked against a published checksum. No root. First session.
| Stores | typed events: kind, time, text, embedding, JSON properties, links to source events |
|---|---|
| Durable | after one fsync of the tenant's log, before the response |
| Retrieval | exact cosine, BM25, or both fused by reciprocal rank |
| Evidence recall@20, LoCoMo | 0.889 with fact extraction and query expansion done by the client; 0.794 on raw turns |
| Search, p50 | 2.8 ms at 10,000 events, 35 ms at 100,000; linear in tenant size |
| Memory | 1.9 GiB peak resident for a run that stored and queried 100,000 events of 384 dimensions |
| Forgetting | unreadable when DELETE returns; bytes off disk within 60 s by default |
| Does not | call a model, replicate, terminate TLS, or bound the cost of a Cypher query |
Each row links to the section that gives its conditions.
Everything below is shown on this log: ten events from a short session between a user and an agent. A turn is something said, a tool_call is something the agent ran, and a fact is something the agent derived and wrote back. The lines at the left are SOURCE_FROM edges. Each fact points at the events it was derived from.
| time | kind | id | text | |
|---|---|---|---|---|
| 09:14:02 | turn | 75173310 | Ana: we adopted a greyhound called Pixel last weekend | |
| 09:14:31 | turn | 9509bd23 | Ana: she needs a vet, somewhere open on Saturdays | |
| 09:14:33 | tool_call | 697ea0fa | find_vets(city=Lisbon, open=saturday) -> Clinica Arroios, VetLuz | |
| 09:15:10 | turn | b8533f46 | Ana: book Clinica Arroios for this Saturday at ten | |
| 09:15:12 | tool_call | 740c7d03 | book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291 | |
| 09:15:13 | fact | d53b6b8b | Ana owns a greyhound named Pixel | |
| 09:15:14 | fact | b41bdb35 | Ana prefers Saturday appointments | |
| 09:15:15 | fact | 3dce87e5 | Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291 | |
| 09:41:50 | turn | af2d5cc8 | Ana: my number is 912 555 019, text me the day before | |
| 09:41:52 | fact | c3d7a7af | Text Ana the day before the vet appointment |
Output of GET /v1/memory/events on a local 0.2.2 server, oldest first. Embeddings have four dimensions and were chosen by hand so the rankings below can be followed; the two tool_call events were stored without one. Every request and response of the session is in session.txt.
$ curl -fsSL https://memspine.com/install.sh | sh
Downloading memspine-macos-arm64.tar.gz ...
Checksum OK.
Installed memspine-server and memspine-admin to ~/.local/bin
Start the server:
MEMSPINE_BIND=127.0.0.1:7777 ~/.local/bin/memspine-server
$ MEMSPINE_BIND=127.0.0.1:7777 MEMSPINE_EMBEDDING_DIM=4 \
~/.local/bin/memspine-server > memspine.log 2>&1 &
$ sleep 1; curl localhost:7777/health
{"service":"memspine-server","status":"ok","version":"0.2.2"}
$ K="Authorization: Bearer 00000000-0000-0000-0000-000000000042"
$ J="Content-Type: application/json"
$ TURN=$(curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
-d '{"kind":"turn","embedding":[0.9,0.1,0.2,0.1],
"text":"Ana: we adopted a greyhound called Pixel last weekend"
}' | jq -r .event_id)
$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
-d '{"kind":"fact","embedding":[0.8,0.2,0.1,0.1],
"text":"Ana owns a dog named Pixel","properties":{
"source_event_ids":["'"$TURN"'"]}}'
{"event_id":"b5ea3003-7681-4632-a015-5ec4fdec7595"}
$ curl -s localhost:7777/v1/memory/recall -H "$K" -H "$J" \
-d '{"text":"which dog does Ana have","k":1,"hops":1}' \
| jq -c '.hits[] | [.kind, .text]'
["fact","Ana owns a dog named Pixel"]
["turn","Ana: we adopted a greyhound called Pixel last weekend"]
Run on macOS arm64 against the published binaries, 2026-10-08. The server takes 384-dimension embeddings unless MEMSPINE_EMBEDDING_DIM says otherwise; this one was started with 4. The last two commands need jq.
The bearer token above is a bare UUID. With no control database, any UUID is a tenant and is also that tenant's key, with read and write access. That mode has no secret and is for local use. memspine-admin init creates the control database, after which only issued keys are accepted.
| field | type | used for | limit |
|---|---|---|---|
| kind | string, required | a filter in list, search and recall | 1 to 256 bytes |
| text | string | keyword recall; returned with the event | the request body |
| embedding | array of numbers | cosine search | exactly the server's dimension; finite, not all zero |
| properties | JSON object | returned as sent | the request body |
| properties.source_event_ids | array of event ids | one SOURCE_FROM edge per id that exists in the tenant | unknown ids are skipped |
| ts | integer, microseconds | order, and the since and until filters | defaults to arrival time; not checked against the server clock |
| event_id | UUID, in the response | get, forget, lineage | chosen by the server, or derived from an Idempotency-Key |
A request body may be 2 MiB for one event and 32 MiB for a batch of up to 1,000.
Events are immutable. To correct one, append the correction and forget the original.
Text and properties of any size within the body limit are stored and read back as sent. From Cypher, up to 16 top-level properties per event can be filtered on by name: booleans, numbers and strings of at most 256 bytes, taken in key order. n.text is null there for text over 4,096 bytes; returning the node gives the whole event.
The server records each tenant's embedding dimension and refuses to open the tenant under a different one once it holds embeddings. It does not record which model produced the vectors, so two models of the same dimension would be mixed without warning.
$ curl -s localhost:7777/v1/memory/event/$F8 -H "$K" | jq .
{
"event_id": "3dce87e5-4b2c-4cc0-96bc-338ce95f158f",
"kind": "fact",
"ts": 1791364515000000,
"text": "Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291",
"properties": {
"source_event_ids": [
"b8533f46-b491-4c20-a078-b0ca2e039926",
"740c7d03-9b97-4854-8799-6d66524cf3fe"
]
}
}
Auth is Authorization: Bearer <key>. The tenant comes from the key and never from the request body. Every response carries x-memspine-trace-id, and the lines the server logs for that request carry the same id.
| route, scope | true when it returns | not promised |
|---|---|---|
| POST /v1/memory/event write | The event, its embedding, its text and its edges are in the tenant's log and the log has been fsynced. A killed server restarts with it. Under an Idempotency-Key the id is derived from tenant and key, so a repeat returns the first id and stores nothing, with no expiry. | A second copy on another disk. A repeat's body is not compared with the first. |
| POST /v1/memory/events write | Up to 1,000 events stored by one batch and one fsync: all of them or none. Events without a ts get consecutive timestamps one microsecond apart. | A keyed batch replayed with the same number of events or fewer returns the first ids without comparing bodies; with more events it gets a 400. |
| GET /v1/memory/event/<id> read | The event as stored. 404 for an id that belongs to another tenant or was forgotten. | |
| GET /v1/memory/events read | Events newest first by (ts, event_id), filtered by kind, since, until, in pages that follow a cursor. | |
| POST /v1/memory/search read | The k nearest events by exact cosine similarity among those that pass the filters. k is at most 1,000. | Sub-linear time: every embedding in the tenant is compared. |
| POST /v1/memory/recall read | A ranking by keyword, by embedding, or by both fused, then up to k events reached over SOURCE_FROM within hops steps. The same request over the same data returns the same order. How it ranks. | Keyword matches in scripts other than ASCII letters and digits. |
| POST /v1/memory/cypher read | Columns and rows of a read-only query over (:Event) nodes and [:SOURCE_FROM] edges. Writes get a 400. | A bound on what the query costs. The screen refuses known explosive shapes only. |
| DELETE /v1/memory/event/<id> write | Get, list, search, recall and Cypher no longer return the event, its edges are gone, and that is durable. With ?purge=true its bytes have also left the tenant's files. | Without ?purge=true the bytes stay in the log until the next purge. What remains. |
| GET /v1/whoami any key | The tenant and scopes behind the key. | |
| GET /health, GET /metrics no key | Liveness, and Prometheus counters and latency histograms for each data-plane handler. No metric is labelled with a tenant id. | /metrics is unauthenticated; move it with MEMSPINE_METRICS_BIND. |
| /v1/admin/tenants, /keys, /audit admin key | Tenants and keys created, listed and revoked; each change recorded in an audit log. These take the server's MEMSPINE_ADMIN_KEY and answer 503 while it is unset. |
Errors are JSON, {"error": code, "detail": text}: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 429 rate_limited, 500 internal_error, 503 unavailable.
| step | if the process dies here |
|---|---|
| The key is resolved to a tenant and a scope. The tenant's rate limit and its bound on calls in flight are checked. | Nothing was written. |
| Under the tenant's write lock, one FFS batch applies the node, the embedding, the text and the edges. | The append was never acknowledged. It may or may not be there after a restart. |
| The tenant's log is fsynced. | The same. |
| The response is sent with the event id. | The event is there after a restart. |
Appends within a tenant are serialised; reads run concurrently and do not wait for them. A read can therefore return an event during the moment between its batch and its fsync, before the append has been acknowledged.
$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" -d @last.json; echo
{"event_id":"1eb38657-2649-47e6-b082-bf90e6da5c72"}
$ kill -9 $(lsof -tiTCP:7777 -sTCP:LISTEN); sleep 0.3; curl -s -m 2 localhost:7777/health; echo "curl exit $?"
curl exit 7
$ MEMSPINE_BIND=127.0.0.1:7777 MEMSPINE_EMBEDDING_DIM=4 MEMSPINE_DATA_DIR=./data \
memspine-server > memspine.log 2>&1 &
$ curl -s localhost:7777/v1/memory/event/$E11 -H "$K"; echo
{"event_id":"1eb38657-2649-47e6-b082-bf90e6da5c72","kind":"turn","ts":1791366150000000,"text":"Ana: thanks, that is all for today","properties":{}}
An acknowledged append, kill -9, a restart, and the event read back. Durability was tested this way, by killing the process. It has not been tested by cutting power, and there is one copy on one disk.
$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
-H "Idempotency-Key: turn-0941" -d @turn.json; echo
{"event_id":"af2d5cc8-2a80-583b-b7ed-941419d7a27f"}
$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
-H "Idempotency-Key: turn-0941" -d @turn.json; echo
{"event_id":"af2d5cc8-2a80-583b-b7ed-941419d7a27f"}
The same request twice under one Idempotency-Key. The id is a version-5 UUID of the tenant and the key, so both calls return it and one event is stored.
Each tenant is its own database directory, opened on first request. Another tenant's event id returns 404. Each tenant has a token bucket (1,000 requests per second, burst 100) and may have 32 engine calls running or queued; past either it gets a 429. What tenants share is one process, one worker pool and one heap, so a large tenant's scan uses the same cores and memory as its neighbours.
Given an embedding, events are ranked by cosine similarity. Given text, they are ranked by BM25 over text. Given both, the two rankings are fused by reciprocal rank (Cormack, Clarke and Büttcher, 2009), with ranks counted from 1:
kind, since and until filter after ranking; the candidate pool widens fourfold until k events pass or the tenant is exhausted.One request, text when is the vet appointment for Pixel and embedding [0.3, 0.9, 0.3, 0.0], asked three ways. Greyed keyword rows fall under the floor.
| rank | cosine | kind | text |
|---|---|---|---|
| 1 | 0.9957 | fact | Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291 |
| 2 | 0.9908 | turn | Ana: she needs a vet, somewhere open on Saturdays |
| 3 | 0.9535 | turn | Ana: book Clinica Arroios for this Saturday at ten |
| 4 | 0.5721 | fact | Ana prefers Saturday appointments |
| 5 | 0.5360 | fact | Text Ana the day before the vet appointment |
| 6 | 0.3996 | turn | Ana: we adopted a greyhound called Pixel last weekend |
| 7 | 0.3015 | fact | Ana owns a greyhound named Pixel |
| 8 | 0.1626 | turn | Ana: my number is 912 555 019, text me the day before |
Embedding only.
| rank | BM25 | kind | text |
|---|---|---|---|
| 1 | 4.6866 | fact | Text Ana the day before the vet appointment |
| 2 | 3.1517 | turn | Ana: my number is 912 555 019, text me the day before |
| 3 | 2.5925 | fact | Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291 |
| 4 | 2.0447 | turn | Ana: book Clinica Arroios for this Saturday at ten |
| 5 | 1.3526 | fact | Ana owns a greyhound named Pixel |
| 6 | 1.1752 | turn | Ana: we adopted a greyhound called Pixel last weekend |
| 7 | 0.9173 | turn | Ana: she needs a vet, somewhere open on Saturdays |
| 8 | 0.8109 | tool_call | book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291 |
Text only. The second row matches on is and the.
| rank | score | emb. | kw. | kind | text |
|---|---|---|---|---|---|
| 1 | 0.0243 | 1 | 3 | fact | Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291 |
| 2 | 0.0236 | 5 | 1 | fact | Text Ana the day before the vet appointment |
| 3 | 0.0228 | 8 | 2 | turn | Ana: my number is 912 555 019, text me the day before |
| 4 | 0.0161 | 2 | – | turn | Ana: she needs a vet, somewhere open on Saturdays |
| 5 | 0.0159 | 3 | – | turn | Ana: book Clinica Arroios for this Saturday at ten |
| 6 | 0.0156 | 4 | – | fact | Ana prefers Saturday appointments |
| 7 | 0.0152 | 6 | – | turn | Ana: we adopted a greyhound called Pixel last weekend |
| 8 | 0.0149 | 7 | – | fact | Ana owns a greyhound named Pixel |
At these settings the table is the server's own response to the request above. The sliders recompute the fusion in the page with the server's formula. Set the floor to 0 and the weak keyword matches take ranks, including a tool_call that has no embedding.
After ranking, hops follows SOURCE_FROM edges out from the hits, in both directions, at most 3 steps. It adds at most k events, examines at most 8k, and marks each addition with via, the hit whose walk reached it. The time filters apply to what is reached; the kind filter does not, since the point is to cross from a fact to the turns behind it.
$ curl -s localhost:7777/v1/memory/recall -H "$K" -H "$J" \
-d '{"text":"vet appointment booking","k":2,"hops":1}' \
| jq -r '.hits[] | [.kind, .event_id[0:8], (.via // "-")[0:8], .text[0:52]] | @tsv'
fact 3dce87e5 - Pixel has a vet appointment at Clinica Arroios on 20
fact c3d7a7af - Text Ana the day before the vet appointment
turn b8533f46 3dce87e5 Ana: book Clinica Arroios for this Saturday at ten
tool_call 740c7d03 3dce87e5 book_vet(Clinica Arroios, 2026-10-10 10:00) -> confi
| time | kind | id | text | recall | |
|---|---|---|---|---|---|
| 09:14:02 | turn | 75173310 | Ana: we adopted a greyhound called Pixel last weekend | ||
| 09:14:31 | turn | 9509bd23 | Ana: she needs a vet, somewhere open on Saturdays | ||
| 09:14:33 | tool_call | 697ea0fa | find_vets(city=Lisbon, open=saturday) -> Clinica Arroios, VetLuz | ||
| 09:15:10 | turn | b8533f46 | Ana: book Clinica Arroios for this Saturday at ten | via hit 1 | |
| 09:15:12 | tool_call | 740c7d03 | book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291 | via hit 1 | |
| 09:15:13 | fact | d53b6b8b | Ana owns a greyhound named Pixel | ||
| 09:15:14 | fact | b41bdb35 | Ana prefers Saturday appointments | ||
| 09:15:15 | fact | 3dce87e5 | Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291 | hit 1 | |
| 09:41:50 | turn | af2d5cc8 | Ana: my number is 912 555 019, text me the day before | ||
| 09:41:52 | fact | c3d7a7af | Text Ana the day before the vet appointment | hit 2 |
Two ranked hits and the two sources of the first. The second hit's sources are one hop away too; the two additions allowed by k were already taken.
$ curl -s localhost:7777/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
"MATCH (f:Event)-[:SOURCE_FROM]->(s:Event) WHERE f.kind = $kind RETURN f.text, s.kind, s.text",
"params":{"kind":"fact"}}' | jq -c '.rows[]'
["Ana owns a greyhound named Pixel","turn","Ana: we adopted a greyhound called Pixel last weekend"]
["Ana prefers Saturday appointments","turn","Ana: she needs a vet, somewhere open on Saturdays"]
["Ana prefers Saturday appointments","turn","Ana: book Clinica Arroios for this Saturday at ten"]
["Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291","tool_call","book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291"]
["Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291","turn","Ana: book Clinica Arroios for this Saturday at ten"]
["Text Ana the day before the vet appointment","fact","Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291"]
["Text Ana the day before the vet appointment","turn","Ana: my number is 912 555 019, text me the day before"]
Retrieval was scored on LoCoMo (Maharana et al., 2024): 10 long conversations, 1,540 questions in categories 1 to 4. The metric is the fraction of a question's gold evidence turns among the 20 events retrieved per query. 1,536 questions are scored, because four open-domain questions name no evidence. Embeddings are text-embedding-3-small at 384 dimensions.
The server does not extract facts or rewrite queries. In the upper half of the table the benchmark harness does both with gpt-4o-mini, as a client would: it appends dated facts linked to their source turns, and expands each question into subqueries. The lower half stores raw turns and sends the question as the only query. The difference between the halves is the client's work.
| retrieval | multi-hop n=282 | temporal 321 | open-domain 92 | single-hop 841 | all 1,536 |
|---|---|---|---|---|---|
| facts and query expansion by the client | |||||
| keyword | 0.585 | 0.902 | 0.532 | 0.885 | 0.812 |
| embedding | 0.734 | 0.940 | 0.550 | 0.921 | 0.869 |
| fused | 0.746 | 0.946 | 0.608 | 0.946 | 0.889 |
| raw turns, the question as the only query | |||||
| keyword | 0.279 | 0.670 | 0.310 | 0.689 | 0.587 |
| embedding | 0.609 | 0.835 | 0.484 | 0.861 | 0.787 |
| fused | 0.590 | 0.844 | 0.531 | 0.873 | 0.794 |
Evidence recall@20, LoCoMo, 10 conversations, run 2026-10-07 on the 0.2.1 engine with embeddings and model calls replayed from a cache recorded in June. Marked: fusion's largest gain, the keyword-only collapse on multi-hop questions without query rewriting, and fusion's loss on multi-hop for raw turns.
The keyword weight and the floor were chosen on the first five conversations and checked on the other five.
| weight / floor | tuned on | held out |
|---|---|---|
| embedding only | 0.859 | 0.878 |
| 0.25 / 0.5 | 0.874 | 0.898 |
| 0.5 / 0.5, the default | 0.882 | 0.896 |
| 0.75 / 0.5 | 0.881 | 0.896 |
| 1.0 / 0.5 | 0.879 | 0.887 |
| 0.5 / 0.3 | 0.881 | 0.892 |
| 0.5 / 0.7 | 0.869 | 0.897 |
Recall@20 on each half. With no floor at all, fusion scored 0.704 on multi-hop over all ten conversations, against 0.734 for embeddings alone.
| fused against embedding | better | same | worse |
|---|---|---|---|
| multi-hop | 30 | 235 | 17 |
| temporal | 3 | 318 | 0 |
| open-domain | 10 | 80 | 2 |
| single-hop | 27 | 809 | 5 |
| all | 70 | 1,442 | 24 |
Questions, by whether fusion retrieved more of their evidence. Questions with all evidence retrieved rose from 1,238 to 1,270; with none, fell from 117 to 88.
An answer model reads the retrieved events and a judge model marks its answer. This was scored once, on 2026-06-12, on the engine before 0.1.0, with embedding retrieval, gpt-4o-mini as answerer and judge, and about 1,150 answer tokens per question. It has not been re-run on the current engine, where the retrieved context is identical for 93% of questions, and fused retrieval has no such score yet. There is no error bar.
| category | n | F1 | J |
|---|---|---|---|
| multi-hop | 282 | 0.433 | 0.762 |
| temporal | 321 | 0.586 | 0.760 |
| open-domain | 96 | 0.211 | 0.375 |
| single-hop | 841 | 0.606 | 0.851 |
| all | 1,540 | 0.546 | 0.786 |
J is the fraction of answers the judge accepted, under a published rubric used verbatim (the ACCURACY_PROMPT of the mem0 evaluation code).
| judge prompt | J, same answers |
|---|---|
| "matches in meaning" | 0.631 |
| the published rubric | 0.786 |
The same 1,540 answers under two judges. The rubric alone moves J by 15.5 points, so a J figure says little without its judge prompt. Under that rubric, answer model and judge model, the mem0 paper reports 0.669.
Where it fails: open-domain questions are the weakest category in every row above, and a caller with no embedding model retrieves 0.279 of multi-hop evidence unless it rewrites its queries.
| one tenant, 384 dimensions | 10,000 events | 100,000 events |
|---|---|---|
| batch ingest, 1,000 per request | 17,304 /s | 9,788 /s |
| single append, p50 / p99 | 16 ms / 20 ms | 16 ms / 20 ms |
| search, k=10, p50 / p99 | 2.8 ms / 6.3 ms | 35 ms / 52 ms |
| fused recall, p50 / p99 | 3.7 ms / 7.5 ms | 45 ms / 50 ms |
| forget, p50 | 20 ms | 24 ms |
| purge, p50 / max | 3.4 s / 4.5 s | 25 s / 39 s |
| cold open | 437 ms | 3.3 s |
| on disk | 27.2 MiB | 269.7 MiB |
| peak resident memory of the run | 214 MiB | 1.9 GiB |
memspine-bench --mode scale on the 0.2.1 engine, 2026-10-07, an Apple M-series laptop that was not otherwise idle. The engine is called in process, with no HTTP or JSON. Random unit vectors, eight-word texts, two short properties per event; 100 queries, 50 single appends. The sizes in between were not run. The rows bound by fsync (ingest, append, forget, purge) varied several-fold between runs on this machine while other builds were using its disk: a second run at 10,000 events gave a single append of 8.0 ms and a purge of 2.2 s. The search rows did not move.
| moment | get, list, search, recall, Cypher | text and embedding bytes on disk |
|---|---|---|
before DELETE | returned | in the tenant's log |
DELETE returns | not returned; edges gone; durable | still in the log |
| purge starts | not returned | log being rewritten; the tenant waits |
| purge ends | not returned | in no file of the tenant's directory |
A marker file is written before the delete, so a crash between the delete and the purge still purges after restart, whether or not the tenant gets another request.
$ grep -rl "912 555 019" data
data/00000000-0000-0000-0000-000000000042/events.ffs.qlog
$ curl -s -X DELETE "localhost:7777/v1/memory/event/$E9?purge=true" -H "$K"; echo
{"deleted":true,"purged":true}
$ grep -rl "912 555 019" data; echo "grep exit $?"
grep exit 1
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:7777/v1/memory/event/$E9 -H "$K"
404
$ curl -s localhost:7777/v1/memory/event/$F10 -H "$K" | jq -c '.properties'
{"source_event_ids":["af2d5cc8-2a80-583b-b7ed-941419d7a27f","3dce87e5-4b2c-4cc0-96bc-338ce95f158f"]}
$ curl -s localhost:7777/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
"MATCH (f:Event)-[:SOURCE_FROM]->(s:Event) WHERE f.event_id = $id RETURN s.kind, s.text",
"params":{"id":"'$F10'"}}' | jq -c '.rows[]'
["fact","Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291"]
| time | kind | id | text | after the forget | |
|---|---|---|---|---|---|
| 09:14:02 | turn | 75173310 | Ana: we adopted a greyhound called Pixel last weekend | ||
| 09:14:31 | turn | 9509bd23 | Ana: she needs a vet, somewhere open on Saturdays | ||
| 09:14:33 | tool_call | 697ea0fa | find_vets(city=Lisbon, open=saturday) -> Clinica Arroios, VetLuz | ||
| 09:15:10 | turn | b8533f46 | Ana: book Clinica Arroios for this Saturday at ten | ||
| 09:15:12 | tool_call | 740c7d03 | book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291 | ||
| 09:15:13 | fact | d53b6b8b | Ana owns a greyhound named Pixel | ||
| 09:15:14 | fact | b41bdb35 | Ana prefers Saturday appointments | ||
| 09:15:15 | fact | 3dce87e5 | Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291 | ||
| 09:41:50 | turn | af2d5cc8 | Ana: my number is 912 555 019, text me the day before | forgotten | |
| 09:41:52 | fact | c3d7a7af | Text Ana the day before the vet appointment | keeps the id in its properties; the edge is gone |
What remains after a purge:
source_event_ids keep its id in their properties, as the last response above shows. The edge is gone.Idempotency-Key, its id is kept, and nothing else, so that a late retry of that append stores nothing. Cypher patterns without a label see that record; match (n:Event).FFS, the storage engine, has no row or time budget for a query and cannot stop one that has started. A query is therefore screened before it runs.
| refused with a 400 | because |
|---|---|
| over 16 KB or 512 tokens | The parser and evaluator recurse without a limit. Deep nesting, or a chain such as 1+1+1+… a few thousand links long, overflowed the stack and aborted the process. |
brackets or CASE nested over 64 deep | |
UNWIND | It multiplies every row by the length of a list. Filter with IN $list. |
| a pattern sharing no variable with what is in scope | It pairs every row of one pattern with every row of the other. Two thousand events made four million rows and 2.7 GB. |
| a variable-length path over 8 hops | Path count grows with every hop. |
| parameters over 10,000 values, results over 10,000 rows | Both are held in memory whole. |
| CREATE, MERGE, SET, DELETE | Queries are read-only. Write through the event routes. |
$ curl -s localhost:7777/v1/memory/cypher -H "$K" -H "$J" \
-d '{"query":"MATCH (a:Event), (b:Event) RETURN count(*)"}'; echo
{"error":"invalid_request","detail":"the query matches two patterns that share no variable, which pairs every row of one with every row of the other. Connect them through a shared variable"}
The screen removes the cheap ways to exhaust the server. It does not bound a query's cost: a pattern through a heavily connected event can still be expensive. Give Cypher only to callers you would let run a full scan.
| keys | what is accepted |
|---|---|
| no control database | Any UUID as the bearer token. It names the tenant and has read and write access. No secret is involved; local use only. |
after memspine-admin init | Issued keys only, stored hashed, each with scopes (read, write), an optional expiry, and revocation. Created with the CLI or the admin routes; both write the audit log. If the control database later goes missing, while the server runs or across a restart, requests get a 503 and the UUID mode does not come back. |
| variable | default | meaning |
|---|---|---|
| MEMSPINE_BIND | 0.0.0.0:7777 | listener for the API |
| MEMSPINE_DATA_DIR | ./.memspine-data | one directory per tenant lives here |
| MEMSPINE_EMBEDDING_DIM | 384 | embedding dimension, fixed per process |
| MEMSPINE_ADMIN_KEY | unset | bearer for the admin routes; unset disables them |
| MEMSPINE_METRICS_BIND | unset | serve /metrics here and not on the API listener |
| MEMSPINE_PURGE_INTERVAL_SECS | 60 | how often tenants that owe a purge are compacted |
| MEMSPINE_TENANT_IDLE_TTL_SECS | 3600 | close a tenant after this long without a request |
| MEMSPINE_TENANT_MAX_IN_FLIGHT | 32 | engine calls one tenant may have running or queued |
| MEMSPINE_MAX_OPEN_TENANTS | (fd limit − 64) / 12 | tenants open at once; the least recently used idle one closes first |
| MEMSPINE_RATE_LIMIT_RPS, _BURST | 1000, 100 | per-tenant token bucket |
<data dir>/<tenant uuid>/: FFS's log and checkpoint files and a meta.json recording the embedding dimension. To back a tenant up, copy its directory while the server is stopped.SIGTERM drains requests in flight, runs purges that are owed and checkpoints open tenants. A hard kill loses nothing acknowledged; the checkpoint only shortens the next start.0.1.0, 0.2.0 and 0.2.1 were all released on 2026-10-07, and 0.2.2 a day later. After 0.2.0, two independent reviews went looking for defects, and each claim was reproduced by a second reviewer before it counted. 0.2.1 and 0.2.2 are the fixes; the last part of the review finished after 0.2.1 had gone out, and found a defect that 0.2.1 itself had introduced. The ones a caller could have met:
| defect | trigger | now |
|---|---|---|
| in 0.2.1 only: tenants damaged, acknowledged appends lost | a second server started on a data directory already in use, even with no request sent to it | the server locks its data directory and each open tenant; a second one refuses to start |
| tenant UUIDs accepted as keys | a restart while the control database was missing | the data directory records that it has a control plane; requests get a 503 |
| a tenant could not be opened again | any string over about 8 KB, then two purges | long values are stored where the engine does not index them; a tenant already in that state is rebuilt from its log on first open |
| appends acknowledged, then lost at restart | the tenant's log could not be opened for writing, for instance no file descriptors left | opening a tenant writes a record and checks the log grew |
| one query aborted the server | about a thousand nested brackets, or a long operator chain | screened on the engine's own tokens; each query runs on its own 16 MB stack |
| one query grew the server by gigabytes | UNWIND, or two unconnected patterns | refused |
| one tenant stalled the others | many slow calls inside its rate limit | 32 engine calls per tenant, then 429 |
| a retry restored a forgotten event | replaying its Idempotency-Key | the id is kept and the replay stores nothing |
| fused recall could not return a keyword match with no embedding | k of 61 or less | it scores as an embedding-only match at the same rank |
search returned fewer than k events | after a forget, until the tenant was reopened | the deleted event leaves the vector index at once |
| forgotten bytes stayed on disk | a crash before the purge, and no later request to that tenant | purges owed are found at startup and on every pass |
The whole list is in changelog.txt. Events forgotten under 0.2.0 left no record of their id, so a retry of one of those keys after upgrading still stores the event.
Tested by 162 Rust tests, 29 in the Python binding's suite and 21 in the TypeScript one. Each change runs formatting, lints with warnings denied, the tests, and a container image that is started and written through. After each publish of this site, the install command above is run on Ubuntu, AlmaLinux 8 and macOS, and the installed binaries append, recall, list and forget an event. The largest tenant measured so far is 100,000 synthetic events.