Skip to main content
Glama

axon

Per-project memory for AI agents. Axon stores small, durable facts about a codebase — its overview, architecture, decisions, conventions, gotchas — and gives them back as cheaply as possible:

  • briefing — one call at cold start returns a project primer packed to a token budget. Tell it what you are about to work on (focus, paths) and the relevant memories come first; it also lists what changed in the last 7 days.

  • search — hybrid semantic + keyword search; top hits in full, the rest as one-line index entries.

  • Linked memories — memories connect to each other like neurons. related walks the links, and links that agents actually follow get stronger over time.

Axon has an MCP server (stdio and Streamable HTTP) and a JSON REST API. Both call the same service. There is no auth: the API is scoped by project, and v1 has no users.

Quick start

docker compose up -d db        # Postgres 17 + pgvector on :54329
npm install
cp .env.example .env
npm run axon -- serve          # REST on http://127.0.0.1:7717/v1, MCP on /mcp

The first start downloads the embedding model (~600 MB) into .models/. Later starts load it in about a second.

Connect an agent

Stdio, scoped to one repository (with AXON_PROJECT set, project becomes optional on every tool):

claude mcp add axon --env AXON_PROJECT=acme/api --env DATABASE_URL=postgres://axon:axon@localhost:54329/axon -- npx tsx /path/to/axon/src/cli.ts serve --stdio

Over HTTP, sharing one running server between projects:

claude mcp add --transport http axon http://127.0.0.1:7717/mcp

Suggested line for a project's CLAUDE.md / AGENTS.md:

Project memory lives in axon (project acme/api). Call briefing before starting work, search before exploring code, and remember anything a future session would otherwise have to rediscover — one fact per memory, in English.

Related MCP server: AI Memory MCP Server

Tools

MCP tool

REST

What it does

briefing

GET /v1/projects/:project/briefing?budget=&focus=&paths=

Pinned + top-ranked memories in full, the rest as index lines, kind and tag overview, date of the last write. focus (hybrid search) or paths puts matching memories next after the pinned ones, under Relevant to focus:. Recent (7d): lists memories created, updated, superseded or archived in the last 7 days that aren't shown in full

search

GET /v1/projects/:project/memories?query=&k=&kinds=&tags=&paths=&budget=

Hybrid search; with no query, browse by rank. Adds linked neighbours of the top hits

get

GET /v1/projects/:project/memories/:id, …/memories?ids=1,2&keys=deploy/current

Full memories with their link footers, by id or key

related

GET /v1/projects/:project/memories/:id/related?hops=&budget=

Walk links up to 3 hops, nearest and strongest first

remember

POST /v1/projects/:project/memories → 201, or 409 with the near-duplicates

Save one memory; optionally links, supersedes, key

update

PATCH /v1/projects/:project/memories/:id

Edit fields, pin, archive (status: "archived"), set or clear (null) the key, confirm it still holds or dispute it

link

POST …/memories/:id/links {to, note}, DELETE …/memories/:id/links/:other

Undirected links; no self-links, same project only

review

GET /v1/projects/:project/review?budget=

Housekeeping report: likely duplicates, orphans, oversized, stale, unread and disputed memories, distance to the cap

list_projects

GET /v1/projects

Projects with memory and link counts

— (REST only)

DELETE /v1/projects/:project

Permanently delete a project with all its memories and links

— (REST only)

GET /v1/projects/:project/export?format=json|markdown&include=active|all

The whole project as one JSON or Markdown document (Export and import)

— (REST only)

POST /v1/projects/:project/import → 201

Append an exported JSON document to a project, under new ids

Project slugs are lowercase and may contain /, e.g. acme/api. Memory ids (#12) are numbered per project. REST errors look like { "error": { "code", "message" } }: 400 means invalid input, 404 not found, 409 a duplicate or a key another memory holds, and 422 a rule violation (self-link, link cap or memory cap). Export and import are for people and scripts rather than agents, so they have no MCP tool: tool definitions cost tokens in every session. Budgeted REST reads also return text, the same compact rendering that MCP clients get.

Memory kinds: overview, architecture, decision, convention, gotcha, howto, reference, state.

Keys

A mutable fact ("the current deploy is v1.4", "who owns billing") can carry a key, e.g. deploy/current: lowercase letters, digits and . _ / : -, up to 80 characters. A later remember with the same key replaces the memory holding it exactly as supersedes would — no search first — and says so (Replaced #12 (key deploy/current); its links moved to #15.). It is admitted at the memory cap, and passing supersedes for some other memory alongside the key is refused. Superseding a keyed memory without a key carries the key on to the successor. At most one active memory per project holds a key: update refuses a key another memory holds (409), and so does restoring an archived memory whose key has since been taken (clear it with key: null first). Archiving or superseding releases a key. get reads by keys as well as ids. Full renders show the key on the metadata line (key deploy/current · tags: …); index lines tag it after the title (#15 state · Current deploy [deploy/current] · 30t).

How it keeps token use low

  • Every read has a budget. The packer fits full bodies in rank order, lists the rest as index lines (#12 gotcha · Stripe webhooks need the raw body · 48t ↔2), spends any budget left over upgrading index lines to full bodies, and reports how many memories it left out. Output never exceeds the budget.

  • A briefing spends its budget on your task. With a focus or paths, matching memories go right after the pinned ones; pinned memories then stop at 50% of the budget and matches may fill up to 80%, so the matches always get at least 30% in full however many pinned or high-importance memories the project holds.

  • Output is compact plain text, not JSON, and ids are short per-project numbers.

  • Lean tool definitions. Clients see types, enums, defaults and descriptions. Length limits and patterns are enforced on the server, which cut tool definitions from 1.7K to 1.1K tokens.

  • Write hygiene. remember refuses near-duplicates, flags writes that likely overlap an existing memory, suggests links, warns about memories over 600 tokens, and warns about non-English prose (the embedder is multilingual, but English is cheaper in tokens).

  • A cap, if you want one. With AXON_MAX_MEMORIES set, a project that holds that many active memories gets 422 from remember until something is archived or superseded — superseding is allowed at the cap, since it retires one memory for the one it adds. The refusal tells the agent what to do. Unset means unlimited. From 90% of the cap, every remember warns (4,520/5,000 memories — archive or supersede what is stale; see review.).

Housekeeping

Memory stores rot quietly, so review says what needs tending, packed to a budget like any read. It lists likely duplicates (pairs of active memories at or above AXON_DEDUP_SIMILARITY − 0.1, the same floor at which remember flags likely overlaps; already-linked pairs are marked (linked)), orphans (active, unpinned, no links to active memories, oldest first), oversized memories (over 600 tokens), stale state (state memories not updated in 30 days), unread memories (created over 90 days ago and not read since; pinned ones are delivered by every briefing, so they are left out) and disputed memories (flagged with update dispute=true and neither confirmed, edited nor archived since). With AXON_MAX_MEMORIES set, it shows how close the project is to the cap, with ⚠ from 80%. Empty sections are left out, and a tidy project gets Nothing to tidy in …. The duplicate check looks up the 3 nearest neighbours of each of the first 500 active memories through the HNSW index, so a review costs at most 1,500 index lookups however large the project grows. Review is read-only: the fixes are ordinary remember supersedes=, link, update and archive calls.

Freshness

Memories age. Each one records when it was last confirmed true (confirmedAt): when it was written, when its title or body was edited (editing asserts it is true), or when an agent or a human sent update confirm=true. update dispute=true flags it as probably wrong (disputedAt) until it is confirmed or edited; the answer says how to resolve it — fix it with update, replace it with remember supersedes=<id>, or archive it. Archiving leaves both flags alone, and superseded memories can be neither confirmed nor disputed. A dispute has no free-text note, so it adds nothing for encryption to cover.

  • Ranking. A briefing scales each memory's score by its freshness: it halves every 30 days unconfirmed for state, 180 days for gotcha, howto and reference, and 365 days for everything else, but never drops below 0.4, so old facts sink without vanishing. A disputed memory's score is halved and it sorts after every undisputed memory with the same pin status. Search keeps its relevance order, so a disputed memory can still be found.

  • Markers. A disputed memory reads #12 gotcha · Stripe webhooks need the raw body ⚠ disputed · 48t, and every full memory shows confirmed YYYY-MM-DD (and disputed YYYY-MM-DD). Index lines of state memories unconfirmed for over 60 days, and gotcha memories for over 180, end with · unconfirmed 140d. A briefing with disputed memories in its pool adds ⚠ 2 disputed memories (#4, #9) — confirm, fix or archive them.

Links are undirected. The database enforces canonical a < b ordering, so there are no self-links and no duplicate pairs, and composite foreign keys prevent links across projects. Each memory can have at most 32 links.

  • Each full memory ends with a footer listing its strongest links: ↔ #12 Auth token format · #31 Session TTL (+3).

  • related is a breadth-first walk (a recursive CTE). It returns each memory once, at its shortest hop, through the strongest path, and skips archived and superseded memories.

  • Hebbian weights. Following a link with related, or reading both ends with one get, strengthens the link. Extra weight decays with a 60-day half-life, so paths agents use often come first.

  • search also lists the strongest neighbours of its top hits under Linked:.

  • Superseding a memory moves its links to the successor.

Export and import

GET /v1/projects/:project/export (or axon export <project>) returns the whole project, decrypted, in one document. It is not budgeted, and it doesn't count as a read: access counters and link strengths are left alone.

  • format=json (the default) is the canonical, importable form:

    {
      "axon": { "version": "0.1.0", "format": 1 },
      "project": "acme/api",
      "exported_at": "2026-10-26T12:00:00.000Z",
      "memories": [
        { "seq": 12, "kind": "gotcha", "title": "…", "body": "…", "tags": [], "paths": [], "pinned": false,
          "importance": 3, "status": "active", "superseded_by": null, "source": null, "key": null,
          "created_at": "…", "updated_at": "…", "confirmed_at": "…", "disputed_at": null }
      ],
      "links": [{ "a": 3, "b": 12, "note": "same webhook", "weight": 1.25 }]
    }

    Memories are in seq order; links are undirected (lower seq first), and weight is the link's current strength.

  • format=markdown is for reading or ejecting: a title line with the project, date and counts, then each memory as ## #12 gotcha · Title with its metadata as a list and its complete body, then a ## Links section.

  • include=active leaves out archived and superseded memories and every link that touches one. The default, all, is a full backup.

  • Not included: embeddings (they only mean something to the model that made them), token counts, access counters, and when links were created or last followed.

POST /v1/projects/:project/import (or axon import <project> [file], reading stdin without a file) takes the JSON document and appends it to the project, creating the project if needed. It is a restore, not a merge:

  • Every memory gets a new id, and links and superseded_by are renumbered to match. The response maps old ids to new: { "imported": 4, "links": 3, "mapping": { "1": 13, "2": 14, … } }.

  • Kind, text, tags, paths, pinning, importance, status, source, key, confirmation and dispute dates and both timestamps are kept. Links keep their note, and start at their exported strength.

  • A key that an active memory of the target already holds is a 409 and nothing is written: an import restores, it does not replace. Two active memories holding one key in the document are refused as invalid.

  • Every memory is re-embedded with the current model and its tokens recounted, so a document moves between instances running different models.

  • Near-duplicates are not refused, so importing the same file twice gives you two copies.

  • The memory cap applies to the batch: if the active memories it adds would take the project past AXON_MAX_MEMORIES, the import is refused with 422 and nothing is written. So is a memory with more links than AXON_MAX_LINKS. The import runs in one transaction, holding the project's lock against concurrent writes.

  • The document is checked before anything is written: an unknown format, a link or superseded_by naming a seq that isn't in the document, a bad kind or status, or anything over remember's limits (title 120 characters, body 16,000, 12 tags, 20 paths) is refused with 400, naming the field.

With encryption on, the export holds plaintext (any read decrypts), so treat the file like the key. An import is stored in the server's current mode: encrypted with AXON_ENCRYPTION_KEY if it is set, whatever mode the source used. To move memory between instances, export from one and import into the other; the keys never need to match.

Storage and retrieval

Everything lives in Postgres with pgvector. Memories, links and vectors are written in one transaction, and hybrid search is a single query. The vector lane uses HNSW with an iterative scan for filtered recall. The keyword lane uses tsvector with the simple config, so exact identifiers match. The two lanes are merged with reciprocal rank fusion (k = 60). Vector-only hits below AXON_SEARCH_MIN_SIMILARITY are dropped.

The embedding column is not typed with a dimension, and the HNSW index is an expression index for the configured model's dimension. So switching models means running axon reembed, not a schema migration. The database records which model built it, and the server refuses to start under a different one.

Embedding model

The default model is Qwen3-Embedding-0.6B (Apache-2.0, multilingual, 1024 dimensions, 32K context), run locally as q8 ONNX through transformers.js. It needs no API keys. On an Apple-silicon laptop CPU, an embedding takes about 40–80 ms and the model loads in about 1.2 s.

Results from npm run eval on the bundled golden set (25 memories, 35 queries):

category

n

R@1

R@5

MRR@10

paraphrase

16

94%

100%

0.969

identifier

9

100%

100%

1.000

cross-lingual (uk, de, es, ja, fr → English memories)

7

86%

100%

0.929

Qwen3's cosine scores are compressed: a paraphrase of the same fact scores about 0.88, a different fact on the same topic about 0.6, and unrelated text about 0.25–0.38. The default thresholds are set for that. After switching models (AXON_EMBED_MODEL, then axon reembed), run npm run eval again and adjust them.

Encryption

Set AXON_ENCRYPTION_KEY (32 bytes, base64: openssl rand -base64 32) and the server encrypts each memory's title, body and source with AES-256-GCM before it reaches Postgres. Every row records whether it is encrypted (memories.encrypted), so plaintext and encrypted memories can live side by side:

  • With the key set, new memories are encrypted, and editing a plaintext memory's text encrypts it. Without it, writes are plaintext.

  • axon encrypt encrypts every existing memory; axon decrypt turns them all back into plaintext (unset the key afterwards). Both can be re-run safely.

  • The database records a fingerprint of the key. The server refuses to start with a different key, or with no key while encrypted memories exist. Losing the key means losing the text.

  • Ciphertext is bound to its project and field, so it can't be copied into another project or column.

Rotating the key

axon rotate-key re-encrypts every encrypted memory from the current key to a new one in a single transaction, without ever writing the text back as plaintext. It is an offline operation: it locks the memories table and takes seconds on a normal database.

  1. Back up first — pg_dump — and keep the backup until the new key is safely stored.

  2. Generate the new key: openssl rand -base64 32.

  3. Stop every axon process: the HTTP server and any stdio MCP servers your agents have spawned.

  4. AXON_ENCRYPTION_KEY=<old> AXON_ENCRYPTION_KEY_NEW=<new> npm run axon -- rotate-key

  5. Set AXON_ENCRYPTION_KEY to the new key, unset AXON_ENCRYPTION_KEY_NEW, and start axon again.

  6. Retire the old key — but keep it as long as backups taken before the rotation still need to be readable.

Either the whole rotation commits or nothing does: an interrupted run, a wrong current key, or a row that fails to decrypt leaves every memory on the old key, so the database is never half-rotated. Re-running after a successful rotation is a no-op. A process that missed step 3 can't do damage either — it is refused at startup (wrong fingerprint) and, if it was already running, its next write to a memory's text fails rather than storing rows the new key couldn't read.

What stays in plaintext: kind, tags, paths, keys, link notes, token counts and timestamps (for filters, stats and ranking), and the embeddings. Embeddings are needed for vector search, and they do reveal roughly what a memory is about. Postgres can't index ciphertext, so the keyword lane matches encrypted memories only on their tags and paths. Their text is found by semantic search alone, so exact identifiers are harder to find.

Commands

npm run axon -- serve [--stdio]   # HTTP (REST + MCP) or MCP over stdio; migrates on start
npm run axon -- migrate
npm run axon -- reembed           # after changing AXON_EMBED_MODEL
npm run axon -- encrypt           # encrypt every memory with AXON_ENCRYPTION_KEY (decrypt: the reverse)
npm run axon -- rotate-key        # re-encrypt from AXON_ENCRYPTION_KEY to AXON_ENCRYPTION_KEY_NEW
npm run axon -- export acme/api [--format json|markdown] [--active] > acme-api.axon.json
npm run axon -- import acme/api [acme-api.axon.json]   # or pipe the document on stdin; prints the id mapping
npm run axon -- warmup            # download/load the model only
npm run eval                      # retrieval quality, in a scratch <db>_eval database
npm test                          # unit + integration tests, in a scratch <db>_test database
npm run test:coverage             # the same, with a coverage report (coverage/index.html)
npm run lint && npm run typecheck

Docker: docker build -t axon . builds a ~2 GB image with the model baked in, so containers start offline (the model loads in ~6 s under Docker Desktop on macOS). It serves on 0.0.0.0:7717 with no auth, so put it behind something that authenticates before exposing it anywhere. Every commit to main also publishes the image to Docker Hub, with a signed provenance attestation (see DEPLOY.md). DEPLOY.md runs it on Gagarin, privately or behind a bearer-token gateway.

Tested invariants

npm test runs against real Postgres + pgvector with a deterministic fake embedder:

  • Isolation — ids are per project and gapless under concurrent writes; nothing reads or links across projects.

  • Budgets — briefing (with or without a focus), search and related output never exceeds budget_tokens; pinned memories come first.

  • Retrieval — exact identifiers hit via the keyword lane; kind/tag/path filters; archived and superseded memories never appear in search, link walks or a briefing's ranked memories (only in its Recent list, marked as such).

  • Briefings — a low-importance memory matching the focus or paths is shown in full where it otherwise wouldn't be, even next to pinned memories that would fill the budget; Recent marks each change as new, updated, superseded by #N or archived, skips memories shown in full and changes older than 7 days, and replaces a memory's line under More: rather than repeating it.

  • Writes — near-duplicates are refused unless forced; failed writes leave nothing behind; updates re-embed only when text changes; superseded memories can't be revived.

  • Freshness — new memories count as confirmed when written (and migration 0004 backfills from created_at); confirm and title/body edits reset the age and clear a dispute, other edits and archiving don't; confirm and dispute together are refused, as is either on a superseded memory or on another project's id; an old state ranks below a fresh one and a disputed memory below an undisputed one, and both are marked wherever they render.

  • Keys — one active holder per key and project (in the service and a partial unique index); a keyed write replaces the holder and moves its links, also at the cap; concurrent writers of one new key form a single chain rather than colliding; taking or restoring into a held key is a 409 that writes nothing; keys never cross projects.

  • Links — no self-links and no cross-project links (checked in both the service and the database schema); undirected and idempotent; per-memory cap enforced on both ends; walks visit each memory once through cycles; traversal strengthens links and extra strength decays with a 60-day half-life; supersede moves links.

  • Model binding — a database refuses a different embedding model until reembed switches it over.

  • Encryption — encrypted rows hold no plaintext text and read back intact, mixed rows coexist, a wrong or missing key is refused, ciphertext is bound to its project and field, and encrypt/decrypt/reembed work across the whole database.

  • Key rotation — rotate-key rewrites every encrypted row and rebinds the database; a failure part-way rolls back to the old key entirely, and a server left on the old key can no longer write memory text.

  • Review — each section finds what it should and nothing else (linked and pinned memories are not orphans, pinned or recently read ones are not unread, archived and superseded ones never appear, a disputed memory is listed until it is confirmed, edited or archived); empty sections are omitted; a tidy project gets the all-clear; output never exceeds budget_tokens; a review of one project never lists another's memories, not even as half of a duplicate pair. The cap line flags ⚠ from 80%, and remember warns from 90% of the cap but not below.

  • Export and import — a JSON round trip into another project reproduces every field, link (note and strength) and supersession under remapped ids, and the copy is re-embedded and searchable; Markdown holds every complete body and the links; include=active drops retired memories and their links; an export never contains another project's memories or links, and importing leaves the source untouched; an import past the memory cap (including two racing imports) or with an invalid document writes nothing; encrypted servers export plaintext and import as ciphertext; keys, confirmation and dispute dates travel with the memory, and a key the target already holds refuses the whole import.

  • Transports — REST status codes, MCP tools over Streamable HTTP (including the default project), server-side validation of limits not advertised to clients, export, import and delete routing for slugs that contain / or end in /export or /import, the export/import CLI, DNS-rebinding protection, and /healthz failing with 503 (and no connection details in the body) when the database is unreachable.

Layout

src/core      AxonService (transport-agnostic facade) over one module per use case: briefing,
              reads, remember, writes, review, transfer; plus ranking, token packing, rendering, input schemas
src/store     PgStore — all SQL: hybrid search, link walk, Hebbian reinforcement; content codecs
              (plaintext / encrypted), database bindings, whole-database maintenance queries
src/maintenance.ts  reembed, encrypt/decrypt and rotate-key, run by the CLI
src/embed     Embedder interface, transformers.js (Qwen3) and a deterministic fake for tests
src/mcp       MCP tools over AxonService
src/http      Hono app: REST /v1 and MCP /mcp
migrations    SQL migrations
eval          golden set for `axon eval`
deploy        a bearer-token gateway for running axon on Gagarin (see DEPLOY.md)

Contributing

See CONTRIBUTING.md. Report security issues privately, as SECURITY.md describes.

License

MIT

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI coding agents to retrieve and manage code context with hybrid search, project memory, and observability via MCP tools.
    30
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI coding agents to maintain a private, local-first persistent memory by automatically saving, searching, and retrieving structured project memories through MCP, with hybrid keyword and embedding search, feedback-driven ranking, and no cloud dependency.
    2
    MIT
  • A
    license
    Not graded
    quality
    F
    maintenance
    Enables coding agents to store, retrieve, and share durable memories across projects and machines via MCP, with session-start recall and scoped, versioned storage.
    6
    MIT