This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Self-hosted semantic memory + Todoist integration. Written in Go as a single static binary.
Stack:
- Qdrant — vector database for storing embeddings (shared
infra-qdrantin production) - text-embeddings-inference (TEI) — local embedding model server
- Traefik v3 — reverse proxy with Let's Encrypt SSL
- mcp-go + Chi — Go HTTP server and MCP implementation
Two Docker services in this repo: memory-embeddings (TEI), memory-mcp (Go server). Qdrant is provided by the infra stack (infra-qdrant) and reached on the infra Docker network. TEI and Qdrant are internal — not exposed outside Docker networks.
Client → Traefik (mcp.<domain>) → memory-mcp:8000 (Go)
├─ /memory → memory MCP (X-API-Key)
│ (tools include RAG when ENABLE_RAG=true)
├─ /todoist → todoist MCP (X-API-Key, ENABLE_TODOIST)
├─ /viz/ → viz dashboard (Authentik ForwardAuth, ENABLE_VIZ)
└─ /health → liveness (no auth)
Single Go process serves all routes on one port via Chi router. MCP endpoints are protected by an X-API-Key middleware in application code. /viz is protected at the Traefik layer with Authentik ForwardAuth so browsers get a proper OIDC login flow.
Todoist, viz, and RAG are toggled by ENABLE_TODOIST / ENABLE_VIZ / ENABLE_RAG env vars. Backup runs as a goroutine.
| Collection | Purpose |
|---|---|
memory |
facts written via store_fact (default memory layer) |
doc_chunks |
markdown chunks from RAG_DOCUMENTS_DIR (when ENABLE_RAG=true) |
doc_folders |
folder summaries for hierarchical search (when ENABLE_RAG=true) |
Collection name is now a qdrant.Client field, not a constant — one client per collection.
cmd/
server/main.go — entrypoint, Chi router, graceful shutdown
indexer/main.go — standalone RAG indexer binary (cron-friendly)
internal/
config/ — env vars → struct
middleware/auth.go — X-API-Key + Bearer auth
qdrant/client.go — Qdrant REST client (upsert, search, scroll, delete, snapshots, field index)
embeddings/client.go — TEI REST client (Embed + EmbedBatch, batch size 32)
memory/
server.go — 11 memory MCP tools
cache.go — in-memory cache with TTL + invalidation
rag/
chunker.go — markdown-aware chunking (heading → paragraph → sentence)
summarizer.go — folder summaries (filenames + first H1/H2/H3)
indexer.go — walk + incremental upsert, stale cleanup, batched embeds
server.go — MCP tools: search_documents, reindex_documents
todoist/
client.go — Todoist REST API v1 client
server.go — 7 MCP tools
viz/
handler.go — Chi subrouter: /{tab}, /api/facts, /api/graph, /api/duplicates, /api/documents, /assets/*
similarity.go — cosine similarity
static/ — embedded dashboard: shell.html + views/*.html + assets/{styles.css, js/*.js}
backup/loop.go — snapshot + prune goroutine
store_fact(fact, tags?, namespace?, permanent?, valid_until?)— embed and save a fact; deduplicates (cosine ≥ 0.97); warns on contradictions (0.60–0.97)update_fact(old_query, new_fact, ...)— find by similarity, replace, preserve metadatadelete_fact(query, namespace?)— find by similarity and deleteforget_old(days?, namespace?, dry_run?)— delete old facts; skipspermanent=true; defaults to dry runimport_facts(facts)— bulk import from JSON string
recall_facts(query, tags?, namespace?, limit?)— semantic search with scores; filters expired; async-incrementsrecall_countlist_facts(tags?, namespace?)— list all facts with metadatafind_related(query, namespace?, limit?)— related but non-duplicate facts (score 0.60–0.97)get_stats()— counts, namespace/tag breakdown, most recalledlist_tags(namespace?)— all tags with countsexport_facts(namespace?)— export as JSON
get_projects,get_labels,get_tasks,create_task,update_task,complete_task,delete_task
search_documents(query, limit?, mode?)— hierarchical search by default: top folders first, then chunks inside those folders, with flat fallback.mode="flat"forces a single-collection vector search.reindex_documents()— launches incremental re-indexing in a background goroutine. Skips unchanged files (SHA256 hash). Mutex-guarded — only one reindex at a time. Stale-file cleanup is aborted if the walk was incomplete or would remove >50% of the index.
text string — the fact
namespace string — logical group (default: "default")
tags []string — labels
permanent bool — never deleted by forget_old
valid_until string — ISO date; expired facts excluded from search
created_at string — ISO datetime
updated_at string — ISO datetime (set on update)
recall_count int — times returned by recall_facts
last_recalled_at string — ISO datetime
user string — from MEMORY_USER env var
- New points: deterministic UUID-v5-like hex (SHA1 of text, formatted
8-4-4-4-12) - Legacy points (from old Python implementation): numeric integer IDs
The Qdrant client unmarshals id into interface{} and converts to string with parsePointID. Don't assume IDs are always strings — Qdrant returns whatever was stored.
| Variable | Default | Description |
|---|---|---|
API_KEY |
— | Shared secret for X-API-Key header (generate with openssl rand -hex 32). If empty, auth is disabled. |
MEMORY_USER |
claude |
Username stored in fact metadata |
MEMORY_DOMAIN |
required | Domain — MCP at mcp.<domain> (used by Traefik labels in deploy) |
QDRANT_URL |
http://memory-qdrant:6333 |
Qdrant endpoint. In production: http://infra-qdrant:6333 |
EMBED_URL |
http://memory-embeddings:80 |
TEI endpoint |
ENABLE_TODOIST |
false |
Enable Todoist MCP server |
ENABLE_VIZ |
false |
Enable visualization dashboard |
TODOIST_TOKEN |
— | Todoist API token (only when ENABLE_TODOIST=true) |
CACHE_TTL |
60 |
Search cache TTL in seconds |
DEDUP_THRESHOLD |
0.97 |
Cosine similarity for dedup |
CONTRADICTION_LOW |
0.60 |
Lower bound for contradiction warning |
KEEP_SNAPSHOTS |
7 |
Snapshots to retain |
BACKUP_INTERVAL_HOURS |
24 |
Backup frequency in hours |
VIZ_SIMILARITY_THRESHOLD |
0.65 |
Cosine similarity threshold for graph edges |
MCP_PORT |
8000 |
HTTP port |
ENABLE_RAG |
false |
Enable document RAG tools (search_documents, reindex_documents) |
RAG_DOCUMENTS_DIR |
/root/documents/personal |
Root directory to index. Hidden dirs (.git, .sync) are skipped. |
RAG_CHUNK_MAX_BYTES |
1500 |
Max chunk size in bytes (heading → paragraph → sentence → hard split) |
RAG_FOLDER_TOP_K |
3 |
Top N folders to consider in hierarchical search |
RAG_FOLDER_THRESHOLD |
0.50 |
Min folder similarity score; below this, fall back to flat chunk search |
RAG_COLLECTION_CHUNKS |
doc_chunks |
Qdrant collection for chunks |
RAG_COLLECTION_FOLDERS |
doc_folders |
Qdrant collection for folder summaries |
RAG_REINDEX_INTERVAL_MINUTES |
0 |
How often the in-server goroutine auto-re-indexes. 0 disables (run manually or via cmd/indexer). |
Never hardcode credentials. Use .env file (excluded from git).
InitCollectionruns at startup — embeds "init" to get vector size, creates collection if missingcache.Invalidate()is called after any write operation (store, delete, update, import, forget_old)recall_countis updated viaqdrant.SetPayloadin a background goroutine — no re-embeddingforget_olddefaults todry_run=true— safe by default- New point IDs are SHA1-based hex UUIDs (deterministic by text); legacy numeric IDs are handled on read
- TEI and Qdrant accessed via Docker network (no auth needed)
- Collection name is a struct field —
NewClient(url, collection string)— one client per collection - Both
SearchandScrollreceive point IDs asinterface{}and normalize viaparsePointID - Scroll uses
interface{}for offset to handle both string and numeric next_page_offset CreateFieldIndex(field, schema)creates payload indexes (used by RAG for fastfile_path/folder_pathfiltering)- Supports snapshot create/list/delete for the backup loop
- Single
ScrollAllat the start ofRunsnapshots every file's hash + expected chunk count; per-file hash checks are in-memory afterwards (no N+1 round-trips) - Files are truly "unchanged" only when hash matches AND
actualCount == totalChunks— a half-indexed file (partial upsert from a prior run) is detected and rebuilt - Embeds are batched via
embeddings.Client.EmbedBatch(TEI sub-batches of 32) - Embed-then-delete ordering: old chunks are deleted only after all embeddings succeed
- Stale cleanup aborts if the walk had any errors OR if it would remove more than half the known files (guards against transient Resilio/FS glitches wiping the index)
- Walk skips hidden dirs (
.git,.sync,.trash, …) except for the root
Server.EnsureCollections/ package-levelrag.EnsureCollections— create collections + payload indexes; shared between server and standalone indexer binaryreindex_documentsis mutex-guarded (sync.Mutex.TryLock) and runs on the server-lifetime context so graceful shutdown cancels in-flight reindexingsearch_documentsreturns file paths relative toRAG_DOCUMENTS_DIR(no absolute server paths leak to clients)
- Thin wrapper over Todoist REST API v1 (
https://api.todoist.com/api/v1) TODOIST_TOKENis read from env at startup — never passed by the client- Stateless — no caching, no local storage
- Chi subrouter with JSON APIs (
/api/facts,/api/graph,/api/duplicates,/api/documents), asset server (/assets/*), and a shell handler for/+/{tab}— all tab paths return the same HTML (SPA with History API routing on the client) static/subtree is embedded via//go:embed all:static;shell.htmlis composed with view fragments at handler construction time, cached ascomposedHTMLWithDocumentRAG(chunks, docsDir)is an opt-in hook; when nil,/api/documentsreturns 404 and the Documents tab is hidden client-side- Graph API computes pairwise cosine similarity in-process; caps at
max_edgesstrongest edges - No auth check here — protected at Traefik layer by Authentik ForwardAuth
- Chi router with logger + recoverer middleware
- Public:
/health(no auth) chi.GroupappliesAPIKeyAuthmiddleware to/memoryand/todoist/vizmounted outside the auth group so Traefik/Authentik handles auth insteadsignal.NotifyContextfor graceful shutdown; backup goroutine respects context- Single
StreamableHTTPServerper MCP server (memory, todoist)
go build ./cmd/server
go test ./...docker build -t personal-memory .Builder stage: golang:1.24-alpine, CGO disabled, static binary.
Runtime stage: alpine:3.21 + ca-certificates — final image ~32MB.
.github/workflows/docker.yml: on push to main, runs go test then builds and pushes to ghcr.io/dzarlax-ai/personal-memory:{latest,sha}.
Production deploy configs live in personal_ai_stack/deploy/memory/. Deploy skill (deploy-personal) handles syncing configs and pulling the latest image on the VPS.
After setup:
curl https://mcp.<domain>/health→okhttp://localhost:6333/dashboardon the VPS shows thememorycollection- Legacy data from the Python implementation is read transparently (numeric IDs handled)