memser
by masikgit
README.md
# memser — Memory-as-a-Service MCP Server
An MCP server that exposes memory operations (`store`, `recall`, `forget`,
`consolidate`, `list`) as tools, backed by a tiered storage engine, so any
MCP-compatible agent (Claude Desktop, Claude Code, a Telegram bot, custom
agents) can plug in for persistent memory.
## Layers
```
MCP Interface Layer (app/server.py) store_memory, recall_memory,
forget_memory, list_memories,
consolidate_memories
Memory Engine (app/memory_engine.py, scoring, dedup, conflict
app/consolidation.py, app/scoring.py) detection, decay scheduling
Storage Tiers
- Working (Redis, TTL) app/redis_client.py
- Episodic (Postgres, JSONB-ish) app/models.py (tier="episodic")
- Semantic (Postgres + pgvector) app/models.py (tier="semantic")
Background Jobs (APScheduler) app/jobs.py
```
## Setup
```bash
cp .env.example .env # adjust as needed
docker compose up -d # Postgres+pgvector and Redis
pip install -e ".[dev]" # or: pip install -r requirements.txt
# Only needed if not using docker-compose's auto-init:
python -m scripts.init_db
python -m app.server # runs the MCP server over stdio
```
To try it with the MCP inspector instead of a real client:
```bash
mcp dev app/server.py
```
To wire it into Claude Desktop/Code, add to its MCP config:
```json
{
"mcpServers": {
"memory-service": {
"command": "python",
"args": ["-m", "app.server"],
"cwd": "/path/to/memser",
"env": { "DATABASE_URL": "...", "REDIS_URL": "...", "OPENAI_API_KEY": "..." }
}
}
}
```
Without `OPENAI_API_KEY`, embeddings fall back to a deterministic local hash
embedder (`app/embeddings.py::HashEmbedder`) — the server runs fully offline,
but recall quality is lexical-overlap-only, not real semantic search.
Consolidation summarization similarly falls back to naive dedup/join instead
of an LLM call.
## Tools
| Tool | Purpose |
|---|---|
| `store_memory(content, scope, tags?, source?, ttl_seconds?, actor?)` | Write a memory. `ttl_seconds` → working tier (Redis); omitted → durable episodic tier (Postgres), with dedup/conflict resolution against existing memories in scope. |
| `recall_memory(query, scope, k?, tags?, actor?)` | Hybrid search: working tier + vector similarity over episodic/semantic, ranked by similarity + recency + frequency + confidence. |
| `forget_memory(scope, memory_id?, tier?, tags?, actor?)` | Cascading delete across Redis + Postgres, with an audit-log snapshot written before deletion. |
| `consolidate_memories(scope, actor?)` | Clusters episodic memories, LLM-summarizes each cluster into a semantic fact, marks originals `superseded_by` (kept, not deleted). Also runs on a schedule. |
| `list_memories(scope, tier?, tags?, ...)` | Audit/debug listing, no ranking. |
`scope` is the tenancy boundary (user id, agent id, or session id) — nothing
crosses scopes. Every tool call is written to `memory_audit_log` (action,
actor, memory id, scope, detail).
## Data model
See [db/init.sql](db/init.sql) for the authoritative schema (`memories`,
`memory_audit_log`) and [app/models.py](app/models.py) for the SQLAlchemy
mapping. Key points:
- `memories.embedding` is `VECTOR(1536)` (pgvector), indexed with an
`ivfflat` cosine-distance index.
- `superseded_by` chains episodic → semantic (via consolidation) and
old-fact → new-fact (via conflict resolution) without ever deleting the
superseded row — that's the audit trail.
- `forget_memory` is the only thing that actually deletes rows, and it logs
a full content snapshot to `memory_audit_log` first.
## Key mechanisms
- **Conflict detection** ([app/memory_engine.py](app/memory_engine.py)):
before a durable store, the new embedding is compared against the
scope's active memories. Above `DUPLICATE_THRESHOLD` cosine similarity,
the write is treated as a duplicate (bumps `access_count`/`confidence` on
the existing row instead of inserting). Above `CONFLICT_THRESHOLD` but
below duplicate, the new memory is inserted and the older, similar ones
are marked `superseded_by` the new row.
- **Consolidation** ([app/consolidation.py](app/consolidation.py)): greedy
single-linkage clustering of a scope's episodic memories by cosine
similarity, one LLM summarization call per cluster, one new semantic row
per cluster, originals marked superseded (not deleted).
- **Scoring/decay** ([app/scoring.py](app/scoring.py)): recall ranks by
`similarity, recency (exponential half-life), frequency (diminishing
returns on access_count), confidence`, weighted per `.env`. A background
decay job (`app/jobs.py::run_decay_sweep`) shrinks confidence on stale,
unaccessed episodic memories over time.
- **Scoped forgetting**: `forget_memory` always requires a `scope`, and
cascades to every tier a memory could live in (Redis working entry +
Postgres row), logging who deleted what and when before the row is gone.
## Background jobs
`app/jobs.py` runs two `APScheduler` interval jobs inside the same process
as the MCP server (started/stopped via the FastMCP `lifespan` hook in
`app/server.py`):
- `run_consolidation_sweep` — every `CONSOLIDATION_INTERVAL_MINUTES`,
consolidates every scope with at least `CONSOLIDATION_MIN_EPISODIC`
un-consolidated episodic memories.
- `run_decay_sweep` — every `DECAY_INTERVAL_MINUTES`, decays confidence on
episodic memories untouched for `DECAY_AFTER_DAYS`.
Set `ENABLE_SCHEDULER=false` to disable both (e.g. run consolidation only
via explicit `consolidate_memories` calls, or move this to Celery beat for
a multi-process deployment — the job bodies are plain async functions and
don't depend on APScheduler specifically).
## Tests
```bash
pytest
```
Tests cover the pure logic (scoring math, hash-embedding determinism,
clustering, summarization fallback) and don't require Postgres/Redis to be
running. There's no integration test suite against a live database in this
scaffold — add one with a `pgvector/pgvector` test container if you extend
this.
## Known scaffold limitations
- `cluster_by_similarity` is O(n²) greedy single-linkage — fine for
per-scope batches of tens–low-hundreds of episodic memories between
consolidation runs; swap in a real clustering/ANN approach if scopes grow
much larger before consolidating.
- Conflict resolution always lets the newest similar memory supersede the
older one(s); there's no merge-vs-flag-for-human-review path — add one if
blind "newest wins" isn't safe for your use case.
- Single-process scheduler (APScheduler) — fine for one server instance;
move to Celery beat (as sketched in the original architecture doc) if you
run multiple replicas and need a single consolidation/decay owner.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues