qcdoc-mem
# qcdoc-mem
**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.
```text
agent ──► memory_remember_conversation ──► LLM extraction ──► facts/*.md + memory.json
agent ◄── memory_context / memory_search ◄── budgeted injection ◄──┘
```




---
## Why
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.
## What you get
| | |
|---|---|
| **10 MCP tools** | recall, explicit saves, automatic conversation extraction, fact CRUD, erasure, flush, status. See [docs/tools.md](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](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](docs/operations.md). |
| **Swappable backend** | the whole memory system sits behind a `MemoryManager` contract. See [docs/extending.md](docs/extending.md). |
---
## Quick start
Requires Python 3.12+.
```bash
uv sync # or: pip install -e .
uv run qcdoc-mem-mcp # streamable HTTP on http://127.0.0.1:8130/mcp
```
Or, for a client that spawns the server itself:
```bash
uv run qcdoc-mem-mcp --transport stdio
```
Point your MCP client at it:
```jsonc
{
"mcpServers": {
"qcdoc-mem": {
"type": "http",
"url": "http://127.0.0.1:8130/mcp"
}
}
}
```
Optional extras:
```bash
uv sync --extra cjk # jieba word segmentation, better Chinese/Japanese/Korean recall
```
> **No 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.
### Give it an LLM
Reads, explicit saves, and fact CRUD work with no model. **Automatic extraction
from conversations needs one.** Point `qcdoc-mem` at any OpenAI-compatible
endpoint:
```yaml
# qcdoc-mem.config.yaml
memory:
backend_config:
model:
provider: openai # anything init_chat_model supports
model: deepseek-chat
base_url: https://api.deepseek.com/v1
```
Run `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](docs/configuration.md#5-llm).
---
## The 10 tools
| 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](docs/tools.md).**
### Errors
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. |
---
## How it works
```text
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](docs/architecture.md) for the full mechanism --
data model, both pipelines step by step, every gate, the concurrency protocol,
and the isolation model.
---
## Using it in an agent loop
`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](docs/integration.md) for recipes and for the
automatic-capture behaviour that this package deliberately leaves to you.
---
## SDK
The same operations in-process, no MCP:
```python
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:
```python
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"))
```
---
## Documentation
Start at [docs/README.md](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](docs/README.md)** | **The index: reading paths by role, and "I want to..." lookups.** |
| [docs/tools.md](docs/tools.md) | Every MCP tool: parameters, returns, when to call it, which phase of the loop, worked examples, error handling. |
| [docs/architecture.md](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](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](docs/configuration.md) | Full config reference: precedence, every environment variable, every CLI flag, every backend field with defaults and bounds. |
| [docs/operations.md](docs/operations.md) | Storage layout, backup and recovery, migration, concurrency limits, troubleshooting, known limitations. |
| [docs/extending.md](docs/extending.md) | Swapping the backend, the storage class, the retrieval adapter, the prompts, and the signal patterns. |
| [examples/README.md](examples/README.md) | Five runnable examples: in-process SDK, agent loop, multi-tenant isolation, a real MCP client, and live LLM extraction. |
---
## Where memory lives
```text
<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](docs/operations.md).
---
## Development
```bash
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 anchors
```
The 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.
## Credits
The memory backend under `src/qcdoc_mem/backends/qcdoc_mem/` is derived from the
memory subsystem of [deer-flow](https://github.com/bytedance/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](THIRD-PARTY-NOTICES.md), which ships with the package.
## License
MIT -- see [LICENSE](LICENSE). Third-party copyright notices are in
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
TDQS
Scored across 10 tools
Each tool targets a distinct operation: read whole memory, search, context injection, manual write, automatic extraction, update, delete, bulk forget, flush, and status. The only near-overlap (memory_get vs memory_context) is explicitly disambiguated in descriptions, and memory_remember vs memory_remember_conversation clearly separates manual from backend-extracted storage.
All tools share the memory_ prefix and use snake_case, mostly following a memory_<verb> pattern. memory_context is a noun-style endpoint and memory_status/remember_conversation vary slightly in structure, but the overall pattern remains predictable.
10 tools is well-scoped for a memory backend: read, search, context load, manual write, automatic extraction, update, delete, full erase, flush, and status. Each tool earns its place with no redundant bloat.
Covers the full lifecycle of persistent facts: create (remember/remember_conversation), read (get/search/context), update, delete, plus admin operations (flush/status) and destructive forget. No obvious dead ends for the stated purpose.