Skip to main content
Glama
README.md
# 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 ◄──┘
```

![python](https://img.shields.io/badge/python-3.12%2B-blue)
![mcp](https://img.shields.io/badge/MCP-streamable--http%20%7C%20stdio-6f42c1)
![tests](https://img.shields.io/badge/tests-58%20passing-brightgreen)
![license](https://img.shields.io/badge/license-MIT-green)

---

## 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

A4.3/5.0

Scored across 10 tools

Disambiguation5/5

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.

Naming Consistency4/5

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.

Tool Count5/5

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.

Completeness5/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues