Persistent long-term memory for AI agents, served over MCP.
Connect an MCP client, pass a user_id, and the agent remembers: facts are
extracted from conversations, stored as plain files on disk, and handed back for
injection into later sessions. No database to operate, no vector store, no
embedding pipeline.
agent ──► memory_remember_conversation ──► LLM extraction ──► facts/*.md + memory.json
agent ◄── memory_context / memory_search ◄── budgeted injection ◄──┘
LLMs are stateless. Every session starts blank, so the user re-introduces
themselves, re-states preferences, and re-corrects the same mistakes. qcdoc-mem
is the memory subsystem that fixes that, packaged as a standalone service:
- It lives outside your agent. One process serves any number of agents over MCP or an in-process SDK. Your client only needs the tool names.
- It decides what to remember. You hand over the conversation; an LLM extracts durable facts, scores their confidence, drops the noise, dedupes near-identical entries, and merges fragments over time.
- It is inspectable. Memory is Markdown and JSON on disk. You can read it,
grep it, diff it, edit it, back it up with
rsync, and recover it without a database.
| 10 MCP tools | recall, explicit saves, automatic conversation extraction, fact CRUD, erasure, flush, status. See docs/tools.md. |
| Two write paths | memory_remember for facts you can already state (no LLM needed); memory_remember_conversation to hand over a session and let extraction decide. |
| Three-layer isolation | per user_id, per agent_name, with an explicit strict_user_scope switch for multi-tenant deployments. |
| Lifecycle management | confidence gates, two-level dedup, max_facts capacity eviction, staleness review, and fragment consolidation. See docs/architecture.md. |
| Budgeted injection | the memory block is ranked and truncated to a token budget, with correction facts in a protected reserve. |
| No infrastructure | Markdown facts + a JSON summary + an embedded SQLite FTS5 index that is derived and rebuildable. See docs/operations.md. |
| Swappable backend | the whole memory system sits behind a MemoryManager contract. See docs/extending.md. |
Requires Python 3.12+.
uv sync # or: pip install -e .
uv run qcdoc-mem-mcp # streamable HTTP on http://127.0.0.1:8130/mcpOr, for a client that spawns the server itself:
uv run qcdoc-mem-mcp --transport stdioPoint your MCP client at it:
Optional extras:
uv sync --extra cjk # jieba word segmentation, better Chinese/Japanese/Korean recallNo built-in authentication. The server binds to loopback by default for exactly that reason. If you expose it, put authentication in front of it -- anyone who can reach the port can read and write every user's memory.
Reads, explicit saves, and fact CRUD work with no model. Automatic extraction
from conversations needs one. Point qcdoc-mem at any OpenAI-compatible
endpoint:
# qcdoc-mem.config.yaml
memory:
backend_config:
model:
provider: openai # anything init_chat_model supports
model: deepseek-chat
base_url: https://api.deepseek.com/v1Run qcdoc-mem-mcp and call memory_status: it reports llm_configured: true
when the model resolved. A misconfigured model does not crash startup --
reads keep working and extraction fails at call time with a clear error.
The API key comes from the environment, never from the file -- the config file
is not interpolated, so a ${VAR} written there would be sent as the key
verbatim. Either export QCDOC_MEM_LLM_API_KEY, or omit api_key entirely and
let the provider read its own variable (OPENAI_API_KEY, ANTHROPIC_API_KEY,
...). See docs/configuration.md.
| Tool | One line | Needs LLM |
|---|---|---|
memory_context |
Load memory as injection-ready text. Call once at the start of a task. | no |
memory_search |
Find facts by query. Call before asking the user to repeat themselves. | no |
memory_get |
Return the whole memory document (summaries + facts). For inspection/export. | no |
memory_remember |
Store one durable fact. Idempotent on exact content. | no |
memory_remember_conversation |
Hand a session over for automatic extraction. | yes |
memory_update_fact |
Edit a fact by id; omitted fields keep their value. | no |
memory_delete_fact |
Delete one fact by id. | no |
memory_forget |
Erase a user's memory and cancel its pending extractions. | no |
memory_flush |
Force pending extractions to run now. | no |
memory_status |
Report backend, mode, storage path, whether a model is configured. | no |
Plus one MCP resource: qcdoc-mem://status.
Every tool takes optional user_id / agent_name; set default_user_id on the
server and a single-tenant client can omit them entirely.
Full reference -- every parameter, every return shape, when to call it and in which phase of the agent loop: docs/tools.md.
Tools never raise at the model. They return
{"error": "...", "kind": "..."}, where kind is one of:
kind |
Meaning |
|---|---|
invalid_request |
The caller's input was wrong (empty query, unknown role, bad confidence). |
unsupported |
The configured backend does not implement that operation. |
backend_error |
Storage or the backend failed. |
write path (asynchronous, batched)
client ── memory_remember_conversation ──► debounce queue
──► prompt assembly (current memory + conversation + hints)
──► one LLM call ──► JSON with 6 decision types
──► deterministic gates ──► dedup ──► capacity eviction ──► files
read path (synchronous, per turn)
client ── memory_context ──► load bucket ──► rank ──► token budget ──► text
client ── memory_search ──► FTS5 (or lexical relevance) ──► ranked facts
The design in one paragraph: one conversation turn produces at most one LLM
call, and that call is asked to do six jobs at once -- update the six summary
sections, add new facts, reinforce confirmed facts, remove contradicted facts,
re-adjudicate aged facts, and merge fragments. Everything the model proposes
then passes through deterministic code: a write gate that rejects anything not
scope=user + durable + descriptive, a confidence floor, exact and
near-duplicate dedup, and a capacity cap. The model proposes; the code decides.
Read docs/architecture.md for the full mechanism -- data model, both pipelines step by step, every gate, the concurrency protocol, and the isolation model.
qcdoc-mem does not hook your agent -- it cannot see your turns. The client
owns three moments:
| Phase | Call | Why |
|---|---|---|
| Session start | memory_context |
Prepend the returned text to the system prompt. |
| Before asking the user to repeat | memory_search |
Only if facts were not already injected. |
| Session end | memory_remember_conversation then memory_flush |
Capture the session; the flush matters because extraction is batched. |
The server's MCP instructions tell the model when to make these calls, so a
client that surfaces them gets the behaviour with no integration code. If you
want automatic capture/injection at the framework level (LangGraph middleware,
turn hooks), you write that layer -- see
docs/integration.md for recipes and for the
automatic-capture behaviour that this package deliberately leaves to you.
The same operations in-process, no MCP:
from qcdoc_mem.contract import get_memory_manager
from qcdoc_mem.service import MemoryService
from qcdoc_mem.settings import configure_defaults
server_settings = configure_defaults() # env + optional config file
service = MemoryService(get_memory_manager(), server_settings)
service.remember("prefers uv over pip", user_id="alice", category="preference")
print(service.context(user_id="alice")["context"])
print(service.search("uv", user_id="alice")["results"])Or talk to the backend contract directly, bypassing the facade:
from qcdoc_mem.contract import HostHooks, MemorySettings, build_memory_manager
manager = build_memory_manager(
MemorySettings(backend_config={"storage_path": "/var/lib/qcdoc-mem"}),
HostHooks(),
)
print(manager.get_context("alice"))Start at docs/README.md, which maps every document and gives lookup tables by task ("store a fact now", "why is my read empty", "point it at DeepSeek", ...).
| Document | What is in it |
|---|---|
| docs/README.md | The index: reading paths by role, and "I want to..." lookups. |
| docs/tools.md | Every MCP tool: parameters, returns, when to call it, which phase of the loop, worked examples, error handling. |
| docs/architecture.md | Principles, data model, the write and read pipelines step by step, gates, dedup, eviction, staleness, consolidation, concurrency, isolation, observability. |
| docs/integration.md | Putting it in an agent loop: phases, recipes, multi-tenant identity, and the automatic-capture layer this package does not ship. |
| docs/configuration.md | Full config reference: precedence, every environment variable, every CLI flag, every backend field with defaults and bounds. |
| docs/operations.md | Storage layout, backup and recovery, migration, concurrency limits, troubleshooting, known limitations. |
| docs/extending.md | Swapping the backend, the storage class, the retrieval adapter, the prompts, and the signal patterns. |
| examples/README.md | Five runnable examples: in-process SDK, agent loop, multi-tenant isolation, a real MCP client, and live LLM extraction. |
<data-dir>/
├── .retrieval/memory-fts5.sqlite3 derived FTS5 index (rebuildable)
└── users/{user_id}/
├── memory.json six summary sections + revision
├── .memory.lock cross-process lock
└── agents/{agent_name}/
├── facts/{ab}/{fact_id}.md one Markdown file per fact
└── .metadata/ access counters, eviction audit
Data root resolution: memory.backend_config.storage_path >
$QCDOC_MEM_DATA_DIR > ~/.qcdoc-mem. Details, including what is safe to delete,
are in docs/operations.md.
uv run pytest # 58 tests
uv run pytest tests/test_mcp_server.py -q
uv run python examples/run_all.py # every offline example, ~5s
uv run python scripts/check_doc_links.py # docs links and anchorsThe MCP tests drive the real tool-dispatch path over FastMCP's in-memory
transport, so nothing binds a port. 04_mcp_client.py is the exception: it
starts a real server on a free port and drives it over HTTP, then shuts it down.
The memory backend under src/qcdoc_mem/backends/qcdoc_mem/ is derived from the
memory subsystem of deer-flow, at
backend/packages/harness/deerflow/agents/memory/, adapted into this standalone
service. The adaptation is mechanical: module paths, class names, the
environment-variable prefix, the configuration filename and the default storage
root were renamed, and the host-coupled layers were replaced by this package's
own contract/, service.py, settings.py and mcp/ packages. The storage,
extraction, retrieval and lifecycle logic itself originates upstream.
That project is MIT licensed. Its copyright notice is reproduced in THIRD-PARTY-NOTICES.md, which ships with the package.
MIT -- see LICENSE. Third-party copyright notices are in THIRD-PARTY-NOTICES.md.
{ "mcpServers": { "qcdoc-mem": { "type": "http", "url": "http://127.0.0.1:8130/mcp" } } }