Agent memory in one 8.8 MiB binary, three shared libraries, 10.7 MiB of RSS and
no mandatory daemons — roughly 1% of Hindsight's documented idle floor. No
language runtime, no database server, no install step: retain/recall/reflect with
bank isolation, FTS5 BM25 + RRF fusion and PII redaction on write, over HTTP or as
an MCP server over stdio, and over HTTP at /mcp on any running server. memory-wire daemon start runs that server in the background
if you want it to outlive your terminal — one process, the same binary, no
supervisor and no lockfile.
- 10.7 MiB idle RSS · 8.8 MiB binary · 3.9 MiB download · 0 background processes until you ask for one
- 97.2% R@5 / 83.8% R@1 LongMemEval-S · 60.0% answer accuracy vs an 8.3% closed-book floor · 100% hit-rate coding-life
- On retrieval we are roughly level to slightly behind agentmemory's hybrid once the fitted weight is accounted for. Both numbers are in Benchmarks; the reason is one sweep on one dataset.
- What the reader gets, served: every auto-injected and MCP-recalled memory
carries its occurrence date in-band (
- [2026-09-20] the deadline moved…) — the serving shape that measured +20.0pp on newest-value questions (HaluMem Dynamic Update 0.2056→0.4056,docs/CONSISTENCY.md§46), and per-bank capsules ("injectionFormat":"summary"in bank config) cut served bytes 41–43% at byte-identical ranking. - Docs:
docs/AUDIT.md(competitor teardowns) ·PLAN.md·docs/VERSIONS.md(pins) ·INSTALL_FOR_AGENTS.md(curl runbook) ·TODO.md(open work) - Agent skill:
plugin/skills/memory-wire/SKILL.md— the one file here written for a model to read rather than a person. It is a standardname/description-front-matter skill: copy it into a host's skills directory (~/.claude/skills/, and the equivalents for codex, cursor and opencode) and the agent picks up the retain/recall workflow, the bank rules and the error contract without being told them.connectdoes not install it — that command wires hooks and MCP entries only, so this is a deliberate copy. - Proof:
eval/RESULTS.md·eval/CODING_LIFE.md·eval/SCALE_SWEEP.md·eval/BENCH_*.md·eval/SOAK.md·eval/BEAM.md(pool-scale re-test) ·eval/relevance/(frozen battery) ·docs/CONSISTENCY.md(the measured record) ·docs/BENCHMARK.md
Contents — Benchmarks · Footprint · Quick start · CLI · The daemon · Install · How it works · Reproduce · Roadmap
Official LongMemEval-S, retrieval-only (no LLM judge), same methodology as the
competitors' harnesses — per-question fresh index, session-as-document, question-text
query. Regenerate: cargo run --example longmemeval → eval/RESULTS.md.
| System | R@1 | R@5 | R@10 | R@20 | NDCG@10 | MRR |
|---|---|---|---|---|---|---|
| memory-wire — shipped config | 83.8% | 97.2% | 98.6% | 99.6% | 88.2% | 89.2% |
| memory-wire — unfitted (equal weight) | not measured | 93.0% | 97.4% | 99.6% | 83.5% | 83.9% |
| agentmemory BM25-only | not reported | 86.2% | 94.6% | 98.6% | 73.0% | 71.5% |
| agentmemory BM25+Vector | not reported | 95.2% | 98.6% | 99.4% | 87.9% | 88.2% |
The first row is measured at a weight that was fitted to this set; the second is
the one to trust. overlap: 0.25 was chosen by sweeping 46 configurations
against these same 500 questions. Re-measured on 1,531 LoCoMo queries that never
chose it, the weight is worth +1.4pp R@5, not +4.2pp
(eval/LOCOMO.md) — the direction replicated, the magnitude did
not, which is what the maximum of 46 noisy draws looks like. A third re-measure
on 313 pool-scale BEAM queries (eval/BEAM.md, turns-as-documents,
docs/CONSISTENCY.md §47) moved the other way — equal weight leads the shipped
value by +3.5pp R@5 there — so the honest out-of-sample estimate of this line is
"the direction holds on one dev corpus and inverts on another, neither dominant".
Adding LoCoMo's measured delta to the clean baseline would put the estimate at
≈94.4% R@5: behind agentmemory's 95.2% hybrid, not 2.0pp ahead of it — but
BEAM says that adjustment is corpus-dependent, so the weight stays the shipped
default and changing FusionWeights::SHIPPED is a user decision, not ours.
So the honest position is decisively ahead on footprint, roughly level to
slightly behind on retrieval. Both rows ship because publishing only the first
would mean publishing only the fitted number. eval/LOCOMO.md and
docs/CONSISTENCY.md §14 have the working.
R@1 is the column that decides whether the answer is right, so it is the one to
read first. An agent that reads the first memory it gets back is correct 83.8%
of the time; one that reads five is correct 97.2% of the time. The two figures are
the same 500 questions, the same build and the same harness method
(eval/ORACLE_RERANK.md, which re-derives the pool recall builds and verifies it
row-for-row against what recall serves). R@1 is not a coverage claim: it is
the shipped ranking's own first-hit rate, and the 100.0% quoted beside it in that
artifact is an oracle — a perfect reranker over the pool this build already
assembles, computed with the gold labels. That oracle is 500/500, so the entire
+16.2pp at R@1 is ordering, not matching, and it is the largest single number in
the system. Neither competitor publishes an R@1, so the column carries one number
rather than a comparison — that is the honest shape of it.
R@1 is also the clearest statement of where the remaining work is. Overall
83.8%, but single-session-preference ranks the right session first 43.3% of
the time while its R@20 is 100.0% — an ordering collapse, invisible in every
K≥5 column. Four mechanisms aimed at it (a BM25-magnitude tie-breaker the fusion
was discarding, a recency stream, sentence-level overlap, and a temporal-query
classifier) are built, unit-tested and deliberately inert: every weight sits at
a default that leaves the shipped ranking byte-identical. They are not a feature
until a weight is chosen on the dev set — see docs/PERFORMANCE_PLAN.md.
The two streams are not equally weighted, and that is a measured result. At
equal weight the fusion scored 93.0 / 97.4 / 99.6 / 83.5 / 83.9 — behind
agentmemory's hybrid on R@5 and 4.4pp behind on NDCG@10. A 500-question sweep of
the weights (eval/SWEEP_FUSION.md, docs/NEXT_ITERATION.md Phase E1) found the
token-overlap stream was a net negative as a co-equal voter, and moved its
weight to 0.25: +4.2pp R@5, +4.8pp NDCG@10, R@20 unchanged. The gain is
concentrated exactly where the deficit was — single-session-preference R@5
56.7% → 86.7% and single-session-assistant 87.5% → 100.0%, with no category
regressing. The cost is stated in full below rather than buried. The +4.2pp is a
fitted number and did not hold its size off this dataset — on LoCoMo it is
+1.4pp. The table above reports both the clean and the fitted configuration
rather than only the flattering one.
The weight has not been shown to earn its place, and the comparison that matters
was not run until now. eval/SWEEP_FUSION.md section D measures each stream
alone: BM25 on its own scores 97.0 / 99.0 / 99.6 / 89.9 / 91.4 at
R@5 / R@10 / R@20 / NDCG@10 / MRR. Against the shipped fusion that is
R@10 +0.4, NDCG@10 +1.7, MRR +2.2, and R@5 −0.2 — three metrics better and
one worse. So the token-overlap stream buys 0.2pp of R@5 and costs NDCG@10 and
MRR against deleting it, and the independent LoCoMo set separately found
overlap: 0.00 ties 0.25 on R@1 and NDCG@10. Two datasets point the same way.
The counter-argument is real and is why the stream is still here: LoCoMo banks
hold 19–32 documents against LongMemEval's ~48 and tests/scale.rs's 5,000, so
the dev set under-measures the pool size at which a second lexical voter pays
(docs/CONSISTENCY.md 14.2). This is a live, unmade decision, not a settled
result — and the honest reading is that the burden of proof has moved.
Two later attempts to make that weight unnecessary both failed, and one of them
failed for an instructive reason (docs/NEXT_ITERATION.md Phases E3–E4).
Weighting the overlap stream by IDF did exactly what E1's diagnosis predicted —
at equal weight it scores 96.4 / 88.6 where the raw stream scores 93.0 / 83.5 —
but at the shipped 0.25 it costs 0.4pp R@5 for +1.7pp NDCG@10, so it is not
shiped. A third distinct-term-coverage stream is algebraically identical to
raising the overlap weight: for any query with no repeated token the two rank
identically, and RRF is linear in the weights, so coverage: w ≡
overlap: 0.25 + w. Both levers were measured, rejected, and then removed from
the tree — they were dormant config nobody would ever turn on. The findings live
in docs/NEXT_ITERATION.md; the sweep artifact that produced them is
eval/SWEEP_FUSION.md.
recall_any@K is a proxy. The number a user feels is whether the agent answers
correctly, so that is measured directly (eval/ANSWER_QUALITY.md): the same 25
LongMemEval questions, answered by an LLM from the retrieved context and graded
by an LLM, against a closed-book control that gets the question and no memory
at all.
| arm | accuracy | scored | prompt tokens |
|---|---|---|---|
| retrieval-conditioned | 60.0% | 25 | 315,343 |
| closed-book control | 8.3% | 24 | 150,372 |
| delta | +51.7pp |
Memory is doing real work, and the control is what makes that claim falsifiable — if the conditioned arm were at parity with the closed-book one, every ranking number above would be measuring nothing.
The single largest win in this project was a budget, not a ranking change.
DEFAULT_RECALL_BUDGET was 2,000 tokens against a 2,626-token median
LongMemEval session — so the budget could not return a median memory. The trim
truncates the top hit and skips everything below it. Raising it to 8,000
(3× the median, so three sessions fit whole) moved answer accuracy
40.0% → 60.0%.
The mechanism is visible in the artifact, and it is the reason this is a mechanism and not a coincidence:
| on this slice | before (2,000) | after (8,000) |
|---|---|---|
| gold session present in the served context | 92.0% | 92.0% — unchanged |
| … answer judged wrong despite gold served | 52.0% | 32.0% |
| … answer judged correct | 40.0% | 60.0% |
Retrieval did not improve; the answerer stopped being starved. Note that
eval/RESULTS.md cannot see this defect at all — its harness passes an
explicit 100,000-token budget, so the default is structurally invisible to it.
That is why weeks of retrieval work left a 2× gap between what recall found and
what the consumer received. tests/recall_budget.rs now asserts a gold row
survives the trim.
Caveats, stated rather than buried: n=25 with 2 row errors, so the closed-book cell is provisional under the harness's own rule; the judge is a local free-tier model, not LongMemEval's official grader; and this is a 25-question sample, not a benchmark. It sizes an effect. It does not establish a rate.
Competitor rows: agentmemory's benchmark/LONGMEMEVAL.md (same metric, same 500
questions, verified in-audit). Hindsight has no comparable retrieval number:
its one published LoCoMo result, 92.0%, is LLM-judged answer accuracy from a
rag run with two Gemini calls in the loop, and git grep -iE "ndcg|recall@| pool_size" finds nothing in the benchmark repo — so the per-pool-size recall
table in their reranker blog is not reproducible from either repo. Their retrieval
stack is 4-stream TEMPR + cross-encoder rerank behind a 0.8–1.0 GB idle RSS
(see below).
| Suite | memory-wire | Competitors |
|---|---|---|
coding-life, 15 labeled queries (eval/CODING_LIFE.md) |
hit-rate 100%, R@5 96.7%, p50 260–620 µs across release runs of this binary | grep baseline 96.7% (same harness); agentmemory publishes no score |
Scale sweep 240→10k (eval/SCALE_SWEEP.md) |
search p50 0.99→6.03 ms, token savings ≥95.7% | agentmemory: 0.1→22.8ms BM25, heap 6→316MB, savings to 100% |
Both latency ranges move 2–3x with the machine's load. eval/CODING_LIFE.md and
eval/SCALE_SWEEP.md each say so in their own text, and each artifact's
provenance line carries the load-relevant date, profile and machine. Quote them
as ranges, never as a single run.
| Dimension | memory-wire (measured) | Hindsight (their install docs) | agentmemory (their SCALE.md) |
|---|---|---|---|
| Ship artifact | 8.8 MiB binary — 9,261,472 B (LTO, incl. the MCP SDK over stdio and HTTP, the daemon and the embedded hermes plugin); 3.9 MiB gzipped (4,084,301 B), which is what you actually download | Python API image + PG/pg0 | Node 20 + iii-engine binary |
| Shared libraries it needs | 3, on linux-x86_64 — libgcc_s.so.1, libm.so.6, libc.so.6, all part of any glibc system. Dynamically linked, not static: readelf -d on the release binary lists exactly those three NEEDED entries and nothing else (a macOS build links libSystem instead, so the list is per-platform). The optional --features embed build needs 4 — it adds ld-linux-x86-64.so.2 — docs/CONSISTENCY.md §16.6 |
whole Python + psycopg/PG stack inside the image |
Node's libnode, libc, libstdc++, libm, libgcc_s, libdl, libpthread |
| Idle RSS | 10.7 MiB post-retain (10,716–11,080 kB over 8 reads; 7-round mean 10,911 kB); 9.1 MiB before the first retain | 0.8–1.0 GB full / ~100s MB slim | heap 6 MB @1k obs |
| RSS under load | 12.9 MiB (13,084–13,360 kB, 3 rounds) — 5k retains + 200 recalls over HTTP, one process, sequential | 1.2–1.5 GB full (models + ONNX arenas) | heap 316 MB @50k obs |
| Minimum box | a glibc box with glibc ≥ 2.39 — one file to copy. SQLite is compiled in (rusqlite with features = ["bundled"]), so there is no database to install and nothing to run. The floor is a real constraint and it is not cosmetic: the published linux-x86_64 asset is built on a glibc 2.44 host and refuses to start on Ubuntu 22.04 LTS (glibc 2.35) with version `GLIBC_2.39' not found. See docs/CONSISTENCY.md §25 for the measurement and the x86_64-unknown-linux-musl static build that removes the floor entirely |
1.5 GB full / 512 MB slim + external providers + separate DB | Node + engine + 4 ports |
| 10k-memories storage | 2.9 MiB (SQLite+FTS5, 3,022,848 B settled main file — 7,237,496 B while the WAL is unflushed) | Postgres + pgvector (+ reranker) | 35.7 MB (BM25+vector) |
| Background processes | 0, or 1 with memory-wire daemon start — same binary, same 10.7 MiB idle RSS, no supervisor |
API + worker + UI + DB | REST + streams + viewer + worker WS |
Hindsight rows: their docs/developer/installation.md (RAM table). agentmemory rows:
their benchmark/SCALE.md (§1 heap + storage tables).
The RAM comparison is only true against one of the two, and the basis is named rather than left implicit. Against Hindsight's 0.8–1.0 GB documented idle RSS, 10.7 MiB is 1.1–1.4% — roughly 75–95x smaller. Against agentmemory the answer depends entirely on which of their figures you take, because it is a heap at an observation count and memory-wire is an RSS at a memory count: at their 50k figure (316 MB) memory-wire is 3.4%, and at their 1k figure (6 MB heap) memory-wire is larger, not smaller. Neither row is a like-for-like measurement and the table does not pretend otherwise; the honest summary is that memory-wire is far below both on any large-bank comparison and above agentmemory's smallest published number.
How the memory-wire rows were measured, 2026-09-28, on this tree. Binary by
stat -c %s after cargo build --release --locked — re-measured 2026-09-29 at
v0.4.0 / 75928a2 and byte-identical at 8,964,464 B, so only this one figure was
re-taken and every RSS and latency number in this section still dates from the
09-28 session. Cross-checked by
eval/BENCH_FOOTPRINT.md (which reads the binary next to the harness's own path,
so it cannot read a debug build or a stale artifact). Both RSS rows by ps -o rss — really VmRSS from /proc/<pid>/status — against a serve on a scratch
--db, read after /health answers ok and then again after one HTTP retain
(idle: 8 reads, two independent procedures; under load: 3 rounds of 5,000
retains + 200 recalls, sequential). Storage is the 10,000-memory row of
eval/BENCH_FOOTPRINT.md, which measures both halves — WAL-unflushed and
post-wal_checkpoint(TRUNCATE) — on every run. eval/SCALE_SWEEP.md reads the
same corpus as 3,014,656 B for the main file; it samples mid-run while the
store holds the file open, so the ~8 KB difference is auto-checkpoint position,
not a disagreement.
These are ranges because RSS on this box is a range. Every RSS figure above
was taken on a machine under sustained load from unrelated work
(loadavg 21–48 on 24 cores), and a single reading on a loaded box is an
anecdote. Nothing here is a one-run point value except the two byte counts, which
are exact.
The deltas are interesting, and three of them moved in opposite directions.
The binary grew, not shrank: 8,836,032 B against the 8,593,368 B measured at
0.1.0, +278,864 B (+3.2%). fastembed and its ONNX runtime were removed at
0.3.0 and are back in the tree now as an optional, default-off embed
feature, so fastembed, ort, ort-sys and tokenizers are in Cargo.lock
again. Every footprint number in this section is still the default build and
is unaffected: default = [], so src/vector.rs and the weights compile only
under --features embed. The growth itself is the MCP SDK — rmcp + schemars
landed between those two measurements and cost more than fastembed ever did. The
binary was 6.4 MB before MCP; MCP is the reason the artifact is 8.6 MiB and not
smaller, and it is code, not a resident dependency, so idle RSS does not move
with it.
Idle RSS is not flat any more, and the honest form is a range. The previous
pin was 10,704 kB post-retain. Measured today over 8 reads — a 7-round shell
loop plus the committed harness's own single read, at loadavg 21–25 — it is
10,716–11,080 kB (7-round mean 10,911 kB, 10.65 MiB). The old pin sits at the
very bottom of today's band, so the claim has moved up by roughly 200 kB. That is
the read pool: four more SQLite connections' worth of page cache on a store that
already had two. Pre-retain moves the same way, 9,123 kB → 9,196–9,536 kB,
mean 9,351 kB (9.13 MiB). The pre/post gap is still the FTS5 index and the
write path's first pages, which is why the two must never be quoted
interchangeably. This is not an interleaved before/after — the pre-pool binary was
not rebuilt for this measurement — so treat the ~200 kB as "the range moved", not
as a precisely attributed delta. docs/CONSISTENCY.md §12.4 has the same record
and the honest caveat, and §11.3 has the page-cache settings that were tried and
rejected.
The 10k store shrank, for the first time. 3,022,848 B (2.88 MiB) settled main
file, −172,032 B (−5.4%) against the 3,194,880 B this table used to publish.
The cause is exact and structural: FTS5 now runs detail=none, so the index
stores no positional data. memories_fts_data went 331,776 → 159,744 B and the
whole index 434,176 → 262,144 B, −39.6%; docsize is unchanged at 94,208 B
because bm25() needs it. The migration is gated by a one-shot
fts_detail_none marker in the existing schema_markers table, so it runs once
per database and never re-runs. Separately, the store runs in WAL mode and
the settled figure is the settled one: while the process holds the file open,
10,000 memories occupy 7,237,496 B (main 3,014,656 + -wal 4,190,072 +
-shm 32,768), collapsing back to 3,022,848 B at a clean
wal_checkpoint(TRUNCATE). A server that has just been fed 10k memories and not
yet checkpointed is using 2.4x the settled figure. The main file reads smaller
in the first state than in the second because a checkpoint copies the committed
WAL frames back into it — the same reason eval/SCALE_SWEEP.md samples
3,014,656 B mid-run.
Under load. 13,084–13,360 kB (mean 13,249 kB, 12.94 MiB) for 5,000 retains +
200 recalls over HTTP, sequential, one process. The previous claim also carried
"recall p50 6 ms / p95 10 ms"; that number came from a different harness and is
not re-pinned here, because today's equivalent — 200 sequential curl
requests end to end, each a fresh process — measured 13.8–32.2 ms per
request, which is the client and the load, not recall. The store's own recall
figures are in eval/: 849 µs p50 under the 16-client soak (eval/SOAK.md),
3.1 ms at a 2k bank with 1 client (eval/BENCH_CONCURRENCY.md), and 11 ms
p50 on LongMemEval-S sessions (eval/RESULTS.md).
The quiet-machine recall-curve figures live in docs/CONSISTENCY.md §13, not in
eval/. Three bench_recall_curve rounds at load 8.36–8.61 — the only
low-load recall-curve measurement in the repo — read p50 1,015 µs at 1k,
6,129 µs at 10k, 34,999 µs at 50k and 71,331 µs at 100k, with the
100k round spread 70.5–73.8 ms against 80–172 ms on a loaded box. §13 records all
four sizes, and it records that the session was not written to eval/, so
nothing under version control carries those numbers. The committed
eval/BENCH_RECALL_CURVE.md is a different, busier run — loadavg
24.64/33.47/31.95, p50 2,164/15,172/54,500/102,450 µs — and is not
comparable to the §13 band; its retrieval columns are. Everything else above is
wall-clock and load-confounded, and is quoted as a range for that reason.
The recall-curve R@5 is no longer 100%, and that is E1's cost, not a bug.
Reweighting the fusion (below) took R@5 on that suite from 100% at every size to
96.9 / 84.4 / 90.6 / 90.6% at 1k / 10k / 50k / 100k, and coding-life R@5 from
100% to 96.7% (its hit rate is still 100%, and it now ties the grep baseline
instead of beating it). All 32 gold rows still reach BM25's top-50 at every size,
so the 200-row fusion pool is still not the binding constraint — what changed is
how the ordering of near-tied candidates is decided. Both suites measure finding
a needle whose distinguishing token is one rare word among many common ones, which
is exactly the case a raw token-count voter is good at and exactly what cutting
that voter's weight to 0.25 gives up. LongMemEval-S measures the opposite case —
multi-facet questions where BM25's own term-rarity ordering is better and a raw
count double-counts common words — and gains 4.2pp there. The measured trade-off,
including the weight that holds both (overlap: 0.75, which keeps these two
suites at 100% and still gains 1.0pp on LongMemEval), is in
docs/NEXT_ITERATION.md Phase E1. The 0.25 default is the one the project's
designated decision instrument picks; the alternative is named so the choice is
revisitable rather than buried.
The rows above are the only head-to-head retrieval numbers in this space: the
same 500 questions under the same harness method. Everything below is either
measured-by-us through a harness whose commit is pinned (and marked), or the
competitor's self-report (and marked that way instead). The rule is AGENTS.md
§12: a number that cannot be traced to a committed artifact is not a number,
it is marketing.
| memory-wire v0.8.0 | agentmemory | Hindsight (vectorize-io) | MemOS (MemTensor) | Honcho (plastic-labs) | |
|---|---|---|---|---|---|
| install | one static binary; a musl build with DT_NEEDED=0 and no glibc floor |
Node runtime + detached engine + worker | Python image + Postgres/pg0 + optional ONNX reranker | Python 3.11, Docker/Helm multi-service stack | Python/FastAPI server or managed service |
| idle footprint | ~10.7 MiB RSS · 8.8 MiB binary · 3 libs | ~6 MB heap @1k → 316 MB @50k | 0.8–1.0 GB | container stack (not measured by us) | server process (not measured by us) |
| LongMemEval-S retrieval, same protocol (measured by us) | R@5 93.0% clean unfitted · 97.2% fitted (marked) · ≈94.4% out-of-sample est · R@1 83.8% | hybrid 95.2% · BM25-only 86.2% (their harness, tuning undisclosed) | no comparable number exists — their published figures are LLM-judged answers | — | — |
LoCoMo judged accuracy (AMB rag) |
84.1% (n 1540) — free-tier small reader, 15.2k ctx, 26.5 ms retrieve | — | 92.0% — Gemini-class answerer, 36k ctx, 964 ms retrieve. Different answerer ⇒ not comparable to ours | — | self-reported 89.9% |
| PrecisionMemBench (LLM-free, AMB retrieval) | 19/77, precision 0.24, recall 0.95 — no identity machinery | — | 66/77, precision 0.85, recall 1.00 — with scope filters + identity-extraction LLM at ingest | — | — |
| self-update | memory-wire update — sha256-verified, rollback kept, live-verified on a second box |
manual | manual | container redeploy | server redeploy |
| license | MIT | MIT | MIT | Apache-2.0 | AGPL-3.0 — embedding cost for proprietary stacks |
| eval evidence discipline | 4 test-set consultations, all measurement-only; every number traceable to a committed artifact | none disclosed | — | — | — |
MemOS and Honcho publish judged numbers (MemOS a "35.24% token savings" claim;
Honcho 90.4% LongMemEval-S, 89.9% LoCoMo)
that no independent harness has reproduced; treat them as claims until run
under a common one. The audit plan that would do exactly that — plus the
mechanism-level screen of all four architectures — is
docs/COMPETITOR_GAPS_PLAN.md; the benchmark
inventory and every comparability rule lives in
docs/BENCHMARK_LANDSCAPE.md.
Where memory-wire wins, measured: the only clean head-to-head in the field (+6.8pp R@5 over agentmemory's BM25-only arm, 93.0 vs 86.2); footprint 30–100× lighter idle than either parent; ~283 µs puts and 26.5 ms end-to-end retrieve against Hindsight's 964 ms on the same mode; the only verified self-updating fleet story; MIT with no AGPL embedding cost.
Where memory-wire trails, named: agentmemory's hybrid by ≈0.8pp honest (≈94.4 vs 95.2 — the dense weight, measured to cost −9.9pp R@1 for +1.8pp coverage); Hindsight's PrecisionMemBench precision 0.24 vs 0.85, because they run an identity-extraction LLM at ingest and we deliberately do not — the gap is priced, recorded, and its lever is on the TODO register, not hidden.
cargo build --release
./target/release/memory-wire daemon start --addr 127.0.0.1:8888 --db ~/.local/share/memory-wire/agents.db
./target/release/memory-wire daemon status
curl -sS -X POST localhost:8888/banks/demo/retain -H 'Content-Type: application/json' -d '{"content":"auth uses jose"}'
curl -sS -X POST localhost:8888/banks/demo/recall -H 'Content-Type: application/json' -d '{"query":"how does auth work","budget":2000}'
# in the foreground instead, or under your own supervisor:
./target/release/memory-wire serve --addr 127.0.0.1:8888 --db ~/.local/share/memory-wire/agents.db
# or, for an MCP client, over stdio:
./target/release/memory-wire mcp --bank my-projectdaemon start returns only after /health has answered ok, so a start that
printed success is a start that worked. It detaches the server into its own
session (setsid), so closing the terminal does not take it down, and records it
in $XDG_DATA_HOME/memory-wire/serve.json for daemon stop and daemon status.
--db is optional: without it the store lands at $XDG_DATA_HOME/memory-wire/memory.db,
falling back to ~/.local/share/memory-wire/memory.db when XDG_DATA_HOME is unset
or relative. Point it at a scratch file for throwaway work —
--db /tmp/scratch.db — and nothing touches your real bank.
GET /health → ok (plain text) · POST /banks/:id/{retain,recall,reflect} ·
GET/PUT /banks/:id/config · GET /banks/:id/memories[?limit=&offset=] ·
GET/DELETE /banks/:id/memories/:mid · GET /banks/:id/stats.
Banks are isolated namespaces; any bank id is created implicitly by its first
retain, and a default bank is seeded at boot. Recall returns a bare JSON array
of content strings by default; {"format":"full"} returns {id, score, created_at, content}
objects instead (created_at is the retain's own created_at — when the memory
happened, RFC 3339 or YYYY-MM-DD, stored as UTC — or the write time if none was
given), where score is the fused RRF value the ranking actually used
(1·1/(60 + rank_bm25) + 0.25·1/(60 + rank_overlap), higher is better) rather than
a token count — so it is a fraction, and only comparable against other scores from
the same recall. Those two weights are the swept default
(docs/NEXT_ITERATION.md, Phase E1); they are compile-time constants, not
per-request options. A third coverage stream and an IDF-weighted overlap scorer
were both built, measured, rejected and removed (Phases E3–E4), so the formula
above is the whole of the shipped score.
reflect returns the top hit prefixed with its id — there is no
LLM in the loop yet.
Bounds, and the error contract. budget is a hard token cap, not a hint:
budget: 0 returns [], and a top hit longer than the budget is cut to exactly
the cap (4 chars per token) rather than dropped — with a literal …[truncated]
tail when the marker's own 12 characters fit inside the remaining room, and
unlabelled when they do not (budgets 1–3 return the top hit cut to 4–12 chars, not
[]). Lower hits that do not fit are skipped so smaller ones further down still
fill what is left. One recall never returns more than 100 memories. A blank
or whitespace bank id is a 400 invalid bank id; a blank or whitespace
content is a 400 invalid content (a memory of nothing matches no query and
cites nothing); an unknown memory id is 404 unknown memory; and every storage
failure — including raw driver text that would echo SQL fragments and on-disk
paths — collapses to an opaque 500 storage error.
Secrets are redacted before the write, on both content and context:
<private> blocks, PEM private keys, JWTs, Bearer headers, Slack tokens, Google
API keys, emails, and phone numbers.
Nine subcommands in a default build, exactly as --help reports them (help is
clap's own and is not one of them). An --features embed build has a tenth,
embed <text>, which prints one 384-d vector as JSON; it is the reason the
weights survive LTO:
| Command | What it does |
|---|---|
info |
Audit + plan pointers (the default when no command is given) |
daemon |
start / stop / status for a backgrounded server — see below |
serve |
Start the HTTP server in the foreground — --addr (default 127.0.0.1:8888), --db. Stops accepting and drains in-flight requests on SIGINT/SIGTERM |
connect |
Wire the hooks into agent hosts — optional <agent>, --uninstall, --guidelines |
hook |
Lifecycle hook the hosts invoke — session-start, prompt, stop, pre-compact, session-end |
doctor |
Endpoint, bank, store, and server health — --db, --strict |
mcp |
Serve MCP over stdio — --bank, --db |
sweep |
Delete memories past their bank's ttl_days — --dry-run, --db |
seed |
One-shot bank seeding from this repo's git history — --commits <N> (default 100, capped at 500), --transcripts, --bank, --db |
--addr defaults to loopback because there is no authentication: anything that
can reach the port can read and delete every bank. A non-loopback address still
binds, and says so once on stderr before it does.
If 8888 is already taken, serve --addr 127.0.0.1:<free port> and point your
client at that port; GET /health on it must return exactly ok. There is no
backup subcommand — a SQLite store is backed up with sqlite3 "$DB" ".backup '$DB.bak'", recipe and caveats in INSTALL_FOR_AGENTS.md.
memory-wire daemon {start,stop,status} runs serve in the background and
reports on it. It is additive: serve is byte-for-byte unchanged, and daemon start re-execs this binary with serve --addr … [--db …], detached.
It exists because of something specific to this crate. The hooks connect
installs are never-fail by contract — every failure path is silent and the
process exits 0 — because a memory server that is down must not put an error in
front of a model. That contract is right, and it has one consequence worth naming:
a stopped server is indistinguishable from a working server that had nothing to
say. Both look like silence. So the daemon is what makes the quiet hook safe
rather than merely quiet, and status is what tells the two apart.
memory-wire daemon start --addr 127.0.0.1:8888 --db ~/.local/share/memory-wire/agents.db
memory-wire daemon status # exit 0 only when a server is really answering
memory-wire daemon stop # SIGTERM, wait for the drain, remove the state fileFour properties, each of them a place the obvious implementation is wrong:
- The bind is the lock.
startattempts the bind itself, in the foreground, and refuses with the reason on your terminal and a nonzero exit before spawning anything. There is no lockfile to go stale, and no child left dying on a bind. (hindsight does the same: "No lockfile needed — port binding prevents duplicate daemons", with the check in the parent so the error is visible.) startproves it. It returns only after the endpoint has answeredok, and a failure to spawn — including a failure to detach — is a nonzero exit with the reason, never a success line for a daemon that is not running.stopreuses the signalservealready handles. SIGTERM drains in-flight requests, sostopsends it and waits; there is no second signal path. It exits 0 when nothing was running, and it never signals a process it cannot prove is its own.- A pid is not an identity. The state file records the process's start time
next to the pid, so a recycled pid is reported as somebody else's process
rather than as the daemon. The endpoint check is doctor's, reused rather than
reimplemented: 2xx and a body of exactly
ok, because any process on the port answers 200.
daemon status distinguishes five states, and the middle one is the reason the
command exists: serving; not running (no state file, or a stale one naming a
pid that is gone — the next start clears it); pid alive, not serving (the pid
exists, the endpoint does not answer — reported with both facts, not called
healthy, and not signalled by stop); corrupt (the state file does not
parse — its path is named and start says so before replacing it); and a
recycled pid, which reads as not running with the reason attached. Logs go to
$XDG_DATA_HOME/memory-wire/serve.log; state to serve.json beside it.
Both serve --addr and daemon start --addr default to 127.0.0.1:8888, and
that is the one port in the tree: paths::DEFAULT_ADDR holds the only literal,
the hooks and doctor resolve to it through paths::endpoint(), and a test reads
the --addr default back out of the built clap command so the two cannot drift
apart again. They used to disagree — both commands bound 8899 while every client
looked for 8888, so a server started with no arguments sat on a port nothing
looked for.
memory-wire connect installs the five lifecycle hooks into every agent host it
detects (claude-code, codex, copilot-cli) and the MCP server entry into the two
that speak it (cursor, opencode); pass a host to wire just that one, --uninstall
to prune, --guidelines to write the rules block into this project's agent files
instead. It is idempotent and never touches a hook it did not write — malformed
config is refused untouched rather than rewritten. (The file it writes is
re-serialised with sorted keys and two-space indent; foreign entries survive
intact, and whenever there was a file to copy, a timestamped backup of it is kept
under $XDG_DATA_HOME/memory-wire/backups/ and its path is printed.)
memory-wire connect claude-code # -> claude-code wired SessionStart, UserPromptSubmit, Stop, PreCompact, SessionEnd
memory-wire doctor --db /path/to/agents.dbhook session-start|prompt|stop|pre-compact|session-end is what those host
entries invoke. The first two print: a bank preamble plus a recall, and the
recall lines for a submitted prompt. The last three print nothing — their
value is entirely in what they retain. stop retains one compacted line naming
the transcript; pre-compact and session-end fire at the two moments a
conversation stops being available (PreCompact runs immediately before Claude
Code discards it to context compaction), so both read the transcript at
transcript_path and retain the conversation's own prose, capped at 2,000
characters, rather than a pointer to the file. All five are best-effort by
contract — a down server prints the local framing and exits 0, never an error in
front of the model. The preamble
reads the bank's background/preamble/system_prompt from
GET /banks/:id/config when that key exists — for the bank the hook resolves
from its own working directory. That bank is resolved in a fixed order:
--bank <id>, then MEMORY_WIRE_BANK, then owner/repo from the repository's
origin remote, then the git work tree's top-level name, then memory-wire.
The remote is read straight out of .git/config with no git process spawned;
https://github.com/acme/api.git, [email protected]:acme/api.git and
ssh://git@host/acme/api.git all resolve to bank acme-api. A repository with
no remote, or one whose remote names no owner/repo, falls back to the old
basename rule. Full order, and the .git-as-a-file handling for worktrees and
submodules: docs/BANK_IDENTITY.md.
Upgrading moves your bank name, and nothing migrates for you. A remote-derived
id is a different id from the basename that produced the same memories before,
so an existing bank stays where it is and new sessions write to the new name.
doctor warns when it sees that case — the derived name differs from the old
one and a bank of the old name exists — and names both, but it stays
read-only and moves nothing. Keep reaching the old memories with
memory-wire hook session-start --bank <old> or MEMORY_WIRE_BANK=<old>. There
is deliberately no automatic migration and no alias: a wrong guess moves
memories between namespaces on the strength of a directory name, silently, and
that is worse than a name you have to type.
doctor is one screen: endpoint, bank, store size and health, bank/memory row
counts, server state. server up means /health answered 2xx and the body
was exactly ok, so a foreign process on the port cannot pass for the server.
--strict exits nonzero when the server or store is unusable, and a store whose
pages fail PRAGMA integrity_check is reported unreadable rather than counted.
It never creates the store it reports on — pointing --db at a nonexistent path
reports missing without creating the file — and on an existing store it folds
the WAL into the main file on the way past (best effort, PASSIVE, never
blocking), which is what makes a cp taken straight afterwards a usable backup.
Point it at the same file you gave serve --db, or the report describes a store
the server never touches.
- Page index — every active doc and the question it owns.
- Architecture — what memory-wire is made of, written for a reader who will not read the code.
GET/PUT /banks/:id/config is a whole-object store-and-serve: unknown keys
(retain_mission, recallPromptPreamble, anything a future version adds) come
back byte-for-byte, and a malformed value in a key this build does act on falls
back to the default rather than taking recall down.
curl -sS -X PUT localhost:8888/banks/demo/config -H 'Content-Type: application/json' \
-d '{"recallMaxTokens":128,"retainTags":["ops"],"retain_mission":"own the release"}'Three keys change behavior. recallMaxTokens is the bank's default recall budget —
a recall that omits budget uses it, an explicit budget still wins.
retainTags is added to every later retain in that bank, after the request's own
tags, so a bank-wide default can never displace a tag you named.
injectionFormat picks what this bank's auto-injections serve: "full" (the
default — whole memories, date-prefixed) or "summary" (H8 capsules: first line,
sentence-cut at 200 chars, with the fetch command when anything was hidden). The
bank is the more specific scope, so it beats MEMORY_WIRE_FORMAT in the
environment; an unrecognized value reads as unset, never an error. On the frozen
relevance battery, capsules cut served bytes 41–43% at identical ranking
(eval/relevance/BATTERY-2026-10-16-frozen.txt).
Tags work without any config: retain accepts {"content", "tags"} and recall
accepts {"query", "tags"} to restrict the search to memories carrying any of
them (normalized, deduped, capped at 20; the cap keeps the head, preserving your
ordering). recall also accepts exclude_tags — the additive inverse: rows
carrying any of those tags are dropped from the candidate set before ranking.
Absent, the field is byte-identical to before it existed. The auto-injection
surfaces (extension, opencode plugin, hooks, the hermes provider) send
["transcript", "marker"] so imported raw transcripts and bare session markers
never land in a system prompt, while an explicit memory_recall still sees
everything.
Memory-wire deletes nothing on a timer. There is no scheduler — with or without
the daemon, no background process decides when a memory expires — so a bank that
wants to forget says so in its own config, and memory-wire sweep is what carries
that out:
curl -sS -X PUT localhost:8888/banks/scratch/config -H 'Content-Type: application/json' \
-d '{"ttl_days":30}'
memory-wire sweep --dry-run # what would go, and how much
memory-wire sweep # actually forget itttl_days is the retention window in days; absent means nothing ever
expires, so forgetting is off until a bank asks for it and every other bank is
listed as skipped and never touched. There is no way to switch forgetting on
globally, and no way to make it happen without running the command — a policy
nobody asked for is a policy that must not fire.
A sweep deletes every memory in a bank whose created_at is older than
now - ttl_days; one created exactly at the cutoff survives. Deleting the row is
all it takes: the memories_ad trigger retires its FTS entry and the tags
cascade, so the index can never cite a memory that is gone. Each bank is one
transaction, so a bank that fails leaves every other bank swept and nothing half
done. Output is per-bank, then a total, and the exit code is 0:
memory-wire sweep
demo ttl 7d cutoff 2026-09-20T13:52:46.190Z would delete 3
scratch skipped (no ttl_days)
total 3 would be deleted
--dry-run is the same arithmetic with the delete left out. A value this build
cannot use — a string, a negative, a window no date can reach — reads as no
policy rather than failing the write, because a typo must not be able to make
forgetting happen (or stop a config from being stored at all). The column that
carries the policy is added by migration, so a store written by an earlier
version opens, reads, and sweeps exactly as it was.
The same four tools are served two ways. memory-wire mcp is stdio —
JSON-RPC 2.0, newline-delimited, stdout only for protocol, logs to stderr.
--bank sets the default bank, falling back to $MEMORY_WIRE_BANK and then
to memory-wire; a per-call bank argument overrides both.
A running memory-wire serve also answers MCP over HTTP at /mcp and at
/mcp/{bank}, which is what hosts that speak url-based MCP need — Codex
among them. On /mcp the bank comes from the tool argument exactly as on
stdio. On /mcp/{bank} the URL pins it: a call that also supplies a
different bank is rejected with a tool error rather than quietly landing in
the bank the caller did not name. memory-wire connect codex writes the
[mcp_servers.memory-wire] entry with a url.
The HTTP endpoint is as unauthenticated as the rest of the server, and rmcp
additionally refuses any request whose Host header is not loopback — so
binding beyond 127.0.0.1 is not enough on its own to expose it.
| Tool | Writes? | Arguments |
|---|---|---|
memory_retain |
yes | content (required), bank?, context?, tags? |
memory_recall |
no | query (required), bank?, budget?, tags?, exclude_tags? |
memory_reflect |
no | query (required), bank? |
memory_bank_config_get |
no | bank? |
Every tool declares destructiveHint: false; memory_retain is the only one
without readOnlyHint: true — a repeated retain of the same content in the same
bank writes nothing and returns the id that already holds it, but a repeated
retain of different content is a second memory. Each tool is a thin adapter onto
the same MemoryService methods
the HTTP routes call — retrieval, redaction, and budgeting are not reimplemented
for MCP, so the two surfaces cannot drift. An unknown tool name is a JSON-RPC
MethodNotFound; a tool that runs and fails returns isError: true with the same
opaque message the HTTP surface returns, never a crash.
{ "mcpServers": { "memory-wire": {
"command": "memory-wire", "args": ["mcp", "--bank", "my-project"] } } }Bank-in-path scoping (/mcp/{bank}) is built for HTTP, and connect writes it
for Codex — the one host whose MCP entry is a url rather than an argv, so the
URL is the only place the bank can live. The rule is the same one that mode needs:
a bank named in the URL is pinned, and a call that also supplies a different one
is refused rather than silently served.
serve → SQLite store (bank-isolated) + seeded `default` bank
retain → ensure bank exists → redact_pii(content, context) → put
(+ tags ∪ bank retainTags); byte-identical content already in the
bank returns that row's id, no second copy
document_id set → supersede that document's prior row in the same tx
created_at set → the memory's occurrence time (UTC-normalized;
unparseable → 400); absent → now
recall → candidate pool: newest 200 rows ∪ the BM25 hits (bounded, so a
recall never reads the whole bank) → FTS5 BM25 (LIMIT 50 in SQL)
+ overlap rank (cap 200) → weighted RRF k=60 (bm25 1.0,
overlap 0.25 — swept, `eval/SWEEP_FUSION.md`)
→ token-budget trim (truncate, never drop) → take 100
budget precedence: request > bank recallMaxTokens > 2000
format=full → {id, score, created_at, content} instead of bare strings,
score = the fused RRF value
reflect → recall(2000).first() cited by id
memories → SELECT … LIMIT ? OFFSET ? (50/0 default, 500 cap) → {id, content, created_at}
stats → COUNT(*) + COUNT(DISTINCT tag) + MIN/MAX(created_at) for the bank
mcp → same four calls, over JSON-RPC on stdio
document_id turns retain into an upsert: with the default
"update_mode":"replace", each call deletes that document's previous row and
inserts the new one inside one transaction, so a reader never sees the new
revision beside the old tag set and a failure never leaves the document with no
revision at all. Three replaces under one document_id leave exactly one row,
holding the last content. tags are replaced wholesale on each write, so a
revision does not inherit the previous revision's tags. The third field,
"update_mode":"append", only ever adds a memory — it never extends a row — so
it is not usable for repeated writes: a second append under a document_id
that already holds a row is refused with 409 document already exists; use update_mode=replace and the first row survives, unchanged. Treat append as
single-use per document id; if you are accumulating revisions, replace is the
mode that works today. Neither field appears in any response — a document id is a
write-side handle only, so keep it yourself.
Library in 0.3.0: src/{api,store,recall,capture,embed,lib,memory}.rs —
capture.rs supplies the redaction filter. Binary-side modules (connect,
daemon, doctor, guidelines, hooks, http, mcp, paths, seed,
sweep) live in
src/main.rs; the HTTP surface is eight route groups (/health, and
/{retain,recall,reflect,config,memories,memories/:mid,stats} under /banks/:id)
plus four MCP tools.
There is no consolidation ladder in this build, and no vector stream in a default build. What a default build ships is FTS5 BM25 plus token-overlap, fused by RRF, and nothing else is reachable from a request.
The embed cargo feature — off by default, default = [] — does add a third
stream: src/vector.rs brings an embedder, the memory_vectors column and a
dense branch in the query path, over ~23 MB of vendored int8 MiniLM weights in
models/. It changes no ranking, because FusionWeights::vector ships at 0.0
in every build, so none of it is compiled unless you ask for it. Every footprint
and latency figure in this README is a default-build figure and is unaffected.
src/embed.rs remains the dependency-free cosine/rank kernel.
Why it ships off, which is a measurement and not a preference. A coordinate
descent over the vector weight on the 1,531-query LoCoMo dev set
(eval/SELECTION_VECTOR_AXIS_FIXED.md) swept vector across 0.00 → 1.50:
vector |
R@1 | R@5 | R@20 | NDCG@10 | up/down |
|---|---|---|---|---|---|
| 0.00 | 60.4% | 86.0% | 98.0% | 73.6% | — |
| 0.10 | 60.8% | 86.7% | 98.0% | 74.2% | 18/6 |
| 0.25 | 57.2% | 87.4% | 98.3% | 73.0% | 34/12 |
| 0.50 | 53.4% | 87.1% | 98.5% | 71.1% | 58/40 |
| 1.50 | 45.4% | 78.2% | 99.2% | 64.2% | 97/216 |
The arm is reordering-only: R@20 rises monotonically while R@1 falls,
which is the signature of ranking, not retrieval — and the dense arm alone
reaches less than the lexical fusion (R@pool 100%, R@5 62.4%). Its best
honest contribution is +1.4pp R@5 at 0.25, with NDCG@10 and MRR moving
backward; at 1.50 the up/down ratio inverts as deep results survive and
shallow ones die. That does not justify +53.8 MB, a fourth shared library and
a cold start, so the weight stays at 0.0.
There is a real second reading, and it is not the one that flatters the table:
NDCG@10 and MRR both peak at 0.10, where R@1, NDCG@10 and MRR improve
together (60.8 / 74.2 / 72.3) for 18 up and 6 down. If a caller reads the top
two results — which is roughly what a token budget allows — 0.10 beats 0.25 on
every rank metric. Whether to enable it is a product decision about how many
results a caller reads, and it is left unmade rather than made here.
0.00 and 0.25 help disjoint question sets (Jaccard 0.43), so this is a
genuine choice, not a stronger-vs-weaker version of one setting.
What the embed build costs, measured rather than estimated. Building it
(cargo build --release --features embed) produces a 65,007,536 B binary
against the default build's 8,874,128 B on that same v0.4.0 build — the
vendored weights plus ONNX
Runtime, and nothing else. Two facts about it are worth stating because both were
assumed the other way:
- It needs no C++ shared library. ONNX Runtime normally drags in
libstdc++.so.6, which would add a fourth dependency the default build does not have. Abuild.rsresolveslibstdc++.awithcc -print-file-nameand links it into rustc's existing-Wl,-Bstaticgroup, gated on#[cfg(feature = "embed")]and a*-linux-gnutarget;--as-neededthen declines to record aDT_NEEDEDfor it at all. A plaincargo build --release --features embedneeds no env var, noRUSTFLAGSand no.cargo/config.toml. - It runs on the same minimal root as the default build. A
bwraproot holding onlyld-linux-x86-64.so.2,libc.so.6,libm.so.6andlibgcc_s.so.1— and deliberately nolibstdc++.so.6— runs--versionand performs real ONNX inference there, not just the version print.
The full record, including the two rejected alternatives (crt-static, which works
but cannot be scoped to one feature because cargo has no per-feature rustflags, and
-static-libstdc++, which is a 0-byte no-op because rustc links through
gcc-ld/lld), is docs/CONSISTENCY.md §16.
Lifecycle ops are routes now — custody, not relevance. recall answers "what is
relevant"; these answer "what did I actually store, and how do I take it back":
GET /banks/:id/memories pages a bank (?limit= default 50, capped at 500;
?offset= default 0) and GET/DELETE /banks/:id/memories/:mid fetch or drop one
by id. Every memory those routes serve carries created_at (RFC 3339 UTC,
stamped at insert), and context appears only when the memory actually has one —
so a memory stored without capture context does not claim an empty one. A missing
id is 404 unknown memory; deleting an already-deleted one is 200 with
{"deleted":false}, which makes the delete idempotent rather than an error.
GET /banks/:id/stats returns {memories, tags, oldest, newest} for the bank.
The unknown-bank rule. An operation that names one resource answers 404
when that resource is absent, so a typo cannot read back as a plausible empty
answer: GET/PUT /banks/:id/config on a bank that was never created is
404 unknown bank, and GET /banks/:id/memories/:mid is 404 unknown memory. An
operation that reads a collection or an aggregate answers 200 with the empty
answer, because banks are created implicitly by their first retain and a
pre-retain recall is normal, not exceptional: GET .../memories → [],
GET .../stats → zeros and nulls, POST .../recall → [], POST .../reflect →
"no relevant memories". POST .../retain creates the bank it names, and
DELETE .../memories/:mid stays idempotent — 200 with {"deleted":false}.
One corollary: PUT /banks/:id/config requires an existing bank and will not
create one, so configure a bank after its first retain.
tests/{e2e,daemon,scale,backup,recall_budget}.rs · examples/{longmemeval,coding_life,scale_sweep,soak,bench_footprint,bench_write,bench_recall_curve,bench_concurrency,bench_coldstart,sweep_fusion,oracle_rerank}.rs.
./eval/download.sh # official LongMemEval-S, 264 MB, once
cargo run --release --example longmemeval -- --data eval/data/longmemeval_s_cleaned.json --n 500 --out-md eval/RESULTS.md
cargo run --release --example coding_life --out-md eval/CODING_LIFE.md
cargo run --release --example scale_sweep --out-md eval/SCALE_SWEEP.md
cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings--out-md has no eval/ default, on purpose. A bare
cargo run --release --example … writes its markdown artifact to a scratch file
under $TMPDIR and prints where; updating a committed eval/*.md means naming
it. Those files are the reviewed record of what was measured, and a single run
silently replacing one has already destroyed real content here twice — a curated
methodology note in RESULTS.md, and a benchmark artifact whose header then
disagreed with the build. One rule, shared by every harness.
The five bench_* harnesses, soak, and the two analysis harnesses are
measurement, not CI:
cargo run --release --example bench_footprint --out-md eval/BENCH_FOOTPRINT.md
cargo run --release --example bench_write --out-md eval/BENCH_WRITE.md
cargo run --release --example bench_recall_curve --out-md eval/BENCH_RECALL_CURVE.md
cargo run --release --example bench_concurrency --out-md eval/BENCH_CONCURRENCY.md
cargo run --release --example bench_coldstart --out-md eval/BENCH_COLDSTART.md
cargo run --release --example soak --out-md eval/SOAK.md
# 500-question weight grid; ~7 min index build per full run
cargo run --release --example sweep_fusion --out-md eval/SWEEP_FUSION.md
# oracle-rerank ceiling: where the first gold row actually sits in the pool recall builds
cargo run --release --example oracle_rerank \
--data eval/data/longmemeval_s_cleaned.json --out-md eval/ORACLE_RERANK.mdeval/SWEEP_FUSION.md is read, not regenerated. It carries the E3/E4 idf
and coverage grid rows (its sections F and G) and those arms were removed from
src/ in 3db88b4 — examples/sweep_fusion.rs no longer contains them — so a
re-run would silently drop the evidence for the removal. Leave it alone.
The eval artifacts are generated from --release, the profile that ships; a
default-profile run reports the same retrieval metrics but roughly 2–5× the
latency, so quote the profile with any latency number. Each artifact's own
header carries its date, profile and machine, and wall-clock latency is only as
good as the load the box was under when it ran — read that header before
quoting a number.
curl -fsSL https://raw.githubusercontent.com/ishan-parihar/memory-wire/main/install/get-memory-wire.sh | sh
# sh get-memory-wire.sh --db <path> # echoes the serve line with that --db
# sh get-memory-wire.sh --bank <id> # pins that bank into the hooks and MCP entries
# sh get-memory-wire.sh --daemon # also start the server and run doctor
# sh get-memory-wire.sh --hosts a,b # wire only these hosts, never prompts
# sh get-memory-wire.sh --no-connect # install the binary only, wire nothing
# sh get-memory-wire.sh --uninstall # unwires the hosts, removes the binary, keeps the dbDetects linux/macos × x86_64/aarch64 and refuses anything else;
MW_REPO, MW_VERSION, MW_INSTALL_DIR, and MW_LOCAL_ASSET override the
defaults, which resolve to this repo and its latest published release
(v0.6.3, assets memory-wire-linux-x86_64.tar.gz,
memory-wire-linux-aarch64.tar.gz and memory-wire-linux-x86_64-musl.tar.gz;
no macOS asset — libsqlite3-sys links CoreFoundation, so those must be built
on a Mac). MW_LOCAL_ASSET takes either a single .tar.gz or a directory
mirroring the release layout, which is how the fallback below is exercised
offline.
On linux-x86_64 the installer probes what it downloaded and falls back to the
static musl asset if the glibc build cannot start. The linux-x86_64 asset is
built on glibc 2.44 and needs ≥ 2.39, so on an older host it downloads,
verifies, installs, and dies on first run with GLIBC_2.39 not found — having
printed success. The installer now runs --version against the staged copy
before it is allowed anywhere near the install path, and on failure tries
memory-wire-linux-x86_64-musl.tar.gz, which is static and has no floor. If
every candidate fails the error names the glibc requirement and both candidates.
The gnu asset is still tried first on every platform, so a healthy host takes
exactly the path it always did. Measured on racknerd (Debian 12, glibc 2.36):
the probe rejected the gnu asset with the real GLIBC_2.39 message and the
install completed on musl. docs/CONSISTENCY.md §29 has the record.
The published release is v0.6.3. It was published 2026-10-07T23:48:51Z
from a local build on this machine, not by GitHub Actions. Cargo.toml says
0.6.3; the tag and the tree drift apart whenever docs land after a cut, so
check with git log v0.6.3..HEAD --oneline | wc -l. Nothing described in this
README's embed paragraphs is in any published release: those shipped the
lexical arm only, with the embed feature absent, and the dense arm still ships
at weight 0.0. The tree-versus-release position is in docs/CONSISTENCY.md §15.2
and docs/VERSIONS.md §0.
The installer wires your agent hosts for you, and lets you choose which. It
first runs memory-wire connect --list, which reports every supported host with
whether it is present on this machine and whether it is already wired — read-only,
touching nothing. Claude Code reads partial when its hooks are in place but the MCP
entry in ~/.claude.json is missing; re-running memory-wire connect claude-code
adds it. Then:
--hosts claude-code,codexwires exactly that list. This is the non-interactive path and it never prompts. Use it from a script or an agent.- On a terminal, with no flag, it shows the detected hosts as a numbered menu
and waits. Enter alone accepts all detected hosts;
2,4orcodex,cursorselects a subset. - On a non-terminal with no flag, it does not prompt — it wires all detected hosts and prints the list. An unattended install can never hang.
--no-connectskips wiring entirely.
Caveat for bank-pinned installs: the wiring step runs connect with no
--bank, and connect preserves the bank baked into each host's existing
entries (since 2026-10-15) — a pinned machine re-wires with its pin intact, so
--no-connect is only for installs that genuinely want nothing wired.
Wiring writes the lifecycle hooks, the MCP entry, or both: claude-code,
codex, and copilot-cli get the five hooks, cursor gets the MCP server,
opencode gets the MCP server and, since v0.6.3, a native plugin
(~/.config/opencode/plugins/memory-wire.ts) that injects a recall block into
each turn's system prompt and retains the finished turn at idle — a
forked-session test showed a continuation prompt makes zero memory_recall
tool calls, so MCP-only left the tool uncalled exactly when it was needed — and
omp and pi get the extension, the same injection-and-retention surface as a
harness plugin. hermes gets a MemoryProvider plugin when named explicitly.
connect --bank <id> bakes the bank into the extension, the plugin, and the MCP
entry, so every surface of one install reads one namespace. Wiring is idempotent,
keeps a timestamped backup of every file it edits, refuses a malformed config
untouched, and never overwrites a hook it did not write. connect --uninstall
undoes it. The MCP entry points at the installed binary by absolute path, so it
survives a cargo clean of a development checkout.
Every auto-injection surface emits the same bounded block (since v0.7.0).
Whatever injects — hooks, extension, plugin, hermes provider — the block is:
harness <system-reminder>/<system-notice> envelopes stripped from the
capture before anything is queried or retained; a short prompt (≤160 chars)
borrows the previous turn's answer into its recall query, so a "Continue" ranks
the work being continued; at most 5 entries, each clamped to 400 characters, the
whole block under 2000; a provenance label (recalled by relevance, unverified)
so a best-of-irrelevant set cannot read as a verified one; and rows tagged
transcript or marker excluded from injection while remaining reachable by an
explicit memory_recall. The measured before/after on a live bank: a bare
"Continue" through the hook surface cost 4848 bytes of injected context and now
costs ~1450; a session-start preamble cost 6074 and now costs ~2265 with the same
recall attached. The full audit and the gap register are in
docs/RETRIEVAL_EFFICACY_AUDIT.md; the labelled battery that measures this is
eval/relevance/.
Downloads are verified against the .sha256 the release publishes next to each
asset; a mismatch aborts the install rather than installing a corrupt binary.
Release assets are cut by scripts/build-release.sh, not by CI. GitHub
Actions minutes are metered and exhaustible, and a release you cannot cut because
the quota is spent is a release blocker. The script builds the three Linux targets
(linux-x86_64, linux-x86_64-musl, linux-aarch64) on one machine — plain
cargo for the host target and for musl, whose built-in static CRT makes a
static-pie binary with DT_NEEDED=0 and no glibc floor; cargo-zigbuild for
the aarch64 cross — writes a .sha256 beside each asset, and publishes with
gh release — a REST call that consumes no Actions minutes. It skips the
macOS targets and says why: libsqlite3-sys links CoreFoundation, so a macOS
binary needs Apple's SDK and cannot be produced from Linux. Rather than publish
a partial set silently it refuses, and --only makes a deliberate subset an
explicit choice:
rustup target add aarch64-unknown-linux-gnu
cargo install cargo-zigbuild # supplies libc headers per target
./scripts/build-release.sh --check # build + verify, publish nothing
./scripts/build-release.sh --upload v0.5.0 # build, then create/update the release
./scripts/build-release.sh --only "linux-x86_64 linux-aarch64" # deliberate subsetIt refuses to upload a partial set, and refuses to upload from a dirty tree
unless you stash deliberately — a v0.4.1 asset built from uncommitted work is
unreproducible and worse than no asset. .github/workflows/release.yml does the
same thing on hosted runners and is still useful if you have quota; the script is
the floor under it.
Until either path ran, only linux-x86_64 had ever been uploaded, so the
installer failed at the download step on ARM Linux and on both Macs.
Agent-facing runbook with per-step assertions: INSTALL_FOR_AGENTS.md.
Shipped: GET/PUT /banks/:id/config (merged into the serve router) · the
connect [HOSTS]... / hook / doctor / seed / update CLI subcommands ·
doctor --all-hosts with per-host wiring and hook-liveness findings · the MCP stdio
server (rmcp, 4 tools, +1.6 MB binary) · tag filtering on retain and recall ·
the lifecycle routes (GET .../memories, GET/DELETE .../memories/:mid,
GET .../stats) with created_at on every served memory · format: "full" on
recall · document_id upsert on retain (replace works; a repeated append is a
409) · dedup-on-retain on the content hash (a repeated retain of the same
content returns the existing id; document-scoped retains are exempt so the
append/replace split is untouched) · a bounded recall candidate window, so recall
cost no longer grows with the bank · per-bank ttl_days plus the explicit
memory-wire sweep that enforces it (off by default, no scheduler) · a loopback
--addr default for serve, with a one-line stderr warning on any other bind ·
graceful shutdown on SIGINT/SIGTERM, draining in-flight requests · the
daemon {start,stop,status} lifecycle: a detached backgrounded server, the port
as the duplicate-daemon defence, and a status that can tell a stopped server from
a silent one · memory-wire update: newest-release check over gh, sha256-
verified download, atomic swap keeping <exe>.old, daemon restart, re-connect
with pins preserved · six agent hosts wired (claude-code, codex, copilot-cli,
cursor, opencode, hermes), claude-code and copilot-cli with HTTP MCP entries,
install shapes pinned by golden tests.
Not built yet: a 4-tier consolidation ladder (promote/evict/retention had no
scheduler and no caller; the code was removed rather than left as a model of
behaviour that does not run) · vectors at retain + stored embeddings +
cross-encoder rerank, which would close the −2.2pp to agentmemory hybrid (the
embed feature and its ONNX dependency are back in the tree, default-off and
inert — FusionWeights::vector ships at 0.0, so none of it is reachable from a
request in a default build; the cosine/rank kernel is likewise unused) · a DELETE for a whole bank (one memory at a time only) · tags in
the lifecycle responses (they serve {content, created_at, id[, context]}; tag
filtering stays on recall) · document_id visible in any response · a backup
subcommand (the sqlite3 .backup recipe is the interface) · Postgres/
pgvector backend · LLM-backed reflect synthesis · an OmniMemEval provider for
the HaluMem/PersonaMem v2 corpora (docs/COMPETITOR_GAPS_PLAN.md §3). Gaps that
need live LLMs/providers (Hindsight system-evals, agentmemory
quality/real-embeddings) are tracked in docs/BENCHMARK.md.
Clone, then turn on the secret gates once:
git config core.hooksPath .githooksWithout it nothing checks what you commit. Credentials never belong in a file, a
commit message or a memory row. docs/SECRETS.md lists what is
checked where, and what to do if a credential leaks.
Project rule: delegate only with the space-bunny model.
The agent-facing surface is plugin/skills/memory-wire/SKILL.md.
It is the only file in this repo addressed to a model rather than a reader: where the README argues
the benchmark is worth believing, the skill just says when to retain, when to recall, what budget
means and which errors are worth acting on. Copy it into a host's skills directory to install it;
nothing in this crate writes it for you.