Skip to main content
Glama

Synapto

CI Python 3.11+ License: MIT PyPI version

Your AI agent forgets everything between sessions. Synapto fixes that.

Flat-file memory (MEMORY.md) doesn't scale — no search, no structure, no decay. Synapto gives any MCP-compatible agent a real memory: store once, recall by meaning, watch bad memories fade and good ones persist.

# remember — while working inside ~/src/acme/api
"acme/api publishes events through an outbox table, never directly from the request handler"

# recall — weeks later, different session, same repository
"How does acme/api publish events?"
→ [stable] acme/api publishes events through an outbox table ... (score=0.94, trust=0.65)

Works with Claude Code, Cursor, Windsurf, Codex, LangGraph, Agno, or any MCP client. Each repository gets its own memory partition automatically: the tenant is derived from the working directory's git remote, so acme/api never sees acme/web's notes unless you ask.

Cross-agent handoffs

Pass work between Codex, Claude Code, Cursor, and other agents in plain language. Synapto stores the structured state under the hood, so the next agent can continue from a memory ID instead of a long pasted brief.

You → Codex: Plan this feature and leave a handoff for Claude to implement.
Codex → You: Handoff created for Claude: b0e1506e-d1b7-4bee-9223-4d0f8d18a1b2

You → Claude: Continue from Synapto handoff b0e1506e-d1b7-4bee-9223-4d0f8d18a1b2.
Claude → You: I read the handoff, fetched its context, and can continue.

What you say

What Synapto does

"Codex, leave this for Claude."

Looks the task's packet up by metadata_filter; extends it, or stores a project / handoff memory with metadata.kind = "handoff" when none exists.

"Claude, continue from this handoff ID."

Fetches the full memory with get_memory and verifies the metadata.

"Any handoffs for me?"

Looks packets up by metadata_filter on kind, to_agent and status — an exact match, not a ranked search.

"Mark it ready for review."

Extends the same packet with update_memory(append=…, metadata_patch={"status": …}); one packet per task_id.

See Cross-agent handoffs for the lifecycle, schema, and Claude/Cursor recipes.

Related MCP server: mcp-memory

Try it in 60 seconds

Docker:

git clone https://github.com/ramonlimaramos/synapto.git && cd synapto
docker compose up -d
docker compose exec synapto synapto search "hello world"

Local:

pip install synapto
createdb synapto && psql -d synapto -c "CREATE EXTENSION vector;"
synapto init
synapto search "hello world"

What it does

Search — Ask a question, get the best memory. Behind the scenes, three signals (vector similarity, full-text, and compositional algebra) are fused into one score. You just call recall.

Scopes — A memory can declare where it applies: repo:acme/api, language:python, skill:code-review, global:all. recall(scopes=[...]) returns only what applies to the context you are in, and a metadata_filter narrows further ("every finding with failure_class = missing_docstring", or every memory whose products list contains inbox, with a true total, not a page size). Scopes gate, metadata describes: a scope is a condition the reader must name, a metadata facet is a fact about the memory that never hides it from an unrelated query.

Provenance — Every memory records who wrote it: human, agent, or consolidation. Recall can filter by origin, and forget refuses to delete a human-authored memory unless told explicitly.

Graph — Entities are auto-extracted and linked. Ask "what depends on Kafka?" and get an answer via graph traversal, not keyword guessing.

Decay — Core memories live forever. Ephemeral notes fade in hours. Working context lasts about a week. Memories that get used stay alive; unused ones sink.

Trust — Mark memories as helpful or not. Bad info gets demoted 2x faster than good info gets promoted. Over time, your memory self-cleans.

Handoffs — Tell one agent to leave work for another in natural language. Synapto turns that into a structured handoff memory, and the receiver continues with get_memory, context_ids, and follow-up updates.

Quickstart

Prerequisites

  • Python 3.11+

  • PostgreSQL 14+ with pgvector

  • Redis 7+

Install and initialize

pip install synapto
createdb synapto && psql -d synapto -c "CREATE EXTENSION vector;"
synapto init            # or: synapto init --interactive

Connect to your agent

The recommended way is uvx with --refresh — every restart pulls the latest version from PyPI, no manual upgrades:

Claude Code — register the server at user scope, so it is available in every project:

claude mcp add --scope user synapto \
  -e CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 \
  -- uvx --refresh synapto serve

That writes the following entry to ~/.claude.json (the file Claude Code actually reads for user-scoped servers — ~/.claude/.mcp.json is not consulted):

{
  "mcpServers": {
    "synapto": {
      "type": "stdio",
      "command": "uvx",
      "args": ["--refresh", "synapto", "serve"],
      "env": {
        "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"
      }
    }
  }
}

Set CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 for Claude Code so Synapto remains the single memory sink instead of duplicating new memories into Claude's flat-file auto-memory.

Claude Code starts the server in the session's working directory, which is what makes per-repository tenants work: open a session inside ~/src/acme/api and memories land in tenant acme/api. If you run the server from a checkout instead of PyPI, use uv run --project /path/to/synapto synapto serve rather than uv --directory ...--directory changes the working directory before the server starts, and every session would then derive the checkout's own tenant.

Restart Claude Code after changing the configuration so the MCP subprocess receives the new environment.

Cursor (.cursor/mcp.json):

{
  "mcpServers": {
    "synapto": {
      "command": "uvx",
      "args": ["--refresh", "synapto", "serve"]
    }
  }
}

Why --refresh? Without it, uvx reuses the cached environment across restarts, so a new Synapto release on PyPI will not be picked up until the cache expires or you run uv cache clean synapto manually. --refresh tells uv to re-resolve the package on every launch, adding 1–3 seconds to startup in exchange for "always on the latest version" — the right default for an alpha project that ships often. Drop the flag (or pin a version like "synapto==0.2.0") if you want to freeze the version.

Restart your agent. Synapto tools appear automatically, and any future release will be live on the next restart.

Default Memory Routing

Synapto is designed to be the primary memory sink for MCP-compatible agents. Agents should call recall before non-trivial work and call remember when users provide durable context instead of writing new memory into flat files.

User signal

Tool action

Recommended type/layer

"always X", "never Y", "from now on"

Store as a rule

feedback / core, subtype workflow

"don't do X", "that's wrong"

Store as a correction

feedback / core, subtype communication or workflow

"we use X for Y", "our architecture is..."

Store as project context

project / stable, subtype stable

"this sprint", "current PR", "release plan"

Store as active work

project / working, subtype working

"tracked in Linear", "dashboard is..."

Store as external reference

reference / stable, subtype external_system

"I work on...", "my preference is..."

Store as user context

user / stable, subtype preference

subtype is optional and free-form. Recommended values include code_style, workflow, tooling, testing, security, communication, external_system, documentation, role, preference, skill, and constraint.

Tenants and scopes

A tenant is the partition a memory lives in; a scope is where inside that partition it applies. Tenants keep repositories apart, scopes keep a Python rule from firing in a TypeScript file.

Tenants are derived, not typed

Every tool resolves the tenant in this order:

  1. An explicit tenant= argument — validated, never repaired. Acme/API, git@github.com:acme/api.git, and acme/api/ are all rejected with the canonical form named in the error.

  2. The origin remote of the git repository containing the server's working directory, normalised to owner/name (https://github.com/acme/api.gitacme/api).

  3. [defaults] tenant in the config file, or SYNAPTO_DEFAULT_TENANT.

  4. default.

Leave step 3 unset unless you have a reason: default is then an honest bucket for "no repository here", and nothing written outside a checkout is silently attributed to one.

Older installs that stored memories under hand-typed spellings (api, acme-api, acme/api) can be collapsed onto the canonical tenant:

synapto maintain --merge-tenants --dry-run   # report the proposed mapping
synapto maintain --merge-tenants --apply     # move the memories, record the aliases

Every target is a canonical, lowercase tenant. A fold from a canonical spelling (apiacme/api) is recorded in tenant_aliases (one hop, never a chain), and every tool follows that alias on reads and writes, so the old spelling keeps working. A legacy spelling the grammar rejects (Acme/API) is folded onto its lowercase form and records no alias — step 1 already refuses it and names the lowercase form — and a store holding only Acme/API is offered acme/api rather than left unreachable. --apply runs the printed plan as one transaction: a group refused midway undoes the ones before it.

Scopes are typed

A scope is a condition: a memory is returned only when the query names a key for every scope type the memory carries (OR within a type, AND across the types the memory itself carries). That is what makes a Python-only rule stay out of an Elixir session even when the query did not mention a language, and it is also why a scope is the wrong place for what a memory is merely about. Facts that describe a memory — the products it concerns, the repositories it cites, its language — belong in metadata (products, repos, language), where a metadata_filter selects them without hiding the memory from every other read. Scopes gate, metadata describes.

A scope is "<type>:<key>". Seven types exist: global, product, repo, language, skill, workflow, area. global:all is the only global key, and it cannot be combined with other scopes on the same memory. area names the discipline a memory belongs to (area:software-engineering, area:finance) rather than a place it applies; a memory with no area applies in every area, a query for one area never sees another area's memories, and a memory that carries an area is returned only by scoped queries that name that area (the rule below) — tag areas deliberately.

remember("Run ruff before every commit", scopes=["repo:acme/api", "language:python"])
recall("pre-commit checks", scopes=["repo:acme/api", "language:python"])

A scoped recall returns a memory when it is global:all, or when every scope type the memory declares is satisfied by one of the query's scopes of that type — a memory tagged repo:acme/api and language:python needs both to match; one tagged only language:python needs just the language. Memories stored without scopes are not returned by a scoped recall: unscoped means "never governed", not "applies everywhere" — use global:all for that. A recall without scopes ignores the axis entirely. Unknown types and malformed keys are rejected, never guessed.

domain= still works on remember and recall but is deprecated: it is one free-form label, whereas scopes are typed and plural. New callers should use scopes.

MCP Tools

Tool

What it does

remember

Store a memory with optional scopes, origin and metadata (entities and search vectors are created automatically)

recall

Search memories by meaning; narrow by scopes, metadata_filter, origin, depth_layer, subtype

ping

Check MCP transport health without touching PostgreSQL, Redis, or embeddings

get_memory

Fetch the complete content and metadata for one recalled memory

get_memories

Fetch complete content for multiple recalled memories

update_memory

Replace, append, or patch fields (including scopes and depth_layer) on an existing memory; a finished handoff moves to ephemeral in the same call

relate

Link two entities ("acme/api" --[publishes]--> "orders.created")

forget

Soft-delete a memory; human-authored memories require allow_human=true

trust_feedback

Mark a memory as helpful or unhelpful

find_contradictions

Find memory pairs that disagree

graph_query

Walk the knowledge graph (N-hop)

list_entities_tool

Browse known entities

memory_stats

View counts and distribution

maintain

Run decay and ephemeral cleanup

agent_handoff_template

Instructions for a task's single handoff packet: look it up by metadata_filter, extend it with update_memory, create it with remember only when none exists

handoff_inbox_template

Build the metadata_filter lookup that returns the packets waiting for an agent

Tool Field Limits

Synapto validates known hard limits before hitting the database, so MCP clients get actionable errors instead of raw Postgres exceptions.

Field

Limit

content

Text; no Synapto length limit

summary

Max 255 characters

memory_type

Max 20 characters

subtype

Optional free-form subcategory, max 50 characters

domain

Deprecated in favour of scopes; max 50 characters

depth_layer

Max 20 characters

tenant

Canonical owner/name, max 100 characters

scopes

Up to 20 unique "<type>:<key>" entries per memory or query

origin

One of human, agent, consolidation

recall.metadata_filter

A flat JSON object, up to 20 keys. A scalar value means equality; a list of scalars (up to 20) means the stored list contains every element — a list never matches a stored scalar, and a scalar never matches a stored list. Nested objects are rejected because containment on an object would not mean equality

get_memories.memory_ids

Max 20 IDs per call

recall.preview_chars

Clamped to 0-1000 characters

CLI

synapto serve                   # start MCP server
synapto init                    # create tables, indexes and extensions
synapto doctor                  # check postgres, redis, embeddings health
synapto search "kafka topics"   # search from terminal
synapto stats                   # memory statistics
synapto migrate status          # show applied/pending migrations
synapto migrate up              # apply pending migrations (serve does this on start)
synapto migrate down --to 7     # roll back everything after version 7
synapto maintain --merge-tenants --dry-run   # propose a tenant merge; --apply performs it
synapto export -o backup.json   # export memories
synapto import MEMORY.md --format markdown  # migrate from flat files
synapto migrate-memories        # detect and import other agents' memory files
synapto configure-mcp --client claude-code --tenant acme/api   # write the MCP entry to ~/.claude.json
synapto configure-mcp --client cursor   # write the MCP entry for Cursor

configure-mcp --client claude-code updates only mcpServers.synapto in ~/.claude.json, keeps every other key in that file, prints the entry that will load, and names ~/.claude/.mcp.json if an older release left one there — Claude Code never read it.

Migrations ship inside the package and synapto serve applies any that are pending when it starts, so upgrading the package is enough to upgrade the schema.

Depth Layers

Layer

Half-life

Example

core

Forever

"Our API uses REST, never GraphQL"

stable

~6 months

"Auth service is in Go, everything else is Python"

working

~1 week

"Currently refactoring the payment module"

ephemeral

~6 hours

"Debugging: the timeout was 30s, changed to 60s"

How it works under the hood

When you call recall("kafka patterns"), Synapto runs three searches in parallel and fuses the results:

  1. Vector similarity (pgvector HNSW) — finds semantically close memories

  2. Full-text search (tsvector + BM25) — finds keyword matches

  3. HRR compositional algebra — detects if "kafka" plays a structural role in the memory, not just appears as a word

The scores are combined via Reciprocal Rank Fusion — the HRR similarity enters on the same scale, worth at most one leg for an identical vector and nothing within the noise floor — then multiplied by decay, trust, and a depth-layer weight (core 1.3, stable 1.25, working 1.0, ephemeral 0.7 — chosen by the sweep in docs/eval/layer_sweep.md). Ties are broken by created_at DESC, id, so equal scores come back in the same order every call and the newest memory wins. Filters — tenant, scopes, metadata, origin, layer, subtype — are applied inside the SQL before ranking, so a filtered recall is a smaller search, not a trimmed page.

HRR (Holographic Reduced Representations) also enables queries that no vector database can do:

  • probe("kafka") — find memories where Kafka is structurally involved (not just mentioned)

  • reason(["kafka", "acme/api"]) — find memories about both entities simultaneously (vector-space AND)

  • contradict() — find memory pairs that share entities but say different things

More in docs/hrr.md.

Configuration

Config file: ~/.synapto/config.toml

[postgresql]
dsn = "postgresql://localhost/synapto"

[redis]
url = "redis://localhost:6379/0"

[embeddings]
provider = ""  # auto-select (sentence-transformers locally, openai if API key set)
model = ""
device = ""  # optional sentence-transformers device override, e.g. "cpu"

[defaults]
# tenant = "acme/api"   # fallback used only outside a git checkout; see "Tenants and scopes"

[decay]
ephemeral_max_age_hours = 24
purge_after_days = 30

All values can be overridden with environment variables: SYNAPTO_PG_DSN, SYNAPTO_REDIS_URL, SYNAPTO_EMBEDDING_PROVIDER, SYNAPTO_EMBEDDING_MODEL, SYNAPTO_EMBEDDING_DEVICE, SYNAPTO_DEFAULT_TENANT.

SYNAPTO_DEFAULT_TENANT (and [defaults] tenant) is a fallback, not a pin: it is consulted only when the working directory has no usable git remote. Set it when the server runs somewhere that is not a checkout — a container, a CI job, a shared shell — and you want those writes to land in a named partition. Inside repositories the derived tenant wins, and an unset fallback resolves to default. Like an explicit tenant= argument, a non-canonical value here is rejected rather than repaired, and the error names the config source instead of blaming the caller.

Using as a Python library

from synapto.db.postgres import PostgresClient
from synapto.db.migrations import run_migrations, ensure_hnsw_index
from synapto.embeddings.registry import get_provider
from synapto.search.hybrid import hybrid_search

pg = PostgresClient("postgresql://localhost/synapto")
await pg.connect()
await run_migrations(pg)

provider = get_provider()
await ensure_hnsw_index(pg, provider.dimension)

results = await hybrid_search(pg, provider, "outbox pattern", tenant="myproject")
for r in results:
    print(f"[{r.depth_layer}] trust={r.trust_score:.2f} {r.content}")

Documentation

HRR deep dive

Compositional algebra, probe, reason, contradict

Trust scoring

Feedback loop and contradiction workflow

Cross-agent handoffs

Coordinate planning, implementation, and review across agents

Migrations

Versioned SQL files with rollback

Claude Code

Setup and usage with Claude Code

Cursor

Setup and usage with Cursor

LangGraph

Using Synapto as a LangGraph tool

Agno

Using Synapto with Agno agents

Retrieval eval

Golden set, ablation and layer sweep — what the ranker was measured to do

Releasing

Version PR, preflight, dispatch, verify

Development

git clone https://github.com/ramonlimaramos/synapto.git
cd synapto
uv sync --extra dev            # or: python -m venv .venv && pip install -e ".[dev]"
uv run synapto init
uv run ruff check src/ tests/ scripts/

CI runs ruff check, bandit, pip-audit, a wheel build verified by scripts/verify_wheel.py, and the test suite on Python 3.11, 3.12 and 3.13. A separate release preflight workflow runs scripts/preflight_release.py on every pull request: it checks release.yml and the GitHub release environment against the hardened pipeline, so a drift turns the next pull request red instead of failing on release day. Releases themselves are dispatched by hand from main; see RELEASING.md.

Three conventions are enforced by tests rather than review:

  • SQL lives in synapto/sql/. Every statement is a static constant with %(name)s parameters, one module per owner; Python selects statements, it never composes them. tests/unit/test_sql_lives_in_the_sql_package.py walks the AST of both sides.

  • Migrations are inventoried. A new file under synapto/_migrations/ must be added to EXPECTED in tests/unit/test_migration_resources.py and EXPECTED_MIGRATIONS in scripts/verify_wheel.py in the same commit, and a migration is never renumbered once merged. See docs/migrations.md.

  • The destructive surface is declared. Every constant in synapto/sql/ that deletes, soft-deletes, drops, truncates or moves rows between tenants must appear in the SURFACE snapshot of tests/unit/test_destructive_surface.py with its reach — MCP tool, CLI command or library-only — and the test modules that prove refusal, happy path and "the rest was not touched". A new destructive statement fails the suite until it is declared.

Running the tests

The PostgreSQL-backed tests are destructive — they roll migrations down (dropping and recreating columns) and truncate tables. They therefore refuse to run against anything but a disposable database, and they never read your production SYNAPTO_PG_DSN.

Create a throwaway database once:

createdb synapto_test
psql -d synapto_test -c "CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm;"

Then point SYNAPTO_TEST_PG_DSN at it:

SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test uv run pytest

Two fail-closed rules protect your real data:

  • No DSN, no connection. With SYNAPTO_TEST_PG_DSN unset, the database-backed tests are skipped — never silently pointed at a default. pytest alone still runs every test that does not need PostgreSQL.

  • The database must be named *_test. The suite asks the live connection for current_database() and aborts before any destructive setup if the name does not end in _test. Parsing the DSN is not enough: a DSN can omit the database name, and service files can redirect it.

Retrieval eval

tests/eval is a golden-set harness that measures what recall actually returns, not just that filters apply. It seeds a synthetic corpus (tests/eval/corpus.toml, ~200 memories under tenant acme/eval) through remember, runs the cases in tests/eval/golden/<signal>.toml through hybrid_search, and reports MRR@10 and Recall@5 per signal — layer, trust, decay, hrr, scopes, metadata, general. The numbers are compared with the committed tests/eval/baseline.json; a move beyond ±0.02 in either direction fails, so a ranking change cannot merge without re-baselining in the same PR. It runs with the rest of the suite (same SYNAPTO_TEST_PG_DSN, same deterministic offline embeddings, no model download).

The baseline is a measurement of the current ranker, not a target: a case the ranker gets wrong today stays in the set with its low score.

To add a case, append a [[case]] to the signal's file (query, expected corpus key, optional scopes, metadata_filter, depth_layer), add any new memory to corpus.toml with a stable key, then re-baseline — the corpus digest recorded in the baseline changes, and the gate says so:

SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test SYNAPTO_EVAL_WRITE_BASELINE=1 uv run pytest tests/eval

Commit the resulting baseline.json next to the change that moved it. Keep the corpus synthetic (acme/* only); a test rejects anything else.

The same harness drives a ranking-signal ablation: each signal (HRR leg, decay, trust, layer weight, vector leg, keyword leg) is switched off in turn — as a variant of the production SQL and a zeroed HRR leg, applied in the test process only — and the golden set is re-run, with a noise floor from five shuffled-insertion runs deciding what counts as a real move. It is opt-in because it takes tens of seconds and produces a document rather than a pass/fail:

SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test SYNAPTO_EVAL_ABLATION=1 uv run pytest tests/eval

The report lands in docs/eval/ablation.md; docs/eval/README.md holds the reading of the latest run and the follow-up issues it produced.

A layer-weight sweep uses the same harness to choose the depth-layer multipliers: ten spreads are run under full and no-hrr, a spread is admissible when every layer case ranks its target first, the best overall MRR@10 among admissible spreads wins, and a tie within the noise floor goes to the narrower. The current 1.3 / 1.25 / 1.0 / 0.7 came from it; change DEPTH_BOOST only with a new run committed next to it:

SYNAPTO_TEST_PG_DSN=postgresql://localhost/synapto_test SYNAPTO_EVAL_LAYER_SWEEP=1 uv run pytest tests/eval

The report lands in docs/eval/layer_sweep.md.

License

MIT

Maintenance

ActivityActive
ResponsivenessWithin a week

Related MCP Connectors

Related MCP Servers