Skip to content

About

MCP server for AI assistant persistent memory. Hybrid search (BM25F + semantic), auto-compilation, web UI.

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

memory-compiler

English · Русский

A personal knowledge base for AI assistants. An MCP server with hybrid search, auto-compilation and a web interface.

Why

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.

Quick start

pip install -r requirements.txt
mkdir -p knowledge
PROJECTS=my-project,infra,general python server.py

The server starts on http://localhost:8765. Or via Docker:

docker-compose up -d --build

First start and the model cache

On 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/health

semantic 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.

Connecting to Claude Code

{
  "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.

Connecting to Claude Code Desktop

Full walkthrough: docs/claude-desktop-setup.en.md — MCP, the memory-autopilot skill, hooks, dependency setup.

Connecting to Kimi Work

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.

Features

50 MCP tools

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

Hybrid search + reranking

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; the EMBED_MODEL env var switches to BAAI/bge-m3 1024d or Alibaba-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=1 turns 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 via SPLADE_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.

⚠️ The figures above were taken with a pre-v1.32.0 harness. On the cleaned set (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. ⚠️ At n=132 only shifts from ~0.024 MRR are distinguishable against an available ceiling of 0.31 — beyond that the limit is the size of the set, not the supply of ideas.

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. ⚠️ When changing RRF_K, keep this coupling with the constant 32 in mind.

⚠️ The measurement noise floor is up to 4 queries out of 140 on recall@1. The order of equally scored documents used to come from iterating a 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.

⚠️ Temporal decay cannot be measured with this harness — a limitation of the method, not a defect. 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.

Contextual Retrieval

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, no contexts:): 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, writes contexts: 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.

Adaptive chunking

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.

Backfilling AI context

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".

Smart saving

save_lesson automatically performs:

  1. A write into the daily log (audit trail)
  2. Auto-tagging (14 regex rules)
  3. A semantic lookup for an existing article — merge instead of duplicate
  4. Cross-references in related articles
  5. An update to the active-context feed
  6. Extraction of git references (commits, issues, tags)
  7. 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
  8. A git commit

Memory reflexes

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.

Web interface

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 ask tool (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 without MC_API_KEY)
  • Command palette — Ctrl+K (and Cmd+K) from any tab: debounced search, ↑/↓ to navigate, Enter to open expanded, Esc to 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

MCP Apps

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, via read_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 containerDimensions and listens for ui/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-scheme inside the sandboxed iframe reflects the Windows theme, not Claude Desktop's, so the panel could render light inside a dark chat. The view applies theme and the standard --color-* variables from hostContext.styles.variables (on ui/initialize and in host-context-changed), keeping its own colours only as fallbacks.
  • Self-contained by necessity. The host applies default-src 'none' with connect-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 as structuredContent, which the search tool already produces.
  • Served with the MIME the client asks for — text/html;profile=mcp-app, exactly the type advertised in the client's initialize capabilities. (Note for anyone porting this: text/html+skybridge is 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 textContent only — they are user content, and innerHTML would 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.

REST API

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).

Automation

  • Daily logs auto-compiled at 02:00
  • Git versioning of every change
  • An embeddings cache for a fast start
  • Notifications about stale articles

Security

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

Backups

A two-link scheme (NAS → PC) whose links are independent:

  • NAS — a daily tar of the knowledge/ directory (scripts/mc-backup.sh, cron 04:00, 7-day rotation). Archives land in backups/.
  • 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 gives git log, git show and single-file recovery without the NAS. git clone ssh:// to the NAS is impossible — DSM has no git, it lives only inside the container.

Installation (Windows, the PC link)

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.

Project layout

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

Tests

pip install pytest pytest-asyncio
pytest tests/ -v

Stack

Component Technology
Server Python 3.12, MCP SDK, Starlette, Uvicorn
Full-text search Whoosh (BM25F)
Semantic search sentence-transformers
Storage Markdown + Git

Workflow

With memory-autopilot (recommended)

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). ⚠️ Desktop caches the tool list for the lifetime of the app — restart it fully after updating the server.

Manual mode

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

Configuration

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.py

Description language

MC_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.

⚠️ Server responses stay in Russian: only the descriptions are translated. This is a deliberate half-step — descriptions are what the model reads when choosing a tool, and they matter more.

Git Capture

Two modes for harvesting knowledge from git:

Mode 1 — repo_path (the server reads git directly):

# .env
GIT_REPOS_PATH=/path/to/your/repos
git_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 parameter

Repeat calls with repo_path automatically process only new commits.

Claude Code output limit

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.

⚠️ Non-Latin text costs more tokens. On the default ceiling keep 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.

⚠️ Count what actually goes into the call, not the readable text. JSON accepts non-Latin characters both literally and as \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.

Support

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.

Boosty

License

MIT

About

MCP server for AI assistant persistent memory. Hybrid search (BM25F + semantic), auto-compilation, web UI.

Resources

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages