A PostgreSQL-backed MCP memory server with thermal decay, a knowledge graph, and 9-signal hybrid retrieval — including local semantic embeddings (bge-small via ONNX, in-process, offline) — a long-term memory for AI agents (e.g. Claude Code). Runs against any Postgres: a managed cloud instance (Supabase) or a local Docker container.
🔮 Live demo — explore the memory graph in 3D → effecet.github.io/memory-persistor
A reusable scaffold. Point it at your own Postgres, run the migrations, and wire it into your MCP client.
🛠️ Companion: claude-craft-kit — an opinionated Claude Code workflow harness (pre-tool guards · a tiered validation gate · a wrap-up gate) that wires this in as its optional long-term memory backend.
# Local Docker (easiest to try)
cp .env.example .env # configure credentials
make up # start Postgres + pg_cron (Docker)
make migrate # run Drizzle schema migrations
make dev # start the MCP server
# Managed Postgres (e.g. Supabase)
cp .env.supabase.example .env.supabase # add your pooler connection string
make dev-remote # start the MCP server against itThe server exposes these tools to an MCP client:
| Tool | Purpose |
|---|---|
remember |
Store a memory with tags, type, importance — auto-relates to top-3 FTS matches, dedup-checks |
recall |
9-signal hybrid search (FTS + trigram + semantic vector + temperature + importance + graph centrality + recency + access frequency) |
forget |
Delete a memory, cascade its relations, remove the synced markdown file |
update |
Partial update with automatic version snapshot before changes |
relate |
Create typed edges: related_to, supersedes, contradicts, elaborates, depends_on |
status |
Dashboard — tier/type breakdown, hottest/coldest memories, stale count |
graph |
Mermaid flowchart of the memory network |
traverse |
Multi-hop BFS graph traversal (depth 1–5, filterable by relation type) |
history |
Version chain for a memory (snapshots before each update) |
merge |
Combine duplicates — append observations, union tags, transfer edges |
conflicts |
List all contradicts edge pairs |
analytics |
Recall hit rate, top accessed, temperature distribution, events/day, graph density |
health |
Orphan count, stale count, cosine near-duplicate pairs (similarity scores + proposed canonical), contradictions, type coverage |
user · project · decision · fact · pattern · feedback · reference
graph TD
CC[MCP Client] -->|MCP| SERVER[mcp-server.ts]
subgraph Core["Core Modules"]
SERVER --> RET[retrieve.ts<br/>9-signal hybrid scoring]
SERVER --> GRP[graph.ts<br/>BFS traverse, community]
SERVER --> INT[intelligence.ts<br/>Dedup, merge, versioning]
SERVER --> OBS[observability.ts<br/>Analytics, health, dedup pairs]
EMB[embed.ts<br/>local ONNX embeddings] --> RET
EMB --> OBS
end
subgraph DB["PostgreSQL 17 + pg_trgm + pgvector + pg_cron"]
PG[(Entities + embedding<br/>+ Relations + Versions + Events)]
CRON[pg_cron<br/>Nightly decay]
end
subgraph Sync["Optional"]
FS[file-sync.ts<br/>mirror memories to markdown]
CAN[events_canary.py<br/>event-freshness check]
end
RET -->|pooled SSL connection| PG
GRP --> PG
INT --> PG
OBS --> PG
SERVER --> FS
CAN -.->|polls events<br/>alert on silence| PG
style Core fill:#1e293b,stroke:#f97316,color:#fff
style DB fill:#1e293b,stroke:#22d3ee,color:#fff
style Sync fill:#1e293b,stroke:#a78bfa,color:#fff
| Signal | Weight | Source |
|---|---|---|
| Full-text rank | 0.13 | ts_rank on tsvector |
| Trigram similarity | 0.10 | pg_trgm |
| Semantic similarity | 0.12 | pgvector cosine over bge-small-384 embeddings |
| Tag match | 0.10 | Array overlap |
| Temperature | 0.15 | Thermal model |
| Importance | 0.10 | Auto-drifting (0.1–0.9) |
| Graph centrality | 0.15 | Relation edge count |
| Recency boost | 0.10 | Time since last access |
| Access frequency | 0.05 | Cumulative access count |
Weights are configurable in src/config.ts.
recall and the health dedup detector run over local semantic embeddings —
no text ever leaves the machine:
- Model —
bge-small-en-v1.5(384-d) via@huggingface/transformers, ONNX, in-process, offline. First call loads the model (~1–2 s, warmed at boot); subsequent embeds are ~10–30 ms. - Storage — an additive nullable
entities.embedding vector(384)column (pgvector). No ANN index at small corpus sizes — exact brute-force cosine (<=>) is sub-millisecond. - Semantic recall — a paraphrase with zero shared keywords can still surface,
not just re-rank among lexical matches (the cosine arm widens the
WHERE). - Cosine dedup —
healthsurfaces near-duplicate pairs above a cosine threshold for human-approvedmerge(never auto-merged). - Pinned — model + quantization (
fp32) are fixed; changing either invalidates every stored vector and requires a re-embed. A one-timescripts/backfill-embeddings.tsembeds any rows written before embeddings were enabled. MEMORY_EMBED_ENABLED— machines with the flag set embed on write; others storeNULL(always safe) and let a primary backfill. Query-time embedding is always available so semantic recall works everywhere.
Memories have a temperature (0.0–1.0) that decays daily with a 0.85 multiplier:
- Pattern-aware decay — memories accessed 3+ days/week decay slower (access bitmap detection)
- Cascade bumps — accessing a memory warms its graph neighbors proportionally to edge weight
- Auto-importance drift — frequently accessed memories gain importance; neglected ones (60+ days) lose it
- Tier classification — HOT (>0.7), WARM (0.3–0.7), COLD (<0.3)
- Stale flagging — COLD memories untouched for 30+ days are marked stale
- PostgreSQL — primary store (entities, relations, versions, events)
- Markdown files (optional) —
file-sync.tsmirrors memories to a directory of.mdfiles (setMEMORY_PERSISTOR_DIR/CLAUDE_DIR), handy for agents with a file-based memory convention.
| Job | Schedule | Purpose |
|---|---|---|
memory-thermal-decay |
0 6 * * * UTC |
Nightly pattern-aware decay + importance drift + stale flagging |
memory-decay-startup-catchup |
@reboot (local Docker) |
Runs decay_catchup() on container start if last decay was >24h ago |
make help # show all targets
make test # unit tests (Vitest + pytest for scripts)
make test-integration # integration tests against real Postgres
make status # local DB + pg_cron status
make decay # run thermal decay (local)
make backfill-embeddings # embed any rows with a NULL embedding (ARGS=--dry-run to count)
make canary # events-pipeline freshness check (local)
make cron-status # pg_cron schedule and recent runs
make graph # Mermaid graph of memory network
make seed # import existing markdown memories (optional file-sync)
make clean # remove volumes and generated filesEach command has a -remote variant (dev-remote, status-remote, decay-remote,
canary-remote) that targets the connection in .env.supabase.
src/
mcp-server.ts # MCP tool definitions and handlers
retrieve.ts # 9-signal hybrid retrieval scoring
embed.ts # Local bge-small ONNX embeddings (in-process, offline)
thermal.ts # Cascade bumps, pattern-aware decay, importance drift
graph.ts # BFS traversal, community detection, auto-relate
intelligence.ts # Dedup detection, confidence scoring, merge, versioning
observability.ts # Analytics, health metrics, cosine dedup pairs
events.ts # Fire-and-forget event logging
file-sync.ts # Optional dual-write to markdown files
schema.ts # Drizzle ORM schema (entities, relations, versions, events)
config.ts # Scoring weights, decay rates, tier boundaries, embed pins
db.ts # Database connection (auto-SSL for remote)
import.ts # Seed script for existing markdown memories
scripts/
memory-decay.py # Python decay runner (Docker exec fallback)
events_canary.py # Event-freshness check (exits 1 if pipeline silent)
backfill-edges.ts # One-time auto-relate backfill
backfill-embeddings.ts # One-time embedding backfill for NULL rows
tests/
*.test.ts # Unit tests (Vitest)
*.py # Python tests (pytest)
integration/ # Integration suites against real Postgres
drizzle/ # Migration files
initdb/
01-pg-cron.sql # pg_cron setup, decay job, startup catchup function
docker-compose.yml # Local Postgres 16 + pg_cron + pg_trgm
LICENSE # MIT
Copy .env.example and adjust:
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
postgresql://postgres:postgres@localhost:5432/memory_persistor |
Postgres connection string |
POSTGRES_PASSWORD |
postgres |
Docker Compose Postgres password |
MEMORY_PERSISTOR_DIR |
project root | Base path for optional markdown mirror |
CLAUDE_DIR |
~/.claude |
Directory for optional markdown mirror |
Create .env.supabase with a pooler connection string (transaction mode, port 6543):
DATABASE_URL=postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:6543/postgres
src/db.ts auto-detects a remote host and enables SSL. It respects an explicit
sslmode=... query param, so a no-SSL service container (CI) and a forced-SSL
managed instance can coexist.
GitHub Actions (.github/workflows/ci.yml, ubuntu-latest) on every push and PR:
npm ci → npm run build → Vitest unit suite → pytest (scripts) → gitleaks.
The integration suite (make test-integration) runs against a real Postgres and
is intended to be run locally / against your own instance.
Secrets scanning uses .gitleaks.toml (built-in rules + an allowlist for local
files and documentation placeholders).
- Runtime: Node.js 24+ (ESM) · Language: TypeScript 5.9 (strict)
- ORM: Drizzle ORM · MCP SDK:
@modelcontextprotocol/sdk· Validation: Zod - Database: PostgreSQL 17/16 +
pg_trgm+pgvector+pg_cron - Embeddings:
@huggingface/transformers(bge-small-en-v1.5, 384-d, ONNX, in-process) - Testing: Vitest (TS) + pytest (Python) · Container: Docker Compose
MIT — see LICENSE.