Skip to main content
Glama
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.

Maintenance

ActivitySlowing
ResponsivenessNo issues