Local, personal spaced repetition over your Markdown notes. Point it at an Obsidian vault and it turns your notes into flashcards, schedules them with FSRS-5, and quizzes you in the terminal. Everything runs on your machine — no accounts, no sync service, no data leaving the box unless you point it at a hosted model yourself.
It also exposes your vault over MCP, so an LLM client like Claude Desktop can search and read your notes.
notes/*.md ──sync──> cards (FSRS) ──review──> ratings ──> next due date
│ │
└──────────> ChromaDB ──> search / MCP └──> missed cards explained by the LLM
sync walks the vault, stamps each note with a stable uid, asks the model for cards, and
indexes the note for semantic search. Re-running it is cheap: a note is only re-processed when
its body actually changed, so nothing is regenerated and no duplicate cards appear.
- Python 3.14+
- uv
- A model. The default is
llama3.2:3bon a local Ollama, but anything litellm supports works
uv sync
# Local model via Docker (skip if you're using a hosted provider)
docker compose up -d ollama
docker exec ollama ollama pull llama3.2:3b
cp .env.sample .env # then edit if you want anything other than the defaults
mkdir -p notes # or point LEXICON_NOTES_DIR at an existing vault
uv run lexicon sync
uv run lexicon review| Command | What it does |
|---|---|
sync |
Parse notes, generate cards, index for search. Safe to re-run. |
review [--topic T] [--limit N] [--all] |
Review what's due, one card per screen. --all ignores the daily limit. |
stats [--topic T] |
Card counts by state, predicted recall, true retention, streak. |
search QUERY [--topic T] |
Semantic search across your notes. |
cards list [--topic T] [--limit N] [--suspended] |
Cards in due order. |
cards suspend ID / cards unsuspend ID |
Take a card out of / back into rotation. |
prune [--yes] |
Clean up notes you deleted from the vault. Dry-run unless --yes. |
serve |
Run the MCP server over stdio. |
--topic matches a topic and everything nested under it, so --topic aws also covers
aws/saa.
During a review: space reveals the answer, 1–4 rate it (Again / Hard / Good / Easy, each showing how far out it pushes the card), s suspends, u undoes the last rating, q quits. Cards you got wrong are explained by the model at the end of the session.
A bare .md file dropped anywhere in notes/ is already valid — no frontmatter required.
Everything below is optional:
---
title: B-tree Indexes # optional; falls back to the first "# H1", then the filename
tags: [postgres, indexing] # optional
cards: generate # generate (default) | extract | none
---
Your note body.
<!-- lexicon-card question="What does a B-tree index cost on write?" answer="Extra write amplification — every INSERT and UPDATE maintains the index pages." -->The topic comes from the folder path, so notes/aws/saa/vpc.md has topic aws/saa.
Card modes:
generate(the default) — the model writes question/answer pairs from the note.extract— the note already contains questions (an exam dump, a practice test); pull them out verbatim rather than inventing new ones.none— index the note for search, but make no cards from it.
Manual <!-- lexicon-card --> annotations are always picked up regardless of mode, and are
stripped from the text before indexing.
uid is stamped automatically on first sync. Don't edit it — it's what keeps your review
history attached to a note when you rename or move the file. Two notes with the same uid is
an error; delete the line from one and re-sync to get a fresh one.
Everything is environment variables with a LEXICON_ prefix, read from .env:
| Variable | Default | |
|---|---|---|
LEXICON_AI_MODEL |
ollama/llama3.2:3b |
Any litellm model string |
LEXICON_AI_BASE_URL |
(unset) | Override the provider endpoint |
LEXICON_NOTES_DIR |
notes |
Your vault |
LEXICON_DATA_DIR |
data |
Where the DB and index live |
LEXICON_DATABASE_URL |
sqlite:///data/lexicon.db |
|
LEXICON_CHROMA_PERSIST_DIR |
data/chroma |
|
LEXICON_DESIRED_RETENTION |
0.9 |
FSRS target — higher means more frequent reviews |
LEXICON_DAILY_REVIEW_LIMIT |
20 |
Cards per review session |
LEXICON_NEW_CARDS_PER_DAY |
10 |
Caps how fast new material enters rotation |
LEXICON_CARDS_PER_NOTE |
5 |
Target when generating |
LEXICON_MAX_NOTE_CHARS |
24000 |
Sync fails past this rather than chunking blindly |
lexicon serve exposes four read-only tools — search_knowledge, read_note, get_stats,
list_topics — so a client can answer questions from your notes and tell you what you're
weakest on.
Because every path defaults to a relative one, the client must launch the server with the project as its working directory:
{
"mcpServers": {
"lexicon": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/Lexicon", "lexicon", "serve"]
}
}
}src/lexicon/
├── cli.py typer commands
├── config.py pydantic-settings
├── exceptions.py LexiconError base
├── db/ SQLAlchemy models + engine
├── core/ parser, scheduler (FSRS), review session, sync pipeline
├── ai/ card generation, explanations for missed cards
├── search/ ChromaDB indexing and query
├── ui/ all Rich rendering, formatting, key input
└── mcp/ FastMCP server
Notes live in notes/, state in data/ — both gitignored.