English · Русский
A personal knowledge base for AI assistants. An MCP server with hybrid search, auto-compilation and a web interface.
AI assistants don't remember context between sessions. memory-compiler fixes that: decisions, bugs and configurations are stored as markdown articles, indexed, and served over MCP or an HTTP API. The assistant looks up similar cases before a task and records new solutions afterwards.
pip install -r requirements.txt
mkdir -p knowledge
PROJECTS=my-project,infra,general python server.pyThe server starts on http://localhost:8765. Or via Docker:
docker-compose up -d --buildOn the first start the server downloads the embedding model from huggingface.co once — about 0.5 GB for the default paraphrase-multilingual-MiniLM-L12-v2. Until the download finishes, search matches keywords only. Later starts run offline from the local cache (the hf_cache volume in Docker) and make no requests to huggingface.co.
Check the state:
curl -s http://localhost:8765/api/healthsemantic is on when semantic search works and loading while the model loads (semantic_reason: first_download during the first download). When it is off, semantic_reason says why:
semantic_reason |
What happened | What to do |
|---|---|---|
offline_no_cache |
The model is not in the cache and offline mode is set explicitly | Remove HF_HUB_OFFLINE and TRANSFORMERS_OFFLINE from .env and restart: the model is downloaded once |
download_failed |
The download from huggingface.co failed | Check access to huggingface.co and restart |
load_failed |
The model is in the cache but did not load | See the server log; if an interrupted download left the cache incomplete, remove it (the hf_cache volume in Docker, the model folder under ~/.cache/huggingface/hub without Docker) or set HF_HUB_OFFLINE=0 for one start |
The same state shows up as a banner in the web interface and as a notice in search results, so the assistant sees it too.
Offline mode follows HF_HUB_OFFLINE / TRANSFORMERS_OFFLINE:
- unset (the default) — the server decides at start: models in the cache → offline, missing → this start downloads them once;
1— strictly offline: without the model in the cache, semantic search stays off;0— always online: models are checked against huggingface.co at every start.
Offline is the default for a reason: on networks with rate limiting or a firewall, requests to huggingface.co after the model had loaded used to crash the start with Errno 99.
{
"mcpServers": {
"memory-compiler": {
"type": "http",
"url": "http://localhost:8765/mcp/"
}
}
}If the server has MC_API_KEY set, add "headers": {"X-Api-Key": "<key>"} ("Authorization": "Bearer <key>" works too).
The legacy /sse endpoint still serves older configs, but Streamable HTTP is the recommended transport. An SSE client that loses its stream reconnects on its own and does not repeat initialize: before v1.90.1 every call after that failed with -32602 Invalid request parameters, while the client kept showing the server as connected.
Full walkthrough: docs/claude-desktop-setup.en.md — MCP, the memory-autopilot skill, hooks, dependency setup.
Full walkthrough: docs/kimi-work-setup.en.md — everything lives in one plugin: MCP, the memory-autopilot skill and the memory gate hooks the daimon runtime executes natively.
Search and read:
| Tool | Description |
|---|---|
search(query, project) |
Hybrid BM25F + semantic search with temporal decay |
ask(question, project) |
Q&A — an answer with quotes from articles |
search_by_tag(tag, project, limit) |
Articles carrying a given tag, freshest first (30 by default) |
search_snippets(query, lang, project) |
Search across code blocks |
search_error(error_text, project) |
Search across tracebacks and error codes |
search_decisions(query, project) |
Search the decision log |
read_article(project, filename, full) |
Article text; the "See also" and "Git links" sections and the frontmatter are hidden unless full=true |
get_context(project, query) |
Top relevant articles ahead of a task |
get_summary(project) |
Condensed project summary (~200 tokens) |
Write and edit:
| Tool | Description |
|---|---|
save_lesson(topic, content, project, tags) |
Save with a diff report, auto-merge and auto-tagging |
save_decision(title, decision, reasoning, project, alternatives) |
Record an architectural decision (alternatives is optional) |
save_runbook(topic, steps, project) |
Create a step-by-step runbook with checkboxes |
save_from_template(template, fields, project) |
Create an article from a template (bug, setup, 1c, deploy, integration) |
save_secret(topic, content, project) |
Save an encrypted article (passwords, keys) |
edit_article(project, filename, content, append) |
Replace or append |
delete_article(project, filename) |
Delete an article |
get_runbook(project, filename) |
Fetch a runbook with its progress |
list_templates() |
List available templates |
Sessions:
| Tool | Description |
|---|---|
save_session(project, summary, ...) |
Save context for the next session |
session_note(note, project) |
A note WHILE working — one line, no summary rebuild |
load_session(project) |
Load context plus notifications about stale articles |
get_active_context(project) |
Feed of the last 10 actions |
Temporal state:
| Tool | Description |
|---|---|
save_tracking(project, entity, facts, replace) |
Bi-temporal snapshot: current state plus history (versions, deploys, configs). Passed facts merge into the current snapshot, null removes a field, replace=true swaps the whole snapshot. Release fields (commit, tag…) follow their version: they move to history when it changes and come back when it returns |
get_current(project, entity) |
Read the current status out of a tracking article |
Combined:
| Tool | Description |
|---|---|
start_task(topic, project) |
Begin a task: search + session + context + decisions + runbooks |
finish_task(topic, content, project) |
Close a task: save the lesson and the session |
Project management:
| Tool | Description |
|---|---|
add_project(name) |
Create a new project |
remove_project(name) |
Delete a project along with all its articles |
list_projects() |
List projects with article counts |
set_project_deps(project, depends_on) |
Declare dependencies between projects |
get_project_deps(project) |
Read a project's dependencies |
Maintenance:
| Tool | Description |
|---|---|
ingest(url, project, ...) |
Pull knowledge from a URL (HTML→markdown) or from raw_text (PDF/documents) |
import_obsidian(vault_path, project, folder_mapping, dry_run) |
Import notes from an Obsidian vault (frontmatter, tags, wiki links) |
git_capture(repo_path, project, ...) |
Harvest knowledge from git commits (repo_path or git_log_raw) |
knowledge_gap(repo_path, project, days) |
Find topics present in git commits but missing from the knowledge base |
compile(dry_run, project, since) |
Compile daily logs into wiki articles |
lint(project, fix, verbose) |
Check for duplicates, staleness, tags and broken links. By default "stale" and "orphan" collapse into a counter — they describe the state of the base rather than problems (across 1612 articles they made up 95% of the output); verbose expands them line by line |
reindex() |
Rebuild the index |
article_history(project, filename) |
Git history of an article |
backlinks(project, filename) |
Who links to this article: incoming MANUAL links with a context line. The auto-generated "See also" block is excluded — it reflects semantic similarity |
Contextual generation (contextual retrieval):
| Tool | Description |
|---|---|
context_gaps(project, limit) |
List articles that still need AI section context (for backfill) |
save_contexts(project, filename, contexts) |
Write AI section context into frontmatter and re-embed the article |
Three layers of relevance:
- BM25F (Whoosh) — full-text with field weights (title x5, tags x3, body x1)
- Semantic — bi-encoder embeddings (default
paraphrase-multilingual-MiniLM-L12-v2; theEMBED_MODELenv var switches toBAAI/bge-m31024d orAlibaba-NLP/gte-multilingual-base, and the cache invalidates itself on a model change) - Cross-encoder reranking —
BAAI/bge-reranker-v2-m3, disabled by default (RERANK_ENABLED=1turns it on). Measured over 132 real queries (scripts/eval_retrieval.py), it delivers no gain on this corpus at ×32 the latency — details below. SPLADE 3-way is optionally available viaSPLADE_ENABLED=true
Channels are merged with RRF (Reciprocal Rank Fusion) rather than a weighted sum: it needs no calibration between BM25 and cosine similarity.
rrf(doc) = Σ_channel 1 / (RRF_K + rank_channel(doc)) # BM25 + semantic (+ SPLADE)
scaled = rrf × 3000 × (0.7 + 0.3 × decay_factor)
final = top_k (search: 8, get_context: 5) # + cross_encoder.rerank if RERANK_ENABLED=1
Temporal decay pushes fresh and frequently used articles higher.
Candidate selection (SEARCH_QUERY_GROUP, SEARCH_POOL, SEARCH_SCOPE_AWARE) governs WHAT reaches the merge step. Diagnostics (scripts/diag_retrieval.py) over 140 real queries showed the main loss lived here, not in the weights:
| Knob | Default | What it used to be | Measurement |
|---|---|---|---|
SEARCH_QUERY_GROUP |
or |
and — a document had to contain EVERY term; the BM25 channel was empty on 48.6% of queries, and on queries of 6+ words on 75% |
MRR +0.046, recall@1 +8 queries out of 140 |
SEARCH_SCOPE_AWARE |
true |
the project scope was applied AFTER taking the global top-20: across 43 projects that left ~2 BM25 candidates and ~9.7 semantic ones | recall@10 +9 queries |
SEARCH_POOL |
100 |
limit*2 (=20): on 12.9% of queries the target sat in the wide pool but not the narrow one |
folded into the row above |
Result over 140 queries (scripts/eval_pipeline.py): MRR 0.4242 → 0.4914, recall@1 0.3071 → 0.3714, recall@5 0.5857 → 0.6714, recall@10 0.65 → 0.7286. To roll back: SEARCH_QUERY_GROUP=and SEARCH_POOL=20 SEARCH_SCOPE_AWARE=false reproduces the old numbers exactly.
load_golden with unreachable targets filtered out, n=132) the same code yields MRR 0.5212, recall@1 0.3939, recall@10 0.7727, while the old pipeline yields 0.4499. Search behaviour did not change; the harness was under-reporting, not the code. Versions may only be compared against each other under an identical harness version.
Current baseline (harness v1.40.0, n=110): MRR 0.6013, recall@1 0.4818, recall@10 0.8364. The latest shift is again the harness, not search: opening an article after a CHANGE OF WORK (a finish_task, save_lesson, edit_article… happened between the query and the open) is no longer credited to the preceding query. The signal is event-based rather than time-based, and it splits the data sharply: without a change of work the median gap is 12 s and no pair exceeds 10 minutes, whereas with one the median is 3.7 hours. That is why no time threshold was needed. On a single snapshot: without boundaries n=131 / MRR 0.5514, with boundaries n=110 / MRR 0.6013.
Previous baseline (harness v1.36.0, n=129): MRR 0.5512, recall@1 0.4419, recall@10 0.7597. That change was also harness-side: search_by_tag was dropped as a source because it does not rank by relevance at all — it walks files and matches the tag (back then it returned hits in directory-traversal order; since v1.91.0 it orders them by article date). Its clicks were measuring the wrong pipeline. The set shrank by just 3 queries while MRR rose by 0.03: tag "queries" were shadowing genuine search queries and claiming their opens. The source-selection criterion was fixed at the same time — it used to be "the set scores better with it" (selection on the metric), and is now "the source exercises the pipeline under measurement" (verified against the code).
What remains after v1.31.0 (breakdown from scripts/diag_retrieval.py, n=132): target ranked first for 39.4%, present but not first for 37.9%, below tenth place in the pool for 18.9%, candidate top-up failed for 0.8%, not found by any channel for 3.0%. In other words candidate selection is closed, the cut-off was never at fault, and the entire remainder (56.8%) is ranking: the target is in the pool, only the order is wrong. The remaining 3.0% is not a search defect but noise in the ground truth: an open attributed to a query made hours earlier.
Ranking tuning (RRF_K, DECAY_WEIGHT, BM25_TITLE_B/BM25_TAGS_B/BM25_BODY_B, RRF_WEIGHT_BM25/RRF_WEIGHT_SEMANTIC) covers the weights INSIDE the selected pool. They apply at query time, so an experiment takes about a minute. Grid: scripts/eval_ranking.py.
Channel weights and field boosts were validated on the cleaned set (n=132) — no improvements, defaults kept. Field boosts: title x3 tags x2 gives MRR 0.5382 against 0.5212, i.e. 3 queries out of 132 — noise; title x8 is already worse than the baseline. Channel weights: disabling semantics lifts MRR to 0.5493 and recall@1 to 0.4470, but the bootstrap CI is [−0.0158, +0.0738] (zero inside), and per query 27 get worse against 25 better — the win rests on a handful of large improvements and would have been a regression in practice.
A grid run over 137 real queries (2026-07-19) found no improvements — defaults left alone: b=1.0 wins a single query out of 137, i.e. noise. The "flat zone" of RRF_K at 10/20/60 turned out not to be a property of the data but an identity: with a two-channel top, the cut-off max(top×0.5, 32) equals exactly the score of a single-channel rank-1 document (3000/(K+1)), so the surviving set does not depend on K. It breaks at K=120, where a single-channel document physically cannot reach the absolute floor of 32 (3000/121 = 24.8) — hence recall@10 dropping to 0.49. RRF_K, keep this coupling with the constant 32 in mind.
set(), i.e. from string-hash randomisation: the same code under different PYTHONHASHSEED values produced MRR between 0.4847 and 0.4970. Since v1.31.0 the order is deterministic (raw score, ties broken by path) and runs match byte for byte. Anything smaller than this floor must not be accepted as an improvement.
track_access updates last_accessed on every result, and the golden set is by construction made of articles that were opened, so every target sits in the decay≈1.0 zone and shares the same multiplier (absolute scores move, ordering does not). DECAY_WEIGHT must not be tuned against these numbers: they are insensitive to it.
Why the reranker is off. Measured by scripts/eval_retrieval.py over 132 real queries from the audit log (ground truth being the articles actually opened after a search):
| Configuration | MRR | recall@1 | recall@5 | recall@10 | time/query |
|---|---|---|---|---|---|
| hybrid | 0.4634 | 0.3636 | 0.5833 | 0.6515 | 0.45 s |
| hybrid + rerank | 0.4535 | 0.3561 | 0.5758 | 0.6515 | 14.5 s |
There is no gain (the shift is one query per level, i.e. noise) at 32× the cost. recall@10 matched structurally: the candidate pool equals 10, so the reranker merely permutes the same 10 documents.
Reopened and closed again (v1.35.0). The caveat above — "worth enabling together with a larger pool" — became testable after v1.31.0, when the pool grew to 100. The measurement was repeated on the cleaned set (n=132): without rerank MRR 0.5151 / recall@1 0.3939; reranking the top-20 by preview gives 0.4769 / 0.3485 (0.038 MRR worse than baseline, twice the distinguishability threshold) at 8.0 s/query; reranking the top-20 by full body gives 0.5073 / 0.3939, i.e. within noise and with an exact recall@1 match, at 22.2 s/query. The per-query tally is +27/−28 — a perfect zero. A truncated preview explains the HARM but not the absence of benefit. The conclusion got stronger: there is no gain even on the pool for which the reranker was proposed to return — and that is on 20 cores, not on the NAS.
Before embedding, every chunk is prefixed with a context header that situates the fragment inside its document (Anthropic's method) — a query finds the right section even when its body contains none of the keywords.
- Phase 1 (metadata): a context header
[project · title · section · tags], automatic, for every article. - Phase 2 (AI context): in multi-section articles each section gets an AI-generated context phrase (one line per section), stored in the YAML frontmatter (
contexts:) and used in place of the metadata. It is populated through two tools:context_gaps(project, limit)— returns articles that need context (multi-section, non-secret, nocontexts:): the full section structure, the list of unfilled sections (pending) and the body of the unfilled sections only — section-wise, with a per-section budget, frontmatter stripped. Append-log### {date}entries are ignored.save_contexts(project, filename, contexts)— validates the headings, writescontexts:into the frontmatter and incrementally re-embeds the article.
The embedding line format is versioned (context_format_version in .embeddings.pkl) — on a mismatch the cache is rebuilt from scratch.
The window size is fitted to the length of the section, so that the section fits into the chunk budget whole instead of being cut off at a fixed ceiling.
This is not cosmetic: before v1.28.0 a hard ceiling of 600 × 4 = 2400 characters per section applied, and everything beyond it was silently discarded. Measured: 26.3% of the text in the base (1,252,908 characters) never reached the embeddings at all, while 70.7% of real queries led to a truncated article.
Three configurations over 133 real queries (scripts/eval_chunking.py):
| Chunking | MRR | recall@5 | recall@10 | Chunks | Coverage |
|---|---|---|---|---|---|
| fixed 600×4 (pre-v1.28.0) | 0.4599 | 0.5865 | 0.6541 | 8034 | ~72% |
| adaptive, budget 4 | 0.4580 | 0.6165 | 0.6842 | 6771 | ~85% |
| adaptive, budget 16 (current) | 0.4626 | 0.6165 | 0.6842 | 7219 | ~93% |
Adaptive mode produces fewer chunks at greater coverage: a wide window covers a section in fewer windows and picks up the tail along the way. The gain is honestly modest — +4 queries out of 133 into the top-5/top-10, with MRR unmoved: what gets fixed is "not found at all", not "not found first".
Tuning: CHUNK_ADAPTIVE (default true), CHUNK_WINDOW_MAX (1200 — the e5-base input limit, ~512 tokens), CHUNK_SUBCHUNKS_CAP (16). Changing any of them changes the chunk texts and invalidates .embeddings.pkl → a full rebuild.
AI context (phase 2) is layered in gradually and is not required for search to work — an article without it falls back to phase 1 metadata. To populate an existing base, ask your assistant to run the backfill; it is a "tool dance" loop the assistant performs on its own:
While context_gaps(project) returns articles:
1. context_gaps(project, limit=5) — fetch a batch of gap articles
2. for each pending section — write one phrase (≤25 words) situating it in the document
3. save_contexts(project, filename, [{heading, context}, …])
4. repeat until remaining == 0
The backfill is interruptible and resumable (the state is the frontmatter itself): new articles are picked up on the next pass. For a large base it parallelises across projects (distinct files, no conflicts). Example request to an assistant: "run the AI context backfill for the infra project" or "for every project".
save_lesson automatically performs:
- A write into the daily log (audit trail)
- Auto-tagging (14 regex rules)
- A semantic lookup for an existing article — merge instead of duplicate
- Cross-references in related articles
- An update to the active-context feed
- Extraction of git references (commits, issues, tags)
- An update to matching tracking articles (version, IP, port, URL) — the current value is owned by tracking, with a guard against version regression; a version the note itself calls a plan, a requirement, not yet done or a condition (
< 0.4.0) is not taken, nor is a bare version that follows only the name of another product (Electron 44.2.0,mcp[cli]==1.28.1); an IP field changes only when the relevant text names exactly one address of the same role - A git commit
Search finds knowledge only when the model thinks to ask. A reflex fires on its own: the client hook sends an event, and the server answers with notes from the base right at the moment of action.
| Client event | What arrives |
|---|---|
A command failed (PostToolUseFailure) |
the article with the fix for that error |
Going to a host, router or database (PreToolUse) |
articles and pointers to the access details for that target |
Reading a file (PostToolUse on Read) |
notes attached to that file |
An article declares what it surfaces on with a ## Рефлексы section (lines - ошибка: …, - цель: …, - файл: …; English error:/target:/file: work too) or with the triggers parameter of save_lesson, finish_task, edit_article. A file trigger needs a path with a directory (memory_compiler/ui.py); a bare name is rejected. A target has a fallback channel — an article title with that address, which is also how pointers to secrets with access details arrive. It works only for address-like values: an IP, a domain, a host name with a digit or a hyphen.
Matching on a word mentioned in the body is deliberately absent, and so is a verbatim channel for errors: both were measured and produce noise — generic messages like "connection refused" appear verbatim in logs quoted by articles. Delivery rules:
- at most three notes;
- each is shown once per session;
- superseded articles never arrive;
- secrets are never decrypted — only the title and a pointer to
read_article.
The endpoint is POST /api/reflex with {"kind": "error|target|file", "text": …, "cwd": …, "exclude": [...]}; the response carries memos and a ready text. The author's hook is the reflex subcommand of the guard script in ~/.claude/hooks and is not part of the repository: it puts the response text into additionalContext.
The basis is a measurement on 13.09.2026 over 873 sessions. Of 432 errors in eleven recurring classes, 225 happened when the fix was already in the base, and after an error the agent looked into the base in 13% of cases.
A live node card. A fact about a node can carry an executable quote: a ## Проверка section with - команда: and - ожидается: lines, or the verify parameter of save_lesson and edit_article. The quote arrives together with the note for that target, so the agent sees what backs the fact: /system identity print => KHV-GW. Only a read-only command is accepted — the quote is executed against live hardware, so the whitelist of verbs is strict, and chains, redirections and substitutions are rejected.
The outcome returns to the base through POST /api/probe with {"target": …, "level": "reachable|verified|stale", "project": …, "file": …}. The client does the comparison: output from a live node is untrusted input, and only the verdict travels to the server — otherwise someone else's banner in that output would get to decide what the knowledge base says. verified and stale require the key of the article whose quote was executed: a node's address also matches secrets with access details that landed there by title, and a "checked by a live command" stamp on those would claim somebody verified their contents. The stamp lives in the .article_meta.json sidecar rather than in the article body: it changes on every command to the hardware, while writing into the article drags a git add -A across the whole base. Into a secret article a quote is accepted only together with content — there is nothing else to check it against when the body is encrypted.
The model writes the quote. Only whoever saw the output can propose a quote, so the model adds it: after a note about a node the agent worked with in this session, the client hook asks for a verify line. Picking the quote from command history was measured and rejected — a whole read-only command to the node right before writing about it appeared in 3.3% of cases. The POST /api/probe target is accepted as a list (up to three): a node is called both by connection name and by address. A note carries targets — those of the requested targets the article was actually found by; without them a quote from an article about one node would apply to another and produce a false stale. The verdict requires full equality of the command: docker ps -a is no longer docker ps, and on the quote docker ps it would report "verified" precisely on a stale fact.
A built-in mobile-friendly UI at http://localhost:8765. Dark and light themes.
- Search — snippets with match highlighting (yellow), a project filter, clickable tags, markdown rendering, breadcrumbs, auto-scroll to the first match
- Answers — a question against the base: the same pipeline as the
asktool (wide candidate pool → fallback across all projects; cross-encoder rerank optional, see above), returning relevant fragments annotated with their source article. There is no generation: the server hosts no LLM, so this is retrieval with sources rather than an invented answer. Secret articles are marked[encrypted]in the results: their body is never placed into the response, and the "Show" button pulls the article through the same request that expands a regular card — under the same access key and the same fail-closed rule (no decryption withoutMC_API_KEY) - Command palette —
Ctrl+K(andCmd+K) from any tab: debounced search,↑/↓to navigate,Enterto open expanded,Escto close - Similar — a sidebar of semantically close articles next to an expanded one: a proximity score, click to jump, and a "follow/frozen" button that pins the list while navigating
- Version timeline — tracking articles get a slider over their bi-temporal snapshots: scroll a fact's history with validity intervals and changed fields highlighted
- Add — an entry form
- Graph — an Obsidian-style animated graph: drag, zoom, pan, project filter, hover highlighting of links
- Compilation — preview and run
- Analytics — most accessed, never used
- Audit — a log of every MCP call
- Language — an RU/EN toggle next to the theme switch. The default comes from
MC_LANG, the choice is remembered in the browser. Interface labels are translated; content (article titles, snippets, audit entries, error texts) comes from the server and stays in Russian
Search results render as an interactive panel inside the chat, not as a wall of text. This is the io.modelcontextprotocol/ui extension (spec 2026-01-26): the search tool carries _meta.ui.resourceUri, the host fetches ui://memory-compiler/search-results.html?v=<server version> (the version in the query string, otherwise the client keeps showing a cached panel for the rest of its session) and renders it in a sandboxed iframe, talking to it over JSON-RPC on postMessage.
The point is not the frame — it is that the model is not in the loop. Clicking a result issues tools/call read_article straight from the iframe to the server and shows the article in the panel. Reading five articles costs five clicks instead of five model turns: no generation, no article bodies filling the context, no waiting.
- Secrets are listed, marked, and openable. A search that matches an encrypted article shows it with a lock. Nothing is decrypted until you click — so the click itself is the deliberate reveal, the same bargain the Web UI's "Show" button makes. (Since v1.87.0 no result carries a
memory://resource link at all, secret or not — the panel opens any of them the same way, viaread_article(project, file).) - Project filter — chips over the results already in hand, filtering without a server round-trip or a model turn.
- The panel reports its own height. Sizing is a negotiation, not CSS: the host sends
containerDimensionsand listens forui/notifications/size-changed. A view that never reports stays stuck in the default height with a scrollbar. - Theme and palette come from the host, not the OS.
prefers-color-schemeinside the sandboxed iframe reflects the Windows theme, not Claude Desktop's, so the panel could render light inside a dark chat. The view appliesthemeand the standard--color-*variables fromhostContext.styles.variables(onui/initializeand inhost-context-changed), keeping its own colours only as fallbacks. - Self-contained by necessity. The host applies
default-src 'none'withconnect-src 'none', so nothing external loads and the view cannot make network calls at all — no CDN, no fonts, not even our own/api/*. CSS and JS are inline; the data arrives from the host asstructuredContent, which thesearchtool already produces. - Served with the MIME the client asks for —
text/html;profile=mcp-app, exactly the type advertised in the client'sinitializecapabilities. (Note for anyone porting this:text/html+skybridgeis the OpenAI Apps SDK type for ChatGPT and will not work here.) - Not in
resources/list. The spec allows omitting UI-only resources, and the listing is about knowledge articles; the host resolves the view by URI from the tool description. - Article titles are inserted as
textContentonly — they are user content, andinnerHTMLwould be an injection vector inside the frame.
Requires a client that declares the extension on initialize; Claude web and desktop do. The server records what each client declared, so an unsupported host is diagnosable rather than mysterious.
22 REST endpoints (/api/*): health, version, login/auth, search, answers from the base (retrieval with sources), semantically similar articles, reflex notes, a live-check verdict, a fact's version timeline, saving, article CRUD, projects, the knowledge graph, analytics, tags, compilation (preview/run), export, audit, logs. Plus / (Web UI), /login and the MCP transports: /mcp (Streamable HTTP, recommended) and /sse (legacy).
- Daily logs auto-compiled at 02:00
- Git versioning of every change
- An embeddings cache for a fast start
- Notifications about stale articles
Three layers of protection, each enabled by an env variable:
| Layer | Variable | What it does |
|---|---|---|
| Authorisation | MC_API_KEY |
Login page + 30-day cookie, Bearer token; for MCP also the X-Api-Key header (?key= only on the legacy /sse) |
| Encryption | MC_ENCRYPT_KEY |
AES-256 for secret articles (save_secret) |
| Audit | automatic | A log of every MCP call, "Audit" tab in the Web UI |
With no variables set, access is open (backwards compatibility). More detail: docs/security.md
A two-link scheme (NAS → PC) whose links are independent:
- NAS — a daily
tarof theknowledge/directory (scripts/mc-backup.sh, cron 04:00, 7-day rotation). Archives land inbackups/. - PC — an independent copy of the archives plus a snapshot of
.env(scripts/mc-backup-pull.ps1, Task Scheduler job "memory-compiler backup pull", 05:00). Retention is 30 days for dailies, with monthly slices (-01) kept indefinitely. A restore drill verifies the integrity of the newest archive (scripts/mc-backup-verify.ps1). - History mirror — a single-file snapshot of the knowledge base git history (
scripts/mc-git-bundle.sh, NAS, cron 04:10) and a live bare mirror built from it on the PC (scripts/mc-git-mirror.ps1, scheduled task "memory-compiler git mirror", 05:10). Archives do contain.git, but reaching the history means unpacking them; the mirror givesgit log,git showand single-file recovery without the NAS.git clone ssh://to the NAS is impossible — DSM has no git, it lives only inside the container.
The Source/EnvFile paths default to values derived from the script's own location (<repo>/scripts/), so the parameters can be omitted. Registering the daily job (replace <repo> with the path to your clone):
schtasks /Create /TN "memory-compiler backup pull" /SC DAILY /ST 05:00 /F `
/TR "pwsh.exe -NoProfile -ExecutionPolicy Bypass -File `"<repo>\scripts\mc-backup-pull.ps1`" -Verify"A one-off run and check: pwsh -File scripts\mc-backup-pull.ps1 -Verify (log at C:\Backups\memory-compiler\pull.log). To restore: unpack the desired archives\knowledge-*.tar.gz into the memory-compiler directory, put back the matching secrets\.env-* (it holds MC_ENCRYPT_KEY), and bring the container up.
memory-compiler/
├── server.py # Entry point (11 lines)
├── memory_compiler/
│ ├── __init__.py
│ ├── config.py # Constants, metadata, shared state
│ ├── search.py # Whoosh BM25F + semantic + reranking + contextual retrieval
│ ├── storage.py # Articles, git, utilities, auto-tagging
│ ├── handlers.py # MCP tool implementations
│ ├── tools.py # MCP tool registration, dispatcher, resources, prompts
│ ├── api.py # REST endpoints, Starlette app
│ ├── ui.py # Web UI HTML template
│ ├── markdown_render.py # Server-side MD rendering (markdown-it-py + Pygments + nh3)
│ ├── obs.py # Observability: structured logging, request_id, error counters
│ ├── retrieval_eval.py # Search evaluation: behavioural golden set + known-item, recall@k/MRR
│ └── maintenance.py # One-off maintenance passes
├── tests/ # pytest (offline: HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1)
│ ├── conftest.py # Fixtures (tmp knowledge dir)
│ └── test_*.py # search, storage, handlers, contextual retrieval, web, concurrency …
├── skills/
│ └── memory-autopilot/
│ └── SKILL.md # The memory autopilot skill for Claude Code
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
pip install pytest pytest-asyncio
pytest tests/ -v| Component | Technology |
|---|---|
| Server | Python 3.12, MCP SDK, Starlette, Uvicorn |
| Full-text search | Whoosh (BM25F) |
| Semantic search | sentence-transformers |
| Storage | Markdown + Git |
The memory-autopilot skill automates the whole cycle — it looks up context, picks the tool and saves the result. The user simply works while memory is managed invisibly.
Installation: copy skills/memory-autopilot/SKILL.md to ~/.claude/skills/memory-autopilot/SKILL.md.
The server itself warns a session about other sessions' writes to the project and reminds it about session_note after long work without a write. It tracks these snapshots per MCP session — and Claude Desktop, when memory-compiler is also listed in claude_desktop_config.json, may hand its own server instance to Code-tab chats, i.e. one MCP session for all of them. To let the server tell chats apart, the client passes the chat id in any call as the service argument _client_session: the server strips it before the tool runs and keeps it out of the audit log, and without it behaves as before. In Claude Code a PreToolUse hook on mcp__memory-compiler__.* does this by returning updatedInput — the original arguments plus _client_session = session_id; no permissionDecision is needed. The parameter is declared in every tool's schema as an optional service field: an argument missing from the schema never gets through the Claude Desktop bridge to the server (verified on Desktop 1.52386 with Claude Code 2.1.266).
1. Task begins → start_task("topic") ← search + session + context
2. In progress → ask("how do I configure X?")
3. Solution found → finish_task(...) ← lesson + session
Projects are created dynamically through add_project(), or automatically by the first write (save_lesson(), finish_task(), session_note() and so on). Each project is its own directory under knowledge/, holding markdown articles. Reading never creates a project: a read-only tool called with an unknown project answers with a hint and names the project with the same name ignoring case, hyphens and underscores (memorycompiler → memory-compiler). A write to such a twin is refused — retry with the real name, or create a separate project explicitly with add_project().
Initial projects can optionally be declared through an environment variable:
PROJECTS=backend,infra,general python server.pyMC_LANG=en switches tool and prompt descriptions to English — the text an MCP client shows the model when it picks a call. Defaults to ru; any unknown value also yields Russian.
Two modes for harvesting knowledge from git:
Mode 1 — repo_path (the server reads git directly):
# .env
GIT_REPOS_PATH=/path/to/your/reposgit_capture(repo_path="/repos/my-project", project="myapp", auto_save=true)
Mode 2 — git_log_raw (the client passes the output of git log):
# Claude runs locally:
git log --format="%H|%s|%an|%aI" --numstat --since="7 days ago"
# and passes the output in the git_log_raw parameterRepeat calls with repo_path automatically process only new commits.
A long article saved in a single finish_task call may never reach the server: the client rejects it as InputValidationError — "input that could not be parsed as JSON".
This is not a memory-compiler limit. The call's arguments are cut off client-side, during generation: thinking tokens share the max_tokens budget with the reply text and with the arguments, leaving no room for a large payload. The JSON arrives unterminated and the call never reaches the server at all — it will not appear in the audit log.
How to tell it apart from a genuine data error: the raw in the error text breaks off mid-word, and its length differs from attempt to attempt. That means a truncated stream, not a bad character — escaping will not help, and repeating the same payload is pointless.
The output ceiling is set on the client side, in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "64000"
}
}The default is 32000 and the documented maximum is 64000; it takes effect when the application restarts. Amazon Bedrock users are advised to go the other way and set 4096 — its throttling logic differs.
content within ~1500 characters of ordinary text; a raised ceiling comfortably takes ~4000. Long analysis is better saved as its own article and linked to: headings carry ×5 weight in search, while text buried in another topic's content will not surface under its own topic.
\uXXXX escape sequences — in the latter case a single letter occupies six characters, so the same text exhausts the budget six times faster. The raw tells them apart: readable text is the ordinary case, an unbroken ribbon of \uXXXX is the escaped form, where the content limit drops to roughly 400 characters. If a retry with the same text in meaning goes through, the form changed, not the length.
memory-compiler is built in the evenings. If it saved you a few hours, you can support the project on Boosty — no paywalls and no obligations, just thanks. Stars and bug reports are appreciated too.