Persistent, cross-runtime, typed memory for AI agents — local, offline, no cloud.
Tool 10 in the UnityInFlow ecosystem.
- crates.io:
agent-memory-mcp(installs theagent-memoryexecutable) - MCP Registry name:
mcp-name: io.github.UnityInFlow/agent-memory
AI agents forget everything between sessions. agent-memory is a small
MCP stdio server backed by an embedded SQLite
database: any MCP-speaking runtime can store, search, list, update, and forget
structured memories, and every runtime pointed at the same database file shares the same
memory — so a decision recorded by one agent is recalled by the next, across
sessions and across tools.
- Local & offline. Embedded SQLite, zero accounts, zero cloud. Your memory never leaves your machine.
- Typed memories. Six categories —
DECISION,PATTERN,ERROR,TODO,ARCHITECTURE,CONSTRAINT— so retrieval is structured, not a soup of text. - Decay, not deletion. Memories fade in ranking over time (exponential decay;
high-value pinned types fade slower) but are never deleted by decay. The only
ways a memory is removed are an explicit TTL expiry or
memory_forget. - Search by meaning and by keyword. Hybrid search when local Ollama embeddings are available: a keyword ranking and a semantic ranking are fused, so an exact-term hit and a paraphrase both surface. Automatic fallback to SQLite FTS5 keyword ranking when Ollama isn't there, and both modes are blended with decay. No Ollama, no network? Everything still works.
brew install unityinflow/tap/agent-memoryDownload the tarball for your platform from the latest release:
| Platform | Asset |
|---|---|
| macOS arm64 (Apple Silicon) | agent-memory-aarch64-apple-darwin.tar.gz |
| macOS x86_64 (Intel) | agent-memory-x86_64-apple-darwin.tar.gz |
| Linux x86_64 (gnu) | agent-memory-x86_64-unknown-linux-gnu.tar.gz |
| Linux aarch64 (gnu) | agent-memory-aarch64-unknown-linux-gnu.tar.gz |
| Linux x86_64 (musl, static) | agent-memory-x86_64-unknown-linux-musl.tar.gz (when available) |
| Linux aarch64 (musl, static) | agent-memory-aarch64-unknown-linux-musl.tar.gz (when available) |
Verify the checksum against SHA256SUMS.txt (shipped with every release),
then extract:
shasum -a 256 -c SHA256SUMS.txt --ignore-missing # (sha256sum on Linux)
tar -xzf agent-memory-<triple>.tar.gz
./agent-memory --versionRequires a stable Rust toolchain. The crate is agent-memory-mcp (the name
agent-memory on crates.io belongs to an unrelated project); the executable it
installs is still agent-memory:
cargo install agent-memory-mcp --locked
agent-memory --versionRequires a stable Rust toolchain (edition 2021).
git clone https://github.com/UnityInFlow/agent-memory
cd agent-memory
cargo build --release
# binary at target/release/agent-memory# Serve the MCP stdio server (creates the DB on first run).
agent-memory serve
# Choose the database file (default: your OS data dir; honors $AGENT_MEMORY_DB).
agent-memory --db /path/to/memory.db serve
AGENT_MEMORY_DB=/path/to/memory.db agent-memory serve
# Move the memory elsewhere.
agent-memory export --output memories.jsonl # portable JSONL export (see Export & import)The server speaks JSON-RPC over stdout; all logs go to stderr, so stdout stays a pure protocol channel.
A new agent-memory version may need to migrate the database schema. It does this automatically the first time it opens the file, and backs the database up first.
- Pre-migration backup. Before any schema migration, the database is copied
with SQLite's online backup to
memory.db.backup-pre-NNNN-YYYYMMDD, next tomemory.db.NNNNis the schema version being migrated to andYYYYMMDDis the UTC date. A brand-new database has nothing to back up. - One backup per target schema version. Re-running the same upgrade replaces that version's backup atomically. The new copy is written to a temporary file, renamed into place, and only then is the older copy removed. Backups made for other schema versions are never touched.
- No backup, no migration. If the backup cannot be written (for example a full disk or a read-only directory), the server refuses to start and the database is not migrated.
- Starting several servers at once is safe. If an MCP server and the REST daemon open an older database at the same moment, both start. The schema is migrated once, and the surviving backup is always a genuine pre-migration copy.
Restoring a backup:
- Stop every agent-memory process that uses the database: each MCP server and the REST daemon.
- Copy the backup over the database:
cp memory.db.backup-pre-0003-20261023 memory.db. - Delete
memory.db-walandmemory.db-shmif they exist. They belong to the database you replaced. - Start the older agent-memory binary that matches the backup's schema.
A newer database meets an older binary. A database written by a newer agent-memory is refused at startup, and the file is left untouched. The error names both schema versions:
database schema is version 4 but this binary supports up to version 3 — upgrade agent-memory (brew upgrade unityinflow/tap/agent-memory) or point --db at a different file
Point every runtime at one database via AGENT_MEMORY_DB and they share memory:
{
"mcpServers": {
"agent-memory": {
"command": "agent-memory",
"args": ["serve"],
"env": {
"AGENT_MEMORY_DB": "/Users/you/.agent-memory/memory.db"
}
}
}
}Drop the same block into each agent runtime's MCP config; because they open the
same AGENT_MEMORY_DB file (WAL-mode SQLite with a serialized writer), a memory
stored in one is immediately retrievable in another.
| Tool | Purpose | Validated parameters |
|---|---|---|
memory_store |
Persist a typed memory; returns its id. | ttl_secs: optional integer, valid range 1–3155760000 (~100 years); omitted = no expiry |
memory_search |
Hybrid (keyword + semantic, rank-fused) search when Ollama is reachable, FTS5 keyword otherwise — weighted by decay, and only the returned results are recency-bumped. Accepts plain natural-language queries (any punctuation) as well as FTS5 syntax. | limit: optional integer, valid range 1–200; omitted defaults to 50. tag: exact tag match (case-sensitive). expand_links: optional boolean, default false |
memory_list |
List memories newest-first, with optional type/tag/scope filters. | limit: optional integer, valid range 1–200; omitted = unlimited, required when expand_links is true. tag: exact tag match (case-sensitive). expand_links: optional boolean, default false |
memory_update |
Patch a memory in place by id — content, tags, scope, TTL (clean not-found for unknown ids). | content: never null or empty. ttl_secs: 1–3155760000 when set; null = no expiry. At least one field besides id |
memory_link |
Link two memories with a typed, directed relation (idempotent; clean not-found for unknown ids). | kind: one of relates_to, supersedes, caused_by. from_id ≠ to_id |
memory_unlink |
Remove a relation (clean not-found when no such edge exists). | kind: one of relates_to, supersedes, caused_by |
memory_forget |
Delete a memory by id (clean not-found for unknown ids). Its relations go with it. | — |
Out-of-bounds values are rejected as client errors (invalid_params over MCP)
with a message naming the field, the valid range, and the offending value —
e.g. invalid argument: limit must be between 1 and 200 (got 0). They are
never silently clamped and never produce an internal error.
{
"content": "We chose io.github.unityinflow as the Maven group.",
"type": "DECISION",
"tags": ["maven", "release"],
"scope": "kore-runtime",
"ttl_secs": null
}content and type are required; tags, source, scope, and ttl_secs are
optional. A non-null ttl_secs makes the memory expire that many seconds after it
is stored — the background sweep then removes it. A present ttl_secs must be
between 1 and 3155760000 (~100 years); anything else is rejected with the range
in the error message.
{ "query": "maven group", "type": "DECISION", "limit": 10 }Returns an envelope carrying the mode actually used plus the ranked results,
each with a freshly-recomputed decay_score:
{ "search_mode": "hybrid", "results": [ ... ] }search_mode |
When | How results are ranked |
|---|---|---|
"hybrid" |
Ollama is reachable | Two rankings are fused with Reciprocal Rank Fusion: the semantic (embedding KNN) ranking and the keyword (FTS5 bm25) ranking, each oversampled to 4 × limit. A memory at rank r in a ranking scores 1 / (60 + r) (k = 60), and a memory found by both rankings sums both scores, so agreement wins: 2nd in both beats 1st in one. The fused score is then multiplied by the memory's decay score, once, so at equal fused rank the fresher memory wins and pinned types keep their slower fade. |
"keyword" |
Ollama is absent or unreachable | FTS5 bm25 relevance blended with decay — the v0.0.1 path, unchanged. |
The envelope is the same {search_mode, results} shape in both modes. A query
that matches nothing returns an empty list, never an error, and an empty hybrid
answer is still reported as "hybrid". A present limit must be between 1 and
200 (omitted defaults to 50); a tag filter matches whole tags exactly and
case-sensitively.
- Plain language or FTS5 syntax — a query is never rejected. The raw query
runs through FTS5 first, so valid FTS5 expressions (
sqlite AND vec,"exact phrase",vec*) keep their exact meaning. A query FTS5 cannot parse — a question mark, a comma or apostrophe, a hyphen or colon (sqlite-vec,TODO: auth), an unbalanced"— is retried once with its words quoted and OR-ed together, sowhat vector store do we use?searches forwhat,vector,store, … A query with no words at all (empty,?!,*) contributes no keyword matches: hybrid answers from the semantic ranking alone and keyword mode returns an empty list. This holds identically in both modes, so whether Ollama is up never changes how a query is treated. - Only returned results are recency-bumped. The candidates fusion looked at
but did not return, and any
expand_linksneighbors, are not touched, so a search never changes how unseen memories decay. - Filters and the semantic ranking. The semantic ranking applies
type/tag/scopefilters after its 4 ×limitcandidate cut, so a heavily filtered hybrid query can recall fewer thanlimitresults. The keyword ranking filters before its cut.
{ "type": "TODO", "scope": "kore-runtime", "limit": 20 }A present limit must be between 1 and 200 (omitted returns everything,
except that expand_links: true requires a limit); a tag filter matches
an exact tag (case-sensitive).
Both memory_search and memory_list accept "expand_links": true to attach
each result's linked memories — see Relations.
{ "id": 42 }Returns { "id": 42, "deleted": true }, or deleted: false for an unknown id.
Patch an existing memory in place — its id never changes. The arguments
are a JSON merge-patch: each field you send is applied, each field you omit is
left alone, and an explicit null clears the field where clearing is legal.
{ "id": 42, "content": "We chose io.github.unityinflow as the Maven group (Sonatype-verified).", "ttl_secs": null }| Field | Omitted | null |
A value |
|---|---|---|---|
content |
unchanged | rejected (invalid_params) |
replaced, then re-indexed and re-embedded |
tags |
unchanged | cleared to [] |
replaced (the whole list) |
scope |
unchanged | reset to global (null) |
replaced |
ttl_secs |
unchanged (the existing expiry stays) | expiry removed — the memory never expires | a new TTL counted from now (1–3155760000) |
type and source cannot be changed; to retype a memory, store a new one and
forget the old. A patch with no field besides id, empty or whitespace-only
content, or an out-of-bounds ttl_secs is rejected as invalid_params
before anything is written.
Unknown fields are rejected too, never silently ignored: a patch carrying
type, source or a misspelled field such as ttl_sec fails as a whole and
writes nothing. Over MCP it is a tool error result (isError: true) whose text
reads failed to deserialize parameters: unknown field ... and names the
field; over REST it is HTTP 400 with the same message in the error body.
On success the result is the full updated memory — the same fields memory_list
returns — plus embedding_status:
{ "id": 42, "content": "We chose io.github.unityinflow as the Maven group (Sonatype-verified).",
"type": "DECISION", "tags": ["maven", "release"], "scope": "kore-runtime",
"decay_score": 1.0, "created_at": 1767225600, "last_accessed": 1767312000,
"embedding_status": 1 }- New content changes the meaning, everywhere. The keyword index and the embedding are replaced in the same transaction, so a search for the old meaning stops matching the memory and the new meaning finds it.
embedding_statusis1when the stored vector matches the current content and0when a re-embed is pending — the update succeeded while Ollama was unreachable, and the background sweep re-embeds it once Ollama is back. Until then the semantic ranking cannot match it by meaning; the keyword ranking (in hybrid and keyword mode alike) already sees the new content.- An update counts as a use. It refreshes
last_accessed(and the access count), so an edited memory ranks as current rather than decayed. - Expired but not yet swept still counts as present. An update to a memory
whose TTL has already passed, but which the background sweep
has not yet removed, still succeeds, just as
memory_listandmemory_searchstill show it until the sweep runs. Attl_secspatch can extend or clear its expiry.
For an unknown id the result is { "id": 42, "updated": false, "reason": "not_found" }
— a normal tool result, not an error.
Connect memories with flat, typed relations — no graph model, just one edge
from_id -[kind]-> to_id per relation:
kind |
Reads as | Seen from to_id (expansion) |
|---|---|---|
relates_to |
from_id relates to to_id (symmetric "see also") |
relates_to |
supersedes |
from_id replaces the older to_id |
superseded_by |
caused_by |
from_id was caused by to_id |
caused |
{ "from_id": 57, "to_id": 42, "kind": "supersedes" }memory_link returns { "from_id": 57, "to_id": 42, "kind": "supersedes", "linked": true }.
- Idempotent. Linking the same
(from_id, to_id, kind)again is a no-op success — safe to retry. A differentkindbetween the same pair is a separate relation.relates_tois symmetric, so linkingb → aaftera → bis the same relation and also a no-op success.supersedesandcaused_byare directed:a → bandb → aare two relations. - Responses echo your request.
memory_link/memory_unlink(and the REST link bodies) always report thefrom_idandto_idyou sent, even though arelates_torelation is stored as (smaller id, larger id). - Rejected as
invalid_params: a self-link (from_id and to_id must differ (got 57)) and an unknown kind (kind must be one of relates_to, supersedes, caused_by (got 'blocks')). - Unknown ids are a clean not-found:
{ ..., "linked": false, "reason": "not_found", "missing_id": 42 }names the id that does not exist. Nothing is written.
memory_unlink takes the same three fields. For supersedes and caused_by,
direction and kind must match the stored edge; a relates_to relation is
symmetric and can be removed from either end. It returns "unlinked": true,
or "unlinked": false, "reason": "not_found" when no such edge exists.
Relations never outlive their memories. Forgetting a memory, or its TTL expiring, removes every relation touching it in the same transaction.
Expansion. Pass "expand_links": true to memory_search or
memory_list and every result gains a related list of trimmed 1-hop
summaries, following relations in both directions:
{ "id": 42, "content": "...", "type": "DECISION", "...": "...",
"related": [
{ "id": 57, "kind": "superseded_by", "direction": "in",
"type": "DECISION", "snippet": "Maven group is io.github.unityinflow (Sonatype-verified).", "tags": ["maven"] }
] }directionisoutwhen the result is the relation'sfrom_idandinwhen it is theto_id;kindreads naturally from the result's side (the table above). Forrelates_tothe reporteddirectioncarries no meaning: the relation is symmetric and stored with the smaller id first.snippetis the neighbor's content cut to 160 characters, with a trailing…when cut. Fetch the neighbor by id if you need all of it. The cut counts characters, not visual glyphs, so it may split a combined emoji or accent sequence.- At most
MAX_EXPANDED_LINKS = 10neighbors per result; when a memory has more, the most recently created relations win. memory_listwithexpand_linksrequires alimit(1–200). Without one the call is rejected as a client error (HTTP 400 / MCPinvalid_params) whose message tells you to pass alimit, so an expanded response is always bounded at 200 results × 10 neighbors.memory_searchis bounded by its ownlimit(default 50). Withoutexpand_links, an omittedlimitstill lists every memory.- Expansion is strictly one hop and a pure read: neighbors are not recency-bumped, so expanding never changes how anything decays.
- A result with no relations gets
"related": []. Without the flag there is norelatedmember at all — the response is exactly the v0.0.1 shape.
| Bound | Value | Applies to |
|---|---|---|
limit |
1–200 (MAX_LIMIT) |
memory_search (default 50), memory_list (default unlimited; required with expand_links) |
ttl_secs |
1–3155760000 (~100 years) | memory_store, memory_update |
MAX_EXPANDED_LINKS |
10 neighbors per result, most recent first | expand_links on search and list |
| snippet length | 160 characters (+ … when cut) |
related[].snippet |
Out of the box, search uses SQLite FTS5 keyword ranking — fully offline, no extra software. To rank by meaning instead, run a local Ollama and pull the embedding model:
ollama pull nomic-embed-textThat's the whole setup. When Ollama is reachable, stores embed content
automatically and searches run in hybrid mode: vector KNN (cosine) and FTS5
keyword rankings fused with RRF, then weighted by decay
(see memory_search). Point
at a non-default Ollama with either:
agent-memory --ollama-url http://localhost:11434 serve
AGENT_MEMORY_OLLAMA_URL=http://localhost:11434 agent-memory serveGraceful fallback, always. If Ollama is stopped, absent, or the model isn't pulled:
- stores still succeed — content is queued (
embedding_status = 0) and backfilled by the background sweep once Ollama returns; - searches transparently fall back to the FTS5 keyword path and report it via
"search_mode": "keyword"— the same envelope and the same ranking as v0.0.1; - nothing errors, nothing blocks. The tool works fully offline without Ollama.
For non-MCP integrations, the same store and logic are exposed over HTTP:
agent-memory serve-rest # binds 127.0.0.1:7437 by default
agent-memory serve-rest --addr 127.0.0.1:8080 # custom loopback bind| Method | Path | Purpose | Validated parameters |
|---|---|---|---|
POST |
/api/memories |
Store a memory (same fields as memory_store) |
ttl_secs: 1–3155760000 when present; omitted = no expiry |
GET |
/api/memories |
List, with optional type/tag/scope/limit/expand_links query params |
limit: 1–200 when present; omitted = unlimited, but required with expand_links=true (400 without one). tag: exact tag (case-sensitive). expand_links=true attaches related summaries |
POST |
/api/search |
Search — returns the same {search_mode, results} envelope |
limit: 1–200 when present; omitted defaults to 50. tag: exact tag (case-sensitive). "expand_links": true attaches related summaries |
PATCH |
/api/memories/{id} |
Update in place — JSON merge-patch body with the same fields and semantics as memory_update; 200 returns the updated memory with embedding_status |
as memory_update; 404 {"id", "updated": false, "reason": "not_found"} for unknown ids |
DELETE |
/api/memories/{id} |
Forget by id (404 with a structured body for unknown ids) | — |
POST |
/api/links |
Link — JSON body {from_id, to_id, kind}, same semantics as memory_link; 200 (also for a duplicate) |
400 for a self-link, an unknown kind or a bad body; 404 {..., "linked": false, "reason": "not_found", "missing_id"} for unknown ids |
DELETE |
/api/links/{from_id}/{to_id}/{kind} |
Unlink — same semantics as memory_unlink |
400 for an unknown kind; 404 {..., "unlinked": false, "reason": "not_found"} when no such edge exists |
GET |
/health |
Liveness + embedder reachability status | — |
Out-of-bounds limit/ttl_secs values return HTTP 400 with a JSON body
whose error field names the field, range, and offending value — shaped
invalid argument: limit must be between 1 and 200 (got 0) — never a 500 and
never a silent clamp. The MCP transport rejects the same inputs identically as
invalid_params, because validation lives once at the shared service seam.
GET /api/memories?expand_links=true without a limit is also a 400
(invalid argument: limit is required when expand_links is true; pass a limit between 1 and 200), and MCP memory_list rejects the same call as
invalid_params.
Malformed input that never reaches the seam gets the same 400 {"error": ...}
body on these routes only:
- request body on
PATCH /api/memories/{id}andPOST /api/links: not valid JSON, a missing or wrongly typed field, or noContent-Type: application/json(and, onPATCHonly, an unknown field); - path segment on
PATCH/DELETE /api/memories/{id}andDELETE /api/links/{from_id}/{to_id}/{kind}, e.g./api/memories/abc; - query parameter on
GET /api/memories, e.g.?limit=abcor?expand_links=1.
The two v0.0.1 body routes, POST /api/memories and POST /api/search, keep
axum's default body rejection: a plain-text (not JSON) response with 422
for a missing or wrongly typed field, 400 for malformed JSON, and 415 for a
missing Content-Type. A body that does deserialize is validated as above, so
an out-of-bounds limit/ttl_secs or an unknown type on those routes is
still a 400 {"error"}. A storage failure on any request the daemon serves —
and on any CLI import/export — reports a generic internal error (HTTP 500
/ MCP internal_error) shaped
<operation> failed due to a storage error (details are in the server log);
the engine detail (SQLite error text, table and column names) goes only to
the server's stderr log.
curl -X PATCH localhost:7437/api/memories/42 \
-H 'content-type: application/json' \
-d '{"tags": ["maven"], "ttl_secs": null}'
curl -X POST localhost:7437/api/links \
-H 'content-type: application/json' \
-d '{"from_id": 57, "to_id": 42, "kind": "supersedes"}'
curl 'localhost:7437/api/memories?type=DECISION&expand_links=true&limit=20'The REST daemon and the MCP stdio server can run concurrently against the same database file (WAL-mode SQLite with a serialized writer).
Security: the API is unauthenticated and binds loopback-only by
default. Binding a non-loopback address requires the explicit
--allow-remote flag, and the server logs a loud warning naming the exposure
when you do. Never expose the REST API publicly — anyone who can reach it
can read and delete every memory. If you need remote access, keep it behind a
VPN/SSH tunnel or an authenticating reverse proxy.
Four deliberate contract changes from v0.0.1 (all apply identically to MCP and REST):
- Tag filtering is now an exact, case-sensitive match instead of a
substring match —
tag=rustno longer matches a memory taggedrustling, andRustdoes not matchrust. This applies to search (hybrid and keyword paths) and list. - Out-of-bounds
limitandttl_secsnow return client errors (HTTP 400 / MCPinvalid_params) carrying the valid range, instead of being silently clamped or accepted. Valid boundaries (limit1–200,ttl_secs1–3155760000) and omitted values behave exactly as before. search_mode: "semantic"is no longer emitted. When the embedder is available, searches are now hybrid: keyword and semantic rankings are fused with RRF (k = 60) and decay-weighted, reported as"search_mode": "hybrid"."keyword"(embedder down) is unchanged, as is the envelope shape. Two side effects:- Queries FTS5 cannot parse are now answered in both modes. With Ollama
down, v0.0.1 rejected punctuated queries (bare
?,,,', an unbalanced") with HTTP 400 /invalid_params, and failed hyphenated or colon-bearing queries (sqlite-vec,TODO: auth) with HTTP 500 /internal_error. v0.1.0 answers both: the raw query runs first, and only if FTS5 cannot parse it is it retried once as its words OR-ed together. - Exact keyword hits now surface alongside paraphrase matches, so result order can differ from v0.0.1's embedding-only ranking.
- Queries FTS5 cannot parse are now answered in both modes. With Ollama
down, v0.0.1 rejected punctuated queries (bare
- Storage faults on every surface now report a generic message —
<operation> failed due to a storage error (details are in the server log)— instead of echoing the storage engine's text. v0.0.1 returnedsqlite error: …strings (with table and column names) on non-search 500s /internal_errors; v0.1.0 redactsstore,list(including/health),update,link/unlink,forget,importand the sweep exactly as it already did forsearch. Clients that parsed those strings must stop — the detail is in the server's stderr log.
Bootstrap your memory from an existing GSD project's state:
agent-memory import --from gsd-state .planning/STATE.md| STATE.md section | Imported as |
|---|---|
### Decisions bullets |
DECISION |
### Blockers/Concerns bullets |
CONSTRAINT |
### Pending Todos bullets |
TODO |
## Deferred Items table rows |
TODO (tagged deferred) |
Every imported memory carries source = "gsd-state", the gsd tag, and a
scope (the project directory containing .planning by default; override with
--scope). Re-running the same import is a no-op: unchanged items are
deduplicated on the exact (source, type, content) key and reported as skipped.
Move an agent's whole memory to another machine — or keep a copy that outlives a change of embedding model — with one portable JSONL file. No cloud, no account:
agent-memory export > memories.jsonl # to stdout
agent-memory export --output memories.jsonl # to a private file (see below)
agent-memory import --from jsonl memories.jsonl # on the other machineBoth are CLI subcommands only — there is no MCP tool and no REST route for
either. A file-path read on the unauthenticated REST daemon would be an
arbitrary-file-read primitive, and the whole corpus must never cross the MCP
server's JSON-RPC stdout channel. The validation and error contract still live
in the core library, so any later transport would inherit them unchanged.
--scope is not accepted with --from jsonl: every memory in the file carries
its own scope.
A header line, then one record per memory, then one record per link. Every row in the database is exported — no scope or type filter, and expired rows are included (the importer decides what to skip).
What does not travel: embedding vectors, decay_score, base_weight and
embedding_status. They are derived data: the score is recomputed on read, the
weight is a function of type, and vectors are pinned to the exporting
machine's model — so they are re-created locally on import, against the
importing database's own model pin (see Embeddings below).
The file is JSON Lines: one JSON object per line, UTF-8, \n-terminated. Each
object carries a record tag, and the tag set is closed (header, memory,
link). Fields appear in the wire order below, so two exports of an unchanged
database under the same clock are byte-identical. The authoritative definition
is the export module of agent-memory-core; this section restates it.
Header (line 1, mandatory):
| field | type | meaning |
|---|---|---|
record |
"header" |
tag |
format |
string | always agent-memory-export |
version |
integer | 1 for files this version writes |
exported_at |
integer | unix seconds at export time |
embedding_model |
string | the source database's embedding-model pin |
embedding_dim |
integer | the source database's embedding-dimension pin |
schema_version |
integer | the source database's schema version |
agent_memory_version |
string | the exporting binary's version |
exported_rows |
integer | number of memory records that follow |
exported_links |
integer | number of link records that follow |
Memory (one per row):
| field | type | meaning |
|---|---|---|
record |
"memory" |
tag |
key |
integer | the source row id, used only as an export-local key that link records refer to — it never becomes an id on import |
type |
string | DECISION, PATTERN, ERROR, TODO, ARCHITECTURE or CONSTRAINT |
content |
string | the memory text, verbatim |
tags |
array of strings | verbatim |
source |
string or null |
provenance, verbatim |
scope |
string or null |
verbatim |
created_at |
integer | unix seconds; preserved on import |
last_accessed |
integer | unix seconds; refreshed to import time on import |
access_count |
integer | preserved on import (provenance only) |
expires_at |
integer or null |
absolute unix seconds; preserved on import |
Link (one per relation edge, after all memories):
| field | type | meaning |
|---|---|---|
record |
"link" |
tag |
from |
integer | key of the source memory |
to |
integer | key of the target memory |
kind |
string | relates_to, supersedes or caused_by |
created_at |
integer | unix seconds; preserved on import |
Memories are written in ascending key order and links ordered by
(from, to, kind). relates_to endpoints appear exactly as stored — smaller
key first; directed kinds keep the direction they were given. Optional fields
(source, scope, expires_at) are always written explicitly as null.
The gsd-state importer is tolerant, because a STATE.md is a human-edited
file. A JSONL export is agent-memory's own contract, so any defect means a
corrupted or hand-edited file — and a half-restored corpus is worse than a
clear refusal. The whole file is validated first; any defect rejects all
of it, and nothing is written (the target database is not even opened):
- a missing or invalid header, a
formatother thanagent-memory-export, aversionthat is not a positive integer, a second header; - a line that is not a JSON object, an unknown
recordtag, an unknown field (a typo, or a field from a newer format), a wrong field type ("1"is not an integer); - an invalid
typeorkind, empty content, a negativeaccess_count, a duplicatekey, a self-link, a link to akeythat does not appear in the file; - a record count that does not match the header's
exported_rows/exported_links.
The message names the 1-based line, for example
invalid argument: line 42: unknown variant memo, expected one of header, memory, link``.
A hand-written file may omit an optional field (source, `scope`,
`expires_at`), which reads as `null`; expired rows are not malformed (see
Timestamps).
Version policy. A file whose version is greater than 1 is refused with
file is format version N but this binary reads up to version 1 — upgrade agent-memory.
An older binary never silently reads a newer format.
Import works into an empty or an already populated database. Each memory is
deduplicated on the exact (source, type, content) key, with source kept
verbatim — so re-running the same import is a no-op reported as skipped
duplicates, and two source rows identical on that key collapse into one
(counted as a duplicate). Links are remapped through the file's own keys to
the target database's ids: a pre-existing row never gains an edge it did not
have, even when its id happens to equal a key in the file.
| column | on import |
|---|---|
created_at |
preserved (provenance) |
expires_at |
preserved; a row already expired at import time (expires_at <= now) is skipped and counted, never inserted |
last_accessed |
refreshed to import time, so restored memories start decay-fresh instead of ranking as months-stale |
access_count |
preserved (provenance only — it does not affect ranking) |
link created_at |
preserved (edge recency orders relation expansion) |
If the header's exported_at is more than an hour ahead of the local clock, a
clock-skew warning is logged and the import continues.
The import never contacts Ollama. Every imported row lands pending
embedding, and the existing lifecycle sweep re-embeds it locally against the
importing database's model pin — 128 rows per tick, the first tick at
serve/serve-rest start and then hourly. Until that backfill catches up, a
large restore is keyword-searchable only; nothing is lost, and vectors from two
different models are never mixed in one database.
A successful import prints two lines on stdout:
imported: 42, skipped duplicates: 3, skipped expired: 1, links imported: 17, links skipped duplicates: 0, links dropped: 2
42 rows pending embedding — the sweep backfills them on the next serve/serve-rest start (128 per tick)
| count | meaning |
|---|---|
imported |
memories inserted |
skipped duplicates |
memories already present on the (source, type, content) key |
skipped expired |
memories whose expires_at had already passed |
links imported |
edges created |
links skipped duplicates |
edges that already existed between the same two rows |
links dropped |
edges whose endpoint was skipped as expired or whose two endpoints collapsed into one row — not an error |
export reports exported N memories and M links on stderr, because
stdout may be the file.
export --output PATH never writes PATH directly. It creates
PATH.export-tmp-<pid> in PATH's own directory with mode 0600 on Unix — the
corpus is sensitive, and the mode is applied at creation, so there is no
world-readable instant — streams into it, syncs, and renames it over PATH in
one step. An existing PATH is replaced; an interrupted export never leaves a
partial PATH behind. On Windows the file follows its directory's permissions
(the Windows ACL story ships with the Windows binaries).
Without --output the file goes to stdout — handy for piping, but a terminal
scrollback or a shell log may keep a copy.
For a same-machine, full-fidelity copy — vectors included — stop every agent-memory process and use SQLite's own backup:
sqlite3 memory.db "VACUUM INTO 'memory-backup.db'"or keep the automatic pre-migration backup described under Upgrading. A raw
database copy pins the schema version and the embedding model, so it is a
backup, not an interchange format. Use export when the memory must move
machines or survive a change of embedding model.
A background task sweeps on an interval while the server runs:
- TTL expiry — rows whose
expires_atis in the past are deleted. - Decay materialization — every survivor's
decay_scoreis recomputed so ranking stays cheap between reads. Pinned types (DECISION,ARCHITECTURE,CONSTRAINT) decay markedly slower. - Embedding backfill — memories stored while Ollama was unreachable are
embedded in batches once it comes back. A memory that is edited or deleted
while its backfill is in flight is never overwritten with a vector of its
old text: that write is skipped, and the memory's current content is
embedded on a later sweep (or by the edit itself, when Ollama is up). Rows
restored by
agent-memory import --from jsonltake this path too.
Decay never deletes. A memory with no TTL stays retrievable forever, however
low its decay score falls — it simply ranks lower. Removal happens only via TTL or
memory_forget.
Reads and updates both count as use: a search result is recency-bumped when it
is returned, and memory_update resets the memory's decay clock. A TTL can be
added, replaced (counted from the time of the update) or removed with
memory_update.
cargo test --workspace # unit + integration (incl. stdout-purity)
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --check
cargo llvm-cov --workspace --fail-under-lines 80 # >80% coverage gate (CI-enforced)CI runs fmt-check, clippy -D warnings, the full test suite, and the coverage gate
on GitHub-hosted runners (secretless, read-only token). See CONTRIBUTING.md.
MIT © Jiří Hermann