"I'll build my own memory framework β with FTS5 and sqlite-vec."
A unified search + memory + knowledge-graph system that runs on what's already on your machine. No new infra, no LLM required, no cloud dependency. Everything lives in one SQLite database.
Document search: BM25 + vector + RRF fusion + LLM reranking across markdown, code, and notes. Agent memory: Store, recall, forget, and extract memories with automatic deduplication and Weibull decay. Knowledge graph: Temporal subject-predicate-object triples β "what was true when?"
Lotl = Living-off-the-Land (the cybersecurity term for "use what's already there to avoid new infrastructure"). Repurposed here: FTS5 + sqlite-vec + local ONNX embeddings via @huggingface/transformers. Cloud APIs (ZeroEntropy, SiliconFlow, Nebius, Gemini, OpenAI) are opt-in, not required.
Evolved from a fork of tobi/qmd β see the origin story below. CLI binary
lotlis the canonical name (theqmdalias was dropped at v1.0.0 β hard break over back-compat). Env vars areLOTL_*; virtual paths arelotl://.
npm install -g @tanarchy/lotl
# Index your documents
lotl collection add ~/notes --name notes
lotl collection add ~/work/docs --name docs
lotl context add lotl://notes/ "Personal notes and ideas"
lotl context add lotl://docs/ "Work documentation"
lotl embed
# Search
lotl search "project timeline" # BM25 keyword search
lotl vsearch "how to deploy" # Vector semantic search
lotl query "quarterly planning process" # Hybrid + reranking (best quality)
# Get documents
lotl get "docs/api-reference.md"
lotl get "#abc123" # by docid
lotl multi-get "journals/2025-05*.md" # by glob pattern
Lotl exposes all functionality via MCP (Model Context Protocol). Works with Claude Desktop, Claude Code, Cursor, OpenClaw, and any MCP client.
| Tool | Description |
|---|---|
query | Hybrid search with typed sub-queries (lex/vec/hyde) + reranking |
get | Retrieve document by path or docid |
multi_get | Batch retrieve by glob or comma-separated list |
status | Index health and collection info |
briefing | Agent wake-up context: collections, contexts, search strategy |
| Tool | Description |
|---|---|
memory_add / memory_add_batch | Store memory/memories with auto-dedup (hash + cosine) and auto-classification |
memory_search | Hybrid search (FTS + vector RRF fusion + keyword expansion + synonym expansion) |
memory_recall_tiered | Search grouped by tier (core/working/peripheral), per-tier limit |
memory_push_pack | Pre-query bundle for session start β core + important-recent + hot-tail |
memory_get / memory_list | Fetch by id / list by filters |
memory_delete / memory_update | Delete / update text, importance, category (re-embeds on text change) |
memory_extract | Extract memories from conversation text (LLM + heuristic fallback) |
memory_reflect / memory_dream | Post-retrieval synthesis / overnight consolidation |
memory_stats | Count by tier, category, scope |
memory_register_scopes | Register scopes for partition-key vec0 queries |
| Tool | Description |
|---|---|
knowledge_add | Store a fact with time validity (auto-invalidates conflicting prior facts) |
knowledge_search | Query facts by subject/predicate/object, optionally at a point in time |
knowledge_invalidate | Mark a fact as no longer valid (preserved in history) |
knowledge_entities / knowledge_timeline / knowledge_stats | Enumerate entities / temporal scans / counts |
| Tool | Description |
|---|---|
manage | Administrative ops: embed, update, cleanup, sync, decay |
Claude Code (fastest β 2 commands):
npm install -g @tanarchy/lotl
# macOS / Linux:
claude mcp add lotl lotl mcp --scope user
# Windows: (Node spawn won't auto-resolve bare `lotl` to .cmd)
claude mcp add lotl lotl.cmd mcp --scope user
Restart Claude Code. The 26 tools (memory_*, knowledge_*, doc_*) appear. Verify with claude mcp list β should show β Connected.
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json macOS, %APPDATA%\Claude\claude_desktop_config.json Windows):
{
"mcpServers": {
"lotl": { "command": "lotl", "args": ["mcp"] }
}
}
Local-dev install (from source clone) β for testing unreleased work:
cd lotl/
npm install && npm run build && npm link
claude mcp add lotl lotl mcp --scope user
HTTP transport (shared daemon, models stay loaded across MCP clients):
lotl mcp --http --daemon # start on localhost:8181
lotl mcp stop # stop
By default a single user-scope MCP server (claude mcp add lotl lotl mcp --scope user)
serves every project from one shared index at ~/.cache/lotl/index.sqlite, and
memory uses the global scope. Set LOTL_ONNX=on once in ~/.config/lotl/.env
and the local model stack applies everywhere.
For hard per-project isolation β a private index per repo, no cross-project
memory or document bleed β drop a .mcp.json at the repo root. A project-scoped
server with the same name overrides the global one inside that repo:
{
"mcpServers": {
"lotl": {
"command": "lotl.cmd",
"args": ["mcp"],
"env": { "INDEX_PATH": ".lotl/index.sqlite", "LOTL_ONNX": "on" }
}
}
}
INDEX_PATH is resolved against the server's working directory (the repo root),
and its parent dir is created automatically on first run. Commit .mcp.json,
gitignore .lotl/. On macOS/Linux use "command": "lotl" (drop the .cmd).
Then, inside the repo: lotl collection add . --name <repo> && lotl embed.
One line in ~/.config/lotl/.env:
LOTL_ONNX=on
LOTL_ONNX=on is the umbrella switch (default off β no models, BM25 only). When on, it auto-enables the full local-ONNX stack with the preconfigured defaults β embeddings (mixedbread-ai/mxbai-embed-xsmall-v1 @ q8) and the local cross-encoder rerank (jinaai/jina-reranker-v1-tiny-en) β no model names to set. Then lotl embed once.
Want to tune it? The granular vars still work and override the toggle:
LOTL_ONNX=on
LOTL_TRANSFORMERS_MODEL=mixedbread-ai/mxbai-embed-xsmall-v1 # override embed model
LOTL_TRANSFORMERS_DTYPE=q8 # override dtype
LOTL_VEC_MIN_SIM=0.1 # cosine acceptance floor
Benchmarks (full reproduction recipes + per-config metrics in evaluate/SNAPSHOTS.md).
LongMemEval _s n=500 (session-id retrieval, RAW recall) β winner is mxbai-xs q8:
| System | recall_any@5 | R@5 (fractional) | MRR | NDCG@10 | Pref MRR | Wall |
|---|---|---|---|---|---|---|
| lotl / mxbai-xs q8 (default) | 98.4% | 93.7% | 0.917 | 0.913 | 0.745 | 26 min |
| lotl / UAE-Large 1024d | 98.0% | 93.8% | 0.921 | 0.919 | 0.714 | 145 min |
| lotl / gte-small 384d | 97.8% | 93.2% | 0.919 | 0.914 | 0.703 | 26 min |
| lotl / bge-large 1024d | 98.0% | 93.6% | 0.917 | 0.917 | 0.680 | 147 min |
| lotl / jina-v5-nano 768d | 95.4% | 89.6% | 0.860 | 0.849 | 0.533 | ~5 h |
| agentmemory hybrid | 95.2% | β | 0.882 | 0.879 | β | β |
| MemPalace raw | 96.6% | β | β | β | β | β |
n=500 sweep across 5 candidates concluded mxbai-xs stays default β challengers cost 5-15Γ params for tied or worse retrieval, all regressed on preference MRR.
LongMemEval _s n=500 with LLM judge (Phase 7):
| Generator | Judge | Judge-Acc | n | Notes |
|---|---|---|---|---|
| gpt-4o (Phase 7.1b, n=100) | gpt-4o | 64.0% | 100 | Matches LongMemEval paper baseline |
| Poe gpt-4o-mini | Poe gpt-4o | 47.0% | 134 | Quota hit at q55, partial result |
| Gemini-2.5-flash | Gemini-2.5-flash | 29.7% | 499 | Generator-bound β Gemini-flash hedges |
LoCoMo (10 convs, n=1986) with LLM judge β generator + judge = gemini-2.5-flash:
| Metric | Value |
|---|---|
| R@5 | 67.6% |
| MRR | 0.593 |
| F1 | 66.2% |
| Judge-Acc | 81.4% |
vs published LoCoMo: Mem0 91.6% (GPT-4 class), Hindsight 89.6% (top backbone). With a stronger generator (gpt-4o, gemini-2.5-pro), Lotl's 81.4% is expected to climb into the 85-90% range on the same retrieval layer.
See devnotes/metrics/metric-discipline.md for recall_any@K (binary, agentmemory/mem0/MemPalace style) vs R@K (fractional, LongMemEval paper) distinction. Eval harness CLI flags + reproduction recipes in evaluate/longmemeval/README.md and evaluate/locomo/README.md. Honest-harness rationale (top-k=10 not the MemPalace top-k=50 cheat) in evaluate/locomo/HYBRID_HARNESS.md.
What these four lines do:
@huggingface/transformers β no cmake, no GPU, ~50 MB download on first use.LOTL_VEC_MIN_SIM=0.1 β overrides the adaptive cosine acceptance floor (tight-cluster q8 models need this; default floor prunes too aggressively).Under the hood (all shipped, no config needed):
LOTL_MEMORY_RERANK=on (optional, +1-2pp MRR; blend hardcoded 0.5/0.5).All tunables hardcoded in src/store/constants.ts (validated at n=500 LME). See docs/ROADMAP.md "2026-04-17" for full sweep history.
Copy .env.example to ~/.config/lotl/.env. Loaded automatically.
Each operation (embed, rerank, query expansion) is configured independently:
QMD_{OP}_PROVIDER= # local | api | url | gemini (or alias: siliconflow, openai, zeroentropy, dashscope)
QMD_{OP}_API_KEY= # Bearer token
QMD_{OP}_URL= # base URL (api) or full endpoint (url)
QMD_{OP}_MODEL= # model name
Provider modes:
api β OpenAI-compatible base URL (paths /embeddings, /rerank, /chat/completions auto-appended)url β direct endpoint URL (used as-is)gemini β Google Gemini (x-goog-api-key auth)siliconflow, openai, zeroentropy, dashscope set mode + default URL automaticallyLOTL_EMBED_PROVIDER=zeroentropy
LOTL_EMBED_API_KEY=ze_your-key
LOTL_EMBED_MODEL=zembed-1
LOTL_RERANK_PROVIDER=zeroentropy
LOTL_RERANK_API_KEY=ze_your-key
LOTL_RERANK_MODEL=zerank-2
LOTL_RERANK_MODE=rerank
LOTL_QUERY_EXPANSION_PROVIDER=api
LOTL_QUERY_EXPANSION_API_KEY=nebius-key
LOTL_QUERY_EXPANSION_URL=https://api.studio.nebius.ai/v1
LOTL_QUERY_EXPANSION_MODEL=meta-llama/Meta-Llama-3.1-8B-Instruct
LOTL_EMBED_PROVIDER=siliconflow
LOTL_EMBED_API_KEY=sk-your-key
LOTL_EMBED_MODEL=Qwen/Qwen3-Embedding-8B
LOTL_RERANK_PROVIDER=siliconflow
LOTL_RERANK_API_KEY=sk-your-key
LOTL_RERANK_MODEL=BAAI/bge-reranker-v2-m3
LOTL_RERANK_MODE=rerank
LOTL_QUERY_EXPANSION_PROVIDER=siliconflow
LOTL_QUERY_EXPANSION_API_KEY=sk-your-key
LOTL_QUERY_EXPANSION_MODEL=zai-org/GLM-4.5-Air
Embed can also run locally via LOTL_EMBED_BACKEND=transformers (ONNX, opt-in). Rerank and query expansion are remote-only.
Agents store and recall memories alongside document search. Same database, same providers, same search pipeline.
memory_store({ text: "David prefers ZeroEntropy for reranking", category: "preference", importance: 0.8 })
β Embeds text β checks hash dedup β checks cosine dedup (β₯0.9) β stores in memories + memories_fts + memories_vec
memory_recall({ query: "what reranker does David use" })
β FTS search + vector search β RRF fusion β keyword boost β decay weighting β top results
memory_extract({ text: "We decided to use SQLite. I prefer TypeScript for backend work." })
β Pattern matching detects [decision] and [preference] β stores each with category + importance
preference β "I prefer X", "I don't like Y"fact β "The API limit is 100/min", "David works at Tanarchy"decision β "We decided to use X", "Let's go with Y"entity β "Vincent runs Ubuntu", "Arachnid is the code agent"reflection β "I realized that...", "Looking back..."other β everything elseAuto-classified when no category is provided, using 16 regex patterns.
Memories fade over time unless accessed frequently or marked important.
composite = 0.4 Γ recency + 0.3 Γ frequency + 0.3 Γ importance
Three tiers with automatic promotion:
Run manage({ operation: "decay" }) to evaluate and promote/demote.
Store facts with time validity windows. When facts change, old values are preserved with timestamps.
knowledge_store({ subject: "David", predicate: "prefers", object: "ZeroEntropy" })
β Later:
knowledge_store({ subject: "David", predicate: "prefers", object: "Nebius" })
β Auto-invalidates old "ZeroEntropy" fact, stores new "Nebius" fact
knowledge_query({ subject: "David", predicate: "prefers" })
β Returns: David β prefers β Nebius (current)
knowledge_query({ subject: "David", as_of: <last week> })
β Returns: David β prefers β ZeroEntropy (was valid then)
Entity names are auto-normalized: "David Gillot" β "david_gillot".
Query
β
βββββββββββββ΄ββββββββββββ
βΌ βΌ
BM25 (FTS5) Vector (sqlite-vec)
keyword match semantic similarity
β β
βββββββββββββ¬ββββββββββββ
βΌ
RRF Fusion (k=60)
BM25 weight: 0.8
Vector weight: 0.2
β
βΌ
Zero-LLM Score Boosts
β’ keyword overlap (+30%)
β’ quoted phrase match (+60%)
β’ person name match (+40%)
β’ stop word filtering
β
βΌ
LLM Reranking (optional)
(ZeroEntropy / SiliconFlow / Gemini / OpenAI)
β
βΌ
Rerank Blend (hardcoded v1.0.0)
50% RRF / 50% reranker
(was position-aware pre-Phase-6;
sweep showed flat 0.5/0.5 wins)
β
βΌ
Final Results
All scoring parameters are env-configurable. See .env.example.
# Collections
lotl collection add <path> --name <name>
lotl collection list
lotl collection remove <name>
lotl collection rename <old> <new>
lotl ls [collection[/path]]
# Context
lotl context add [path] "description"
lotl context list
lotl context check
lotl context rm <path>
# Search
lotl search <query> # BM25 keyword search
lotl vsearch <query> # Vector similarity search
lotl query <query> # Hybrid + reranking (best quality)
# Retrieval
lotl get <file> # by path or docid (#abc123)
lotl multi-get <pattern> # by glob or comma-separated list
# Indexing
lotl embed # generate vector embeddings
lotl embed --force # re-embed everything
lotl update # re-index all collections
lotl sync # update + embed in one command
# Maintenance
lotl status # index status + remote providers
lotl cleanup # clear cache + orphans + vacuum
lotl vacuum # reclaim DB space
# MCP
lotl mcp # stdio transport
lotl mcp --http [--port N] # HTTP transport
lotl mcp --http --daemon # background daemon
lotl mcp stop # stop daemon
import { createStore } from '@tanarchy/lotl'
const store = await createStore({
dbPath: './my-index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '**/*.md' },
},
},
})
const results = await store.search({ query: "authentication flow" })
await store.close()
npx tsx src/cli/lotl.ts <command> # Run CLI from source (dev mode)
npm link # Install globally as 'lotl'
npx vitest run test/ # Run tests
npm run build # Compile TypeScript to dist/
npm run typecheck # tsc --noEmit (no emit, just type-check)
Node.js β₯22 required. Bun support was dropped β all code is Node-only.
Lotl ships a local-first, zero-cost benchmark loop that matches MemPalace's setup exactly: local ONNX embeddings via fastembed, no API keys, deterministic. The cost discipline is "iterate locally with --no-llm, validate answer quality with one paid Gemini run at the end." A full longmemeval_s_cleaned n=500 retrieval pass costs $0 and runs in ~25 min on a laptop.
Primary metrics: R@5 / R@10 (token-overlap recall), MRR (rank quality), F1 / EM / SH (answer quality). These actually discriminate pipeline changes. MemPalace-style session recall (SR@K) and dialog recall (DR@K) are reported as secondary reference rows; they're ceilinged or near-ceilinged on these datasets and should be taken with a grain of salt (see caveats below).
| Pipeline | n | R@5 | R@10 | F1 | EM | Cost | Time |
|---|---|---|---|---|---|---|---|
| MemPalace raw + fastembed (their published run) | 500 | 96.6% | 98.2% | β | β | $0 | 12.5m |
| Lotl raw + fastembed + scope-partitioned vec0 | 500 | 93.2% | 95.2% | β | β | $0 | 24m |
| Lotl raw + fastembed + adaptive cosine (n=100 first slice) | 100 | 97.0% | 97.0% | 64.9% | 48.0% | $0 | 5m |
Same embed model (all-MiniLM-L6-v2, 384-dim ONNX). Same dataset. Zero API keys for retrieval. Deterministic. Lotl additionally measures end-to-end answer quality (F1/EM/SH) that MemPalace's benchmark doesn't produce β their 96.6% is retrieval-only.
Per-category performance on full n=500 (pre-fix baseline β illustrates how diagnostic the per-category split is):
| Category | n | Lotl R@5 | MemPalace R@5 | Ξ |
|---|---|---|---|---|
| single-session-user | 70 | 99% | 97% | +2 β |
| single-session-assistant | 56 | 98% | 96% | +2 β |
| knowledge-update | 78 | 95% | 100% | β5 |
| single-session-preference | 30 | 93% | 97% | β4 |
| temporal-reasoning | 133 | 86% | 97% | β11 |
| multi-session | 133 | 80% | 100% | β20 |
The 7-pp overall gap was concentrated in the two largest categories (multi-session + temporal-reasoning, 53% of the dataset). DB inspection traced it to a real bug: the vec0 KNN query has no scope filter β it returned the K nearest memories across the entire 23,867-row index, and only ~0.3 hits per scope landed in the right one (we then dropped the rest in post-vector scope filtering, leaving most queries with mem=1-5 instead of mem=50). MemPalace doesn't hit this because they create a fresh ChromaDB EphemeralClient per question.
Two fixes shipped this session:
pickVectorMatches, 7 unit tests) β replaces the fixed 0.3 floor with max(0.05, top1 Γ 0.5). Quality fix for both production (open vaults) and benchmarks (focused haystacks). Documented in docs/EVAL.md.LOTL_VEC_K_MULTIPLIER=20) β workaround that fetches K=1000 vec hits instead of K=150, so the post-vector scope filter has enough candidates per scope to fill top-50. Architecturally proper fix (a scope partition key on memories_vec) is queued as a separate schema-migration commit.n=500 rerun with both fixes is in flight at session-close.
| Pipeline | R@5 | R@10 | F1 | EM | SR@5 (MP-compat) |
|---|---|---|---|---|---|
| Lotl v15.1 | 87.0% | 93.0% | 50.6% | 27.5% | 100% ceiling |
| Lotl v16.1 (reflect augment) | 84.5% | 91.5% | 49.4% | 27.0% | 100% |
| MemPalace (own benchmark) | 100% | 100% | β | β | 100% ceiling |
Oracle is pre-filtered to relevant sessions β SR@K hits 100% by construction for any retriever. Use the _s_cleaned row above for a meaningful comparison.
| Pipeline | R@5 | R@10 | F1 | EM | DR@50 (MP-compat) |
|---|---|---|---|---|---|
| Lotl v15-final | β | β | 60.9% | 38.6% | β |
| Lotl v15.1 | 50.0% | 60.9% | 58.6% | 36.2% | 74.9% |
| Lotl v16 (diversity only) | 50.9% | 60.9% | 58.9% | 37.2% | 75.7% |
| MemPalace (own benchmark) | β | β | β | β | 74.8% |
Single-conv breakdowns and v16.1 (reflect augment) detail live in docs/ROADMAP.md.
What this says:
longmemeval_s_cleaned dataset (the fully unfiltered haystack), not oracle. Comparing our numbers to that requires running _s β a future benchmark.Caveats on the MP-compat metrics (this is why we demote them to reference rows):
SR@K (session any-match) hits 100% on LME oracle by construction β doesn't discriminate retriever quality.DR@K (dialog fractional recall) is honest but only computable on LoCoMo where the dataset exposes dialog IDs.Reference SOTA on LongMemEval (per vectorize.io memory survey) β all reported on longmemeval_s_cleaned, not oracle:
# One-time
npm install fastembed
curl -L -o evaluate/longmemeval/longmemeval_s_cleaned.json \
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
# Run the same recipe MemPalace uses, on the same dataset
LOTL_EMBED_BACKEND=fastembed \
LOTL_RECALL_RAW=on \
LOTL_INGEST_EXTRACTION=off LOTL_INGEST_SYNTHESIS=off LOTL_INGEST_PER_TURN=off \
npx tsx evaluate/longmemeval/eval.mts --ds s --limit 500 --no-llm \
--workers 4 --tag local-baseline
Full retrieval pipeline. No API keys. ~$0 cost. ~25 min wall on a laptop. Reports R@5/R@10/MRR + (noisy but comparable) F1/EM/SH.
For end-to-end answer quality, add --llm gemini and a GOOGLE_API_KEY β that's the only paid call in the cycle.
QMD's benchmark methodology is documented in docs/EVAL.md. The headlines:
fastembed + --no-llm β costs nothing, deterministic, no rate limits.evaluate/run-mempalace-baseline.sh.Full version history, technique tables, lessons learned, and SOTA targets in docs/ROADMAP.md.
Lotl is a pile of good ideas from other projects, glued together with one SQLite database and a lot of benchmarking. Everything below is shipped and verified in code.
tobi/qmd β Tobi Lutke's original QMD. SQLite FTS5 + sqlite-vec hybrid search, AST-aware chunking via tree-sitter, the MCP server, session management, the entire CLI scaffolding. We forked this and grew the memory system on top.
MemPalace β the project that pushed us past 90% R@5 on LongMemEval.
memories_vec PARTITION KEY)Mem0 β atomic-fact extraction and dedup pipeline.
Mastra β TypeScript memory processing patterns.
scope field design)memory-lancedb-pro β the most complete memory lifecycle layer we found.
src/memory/decay.tssession_end hookZep / Graphiti β temporal knowledge graph schema.
valid_from / valid_until)knowledge_store / knowledge_query API and the
consolidateEntityFacts synthesis passLetta / MemGPT β agent
self-directed retrieval via tool calls. We expose this through the MCP
server's memory_recall and memory_store tools, letting the agent
choose when to recall vs ingest.
Tinkerclaw β Serra (2026) β three OpenClaw memory papers (Instant Recall, Total Recall, Sleep Consolidation). The most influential single source on our retrieval shape.
pushPack() in src/memory/index.ts:1400)effective = cos_sim Γ (1 + Ξ±Β·log(importance)), Ξ±β0.15applyDialogDiversity() + LOTL_MEMORY_MMR=session)runEvictionPass() in src/memory/decay.ts:135)Hindsight (architectural target, source-unconfirmed) β the
LongMemEval SOTA we benchmark against (91.4% R@5). Their published
4-parallel-path recipe is documented in our docs/EVAL.md SOTA table.
We adopted the post-retrieval reflect synthesis pattern: one LLM
call after top-K retrieval that reasons across the recovered memories
before the agent answers. Implemented as memoryReflect() in
src/memory/index.ts:1255. We have not been able to locate an open-
source repo for Hindsight β if you know the canonical link, please
open an issue.
Generative Agents (Park et al. 2023) β
periodic reflection over stored memory streams. We run this as
runReflectionPass() (src/memory/index.ts:1306) β pulls the last N
memories, derives meta-reflections via LLM, stores them as new memories
with category=reflection. Wired into the OpenClaw session_end hook.
bm25() ranking from FTS5's built-in implementation.RRF_K=60 smoothing constant fuses the BM25 + vector ranked lists.
Two-list RRF with 2Γ weight on the BM25 list.applyDialogDiversity. We use a
session-key variant instead of cosine similarity β cheaper, attacks
the multi-evidence retrieval pattern directly.runEvictionPass.src/memory/decay.ts:16.@xenova/transformers. Local ONNX embed backend
(default: mixedbread-ai/mxbai-embed-xsmall-v1 q8, confirmed at
n=500 LongMemEval after the Phase 11.8 sweep on 2026-04-18; Snowflake
arctic-embed-s was briefly a candidate but lost to mxbai-xs after
the metric audit).lotl mcp server.~/.cache/lotl/index.sqlite. No ChromaDB, no LanceDB, no separate
vector store, no Redis.LOTL_EMBED_BACKEND=transformers is opt-in β no cmake, no GPU
required for the default install.memories_vec ships with scope TEXT PARTITION KEY so vector KNN walks only the current scope's slice of
the index. Eliminated the n=500 89.4% R@5 ceiling caused by global
KNN bleeding across scopes.pickVectorMatches replaces the
legacy fixed 0.3 floor with max(absFloor=0.05, top1 Γ 0.5) and a
minKeep=5 safety net. Survives both open-vault and focused-
haystack regimes.LOTL_MEMORY_EXPAND=entities (proper nouns) and
LOTL_MEMORY_EXPAND=keywords (top-N keyword groups). The keyword
variant gave +1pp multi-session R@5 on LongMemEval n=500 in our
2026-04-13/14 night cycle.node-llama-cpp, no cmake
builds, no fastembed enum. Single ONNX backend that accepts any HF
repo via env vars.How Lotl started. Two weeks before this release (my first fork commit is 2026-04-04), I was running tobi/qmd (a local BM25+vector markdown search CLI) alongside memory-lancedb-pro for agent memory β and the mismatch between the two databases constantly bit me. Two separate stores, two separate ingest paths, two different query APIs, syncing by hand. I forked qmd because it was the easiest codebase to get running and modify, and set out to merge the memory layer into the same SQLite file.
That "just get them to sync cleanly" goal turned into a rabbit hole. The first attempts at a proper memory framework on top of qmd didn't work β recall was bad, decay was wrong, extraction produced garbage. That failure sent me into the research literature (LongMemEval, LoCoMo, Mem0, Hindsight, MemPalace, MemGPT, GraphRAG), and each paper changed a piece of the design. Every version below is my fork β not upstream tobi/qmd β evolving the memory layer while keeping the hybrid-search core qmd was already good at.
Two weeks later (2026-04-04 β 2026-04-18), the result was unrecognizable from the starting point. Hence the rename to Lotl.
Why rename instead of staying qmd. Tobi's qmd is a carefully-designed, focused search CLI β small, deliberate, well-scoped. What I built is the opposite: a vibe-coded rabbit hole, shipped fast, that ended up as a memory framework with its own opinions. Calling my fork qmd would have (a) risked confusing users about who authored what, and (b) attached my experimental, research-driven work to tobi's proper project in a way that doesn't honor the difference. Renaming to Lotl makes the boundary clean: tobi keeps qmd as the tight search-CLI he designed; this fork gets its own identity for its own trade-offs. Not stealing, not competing β just not squatting on his name.
| Version | Date | Configuration | rAny@5 | MRR | pref MRR | What changed |
|---|---|---|---|---|---|---|
| tobi/qmd upstream | 2025-12-07 onwards | BM25 + vector + RRF + rerank CLI, sqlite-vec, MCP server, no memory layer | β | β | β | Tobi Lutke's original. My fork branches off this. |
| fork v0 (cloud + rebrand) | 2026-04-04 β 04-08 | + ZeroEntropy cloud LLM config, per-op remote dispatch, @tanarchy/qmd rebrand | β | β | β | First David commits. Still mostly tobi's shape + minor cloud plumbing |
| fork v1 | 2026-04-09 β 04-12 | FTS AND, no memory vectors β first naive memory attempt | β | β | β | Baseline LoCoMo F1=8%. Tried to add memory by intuition; didn't work |
| fork v2 | 2026-04-12 | FTS OR + stopwords + sqlite-vec for memory | β | β | β | F1=22.5%, EM=6%. First usable recall |
| fork v3 | 2026-04-12 | + ZeroEntropy rerank + date-reasoning prompt | β | β | β | F1=27.7% |
| fork v4βv6 | 2026-04-13 | + query expansion + KG triples + adversarial-fix | β | β | β | F1=49β51%, EM=30%. Mem0 paper changed extraction |
| fork v7βv8 | 2026-04-13 | + decay + strong-signal, then rip KG-in-recall (regressed R@5) | β | β | β | F1=53%, then R@5=38.7%/F1=49.5% after KG-rip |
| fork v10 | 2026-04-11 | Mem0-style LLM fact extraction + KG auto-pop | **59.0%**ΒΉ | β | β | conv-30 F1=54.3%, EM=34.3% |
| fork v11βv16 | 2026-04-13 β 04-17 | RRF pipeline + keyword expansion + synonym expansion | 96.6 β 98.4% | 0.88 β 0.917 | 0.72 β 0.745 | LongMemEval era. Phase 1β7 sweeps |
| fork v17 (pre-rename) | 2026-04-17 | n=500 validated best-config | 98.4% | 0.917 | 0.745 | Last version under the qmd name |
| Lotl v1.0 | 2026-04-18 | n=500 sweep of 5 embedders, honest-eval harness, LoCoMo Judge-Acc | 98.4% | 0.917 | 0.745 | mxbai-xs q8 confirmed as permanent default. Renamed |
ΒΉ LoCoMo conv-30, 105Q sample
LoCoMo end-to-end at v1.0 (10 convs, 1986 QA, gemini-2.5-flash gen+judge): 81.4% Judge-Acc. Competitive with published LoCoMo claims (Mem0 91.6% on GPT-4-class, Hindsight 89.6%/83.6%). See evaluate/SNAPSHOTS.md for reproduction recipes.
R@5 β a metric-collision storyOne sentence: most memory-framework "R@5" claims are not apples-to-apples.
Three metrics all get called "R@5":
| Name | Definition | Who publishes this |
|---|---|---|
recall_any@5 | 1 if ANY gold session appears in top-5, else 0 | agentmemory, Mem0, MemPalace |
R@5 (fractional) | (gold sessions in top-5) / (total gold sessions) | LongMemEval paper (ICLR 2025) |
session_recall@5 ("sr5") | Set-membership on unique session IDs | our original metric pre-audit |
For a question with 3 gold sessions where top-5 contains 2:
recall_any@5 = 1.0R@5 (fractional) = 0.667sr5 = 1.0 (same as recall_any@5 modulo duplicates)Before the audit we compared our sr5 against MemPalace's R@5 label and thought we were 7pp behind. After the audit we realized MemPalace's 96.6% "R@5" is actually recall_any@5, and ours was already 98.4% β we were ahead, not behind. Six hours of chasing a fake gap.
Lessons that shaped Lotl's eval:
evaluate/SNAPSHOTS.md logs both.recall_any@5 for Mem0/MemPalace parity, R@5 (fractional) for LongMemEval paper parity, MRR/NDCG@10 for ranking-quality signal.top_k=50 on conversations that have β€32 sessions is a whole-conversation leak (MemPalace admitted this in their own BENCHMARKS.md). Lotl caps LLM context at top-k=10 (Mem0 paper default) regardless of retrieval-pool size.Full audit at evaluate/locomo/HYBRID_HARNESS.md and the competitor methodology table in evaluate/CLEANUP_PLAN.md.
Lotl stands on a lot of shoulders. This list is not exhaustive and reflects what moved the needle for v1.0.
src/memory/ either adopts or deliberately diverges from memory-lancedb-pro.git log: my first commit on this tree is 2026-04-04 ("feat: add cloud LLM support with ZeroEntropy"); the Lotl v1.0 rename is 2026-04-18 β exactly two weeks of work. Early iterations (fork v1βv8) in that window were my own failed attempts at adding a memory framework by intuition alone. It didn't work β recall was poor, extraction produced noise, decay was wrong. That failure made me sit down and read the research literature (below). Every version from v10 onward explicitly takes technique from a specific paper or framework. The BM25+vector+RRF+rerank search pipeline stays close to tobi's original shape; the memory system, KG, decay engine, extraction, honest-eval harness, and metric discipline were written from scratch in the last six of those fourteen days. Lotl is qmd-forked with most of the memory code written from scratch, renamed because the result is no longer "qmd with memory" β it's a different project.R@5 (fractional) definition used across all retrieval claims; the 6000-char per-memory cap in Phase 7 came from reading their eval harness carefully.src/memory/extractor.ts.evaluate/SNAPSHOTS.md.knowledge_* MCP tools' subject-predicate-object design.memex = MEMory EXtender, proto-ancestor of every personal knowledge system since. Lotl's architecture is literally what Bush described 80 years ago: associative trails through indexed records.BENCHMARKS.md for admitting it first).src/memory/decay.ts.valid_from / valid_until windows in src/memory/knowledge.ts.entityDensity + hasDecisionSignal in src/memory/extractor.ts are direct ports (minus the engagement component, which duplicated our length heuristic).evaluate/locomo/eval.mts.@huggingface/transformers (the JS port). Every local embed + rerank path goes through this; the direct-ORT backend's tokenizer (AutoTokenizer) lives here.tanarchy-lotl plugin manifest.If you'd like an attribution added or corrected, please open an issue.
MIT
(top 30 of 58)
TypeScript
86.5%
Shell
8.7%
JavaScript
4.8%
"I'll build my own memory framework β with FTS5 and sqlite-vec."
A unified search + memory + knowledge-graph system that runs on what's already on your machine. No new infra, no LLM required, no cloud dependency. Everything lives in one SQLite database.
Document search: BM25 + vector + RRF fusion + LLM reranking across markdown, code, and notes. Agent memory: Store, recall, forget, and extract memories with automatic deduplication and Weibull decay. Knowledge graph: Temporal subject-predicate-object triples β "what was true when?"
Lotl = Living-off-the-Land (the cybersecurity term for "use what's already there to avoid new infrastructure"). Repurposed here: FTS5 + sqlite-vec + local ONNX embeddings via @huggingface/transformers. Cloud APIs (ZeroEntropy, SiliconFlow, Nebius, Gemini, OpenAI) are opt-in, not required.
Evolved from a fork of tobi/qmd β see the origin story below. CLI binary
lotlis the canonical name (theqmdalias was dropped at v1.0.0 β hard break over back-compat). Env vars areLOTL_*; virtual paths arelotl://.
npm install -g @tanarchy/lotl
# Index your documents
lotl collection add ~/notes --name notes
lotl collection add ~/work/docs --name docs
lotl context add lotl://notes/ "Personal notes and ideas"
lotl context add lotl://docs/ "Work documentation"
lotl embed
# Search
lotl search "project timeline" # BM25 keyword search
lotl vsearch "how to deploy" # Vector semantic search
lotl query "quarterly planning process" # Hybrid + reranking (best quality)
# Get documents
lotl get "docs/api-reference.md"
lotl get "#abc123" # by docid
lotl multi-get "journals/2025-05*.md" # by glob pattern
Lotl exposes all functionality via MCP (Model Context Protocol). Works with Claude Desktop, Claude Code, Cursor, OpenClaw, and any MCP client.
| Tool | Description |
|---|---|
query | Hybrid search with typed sub-queries (lex/vec/hyde) + reranking |
get | Retrieve document by path or docid |
multi_get | Batch retrieve by glob or comma-separated list |
status | Index health and collection info |
briefing | Agent wake-up context: collections, contexts, search strategy |
| Tool | Description |
|---|---|
memory_add / memory_add_batch | Store memory/memories with auto-dedup (hash + cosine) and auto-classification |
memory_search | Hybrid search (FTS + vector RRF fusion + keyword expansion + synonym expansion) |
memory_recall_tiered | Search grouped by tier (core/working/peripheral), per-tier limit |
memory_push_pack | Pre-query bundle for session start β core + important-recent + hot-tail |
memory_get / memory_list | Fetch by id / list by filters |
memory_delete / memory_update | Delete / update text, importance, category (re-embeds on text change) |
memory_extract | Extract memories from conversation text (LLM + heuristic fallback) |
memory_reflect / memory_dream | Post-retrieval synthesis / overnight consolidation |
memory_stats | Count by tier, category, scope |
memory_register_scopes | Register scopes for partition-key vec0 queries |
| Tool | Description |
|---|---|
knowledge_add | Store a fact with time validity (auto-invalidates conflicting prior facts) |
knowledge_search | Query facts by subject/predicate/object, optionally at a point in time |
knowledge_invalidate | Mark a fact as no longer valid (preserved in history) |
knowledge_entities / knowledge_timeline / knowledge_stats | Enumerate entities / temporal scans / counts |
| Tool | Description |
|---|---|
manage | Administrative ops: embed, update, cleanup, sync, decay |
Claude Code (fastest β 2 commands):
npm install -g @tanarchy/lotl
# macOS / Linux:
claude mcp add lotl lotl mcp --scope user
# Windows: (Node spawn won't auto-resolve bare `lotl` to .cmd)
claude mcp add lotl lotl.cmd mcp --scope user
Restart Claude Code. The 26 tools (memory_*, knowledge_*, doc_*) appear. Verify with claude mcp list β should show β Connected.
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json macOS, %APPDATA%\Claude\claude_desktop_config.json Windows):
{
"mcpServers": {
"lotl": { "command": "lotl", "args": ["mcp"] }
}
}
Local-dev install (from source clone) β for testing unreleased work:
cd lotl/
npm install && npm run build && npm link
claude mcp add lotl lotl mcp --scope user
HTTP transport (shared daemon, models stay loaded across MCP clients):
lotl mcp --http --daemon # start on localhost:8181
lotl mcp stop # stop
By default a single user-scope MCP server (claude mcp add lotl lotl mcp --scope user)
serves every project from one shared index at ~/.cache/lotl/index.sqlite, and
memory uses the global scope. Set LOTL_ONNX=on once in ~/.config/lotl/.env
and the local model stack applies everywhere.
For hard per-project isolation β a private index per repo, no cross-project
memory or document bleed β drop a .mcp.json at the repo root. A project-scoped
server with the same name overrides the global one inside that repo:
{
"mcpServers": {
"lotl": {
"command": "lotl.cmd",
"args": ["mcp"],
"env": { "INDEX_PATH": ".lotl/index.sqlite", "LOTL_ONNX": "on" }
}
}
}
INDEX_PATH is resolved against the server's working directory (the repo root),
and its parent dir is created automatically on first run. Commit .mcp.json,
gitignore .lotl/. On macOS/Linux use "command": "lotl" (drop the .cmd).
Then, inside the repo: lotl collection add . --name <repo> && lotl embed.
One line in ~/.config/lotl/.env:
LOTL_ONNX=on
LOTL_ONNX=on is the umbrella switch (default off β no models, BM25 only). When on, it auto-enables the full local-ONNX stack with the preconfigured defaults β embeddings (mixedbread-ai/mxbai-embed-xsmall-v1 @ q8) and the local cross-encoder rerank (jinaai/jina-reranker-v1-tiny-en) β no model names to set. Then lotl embed once.
Want to tune it? The granular vars still work and override the toggle:
LOTL_ONNX=on
LOTL_TRANSFORMERS_MODEL=mixedbread-ai/mxbai-embed-xsmall-v1 # override embed model
LOTL_TRANSFORMERS_DTYPE=q8 # override dtype
LOTL_VEC_MIN_SIM=0.1 # cosine acceptance floor
Benchmarks (full reproduction recipes + per-config metrics in evaluate/SNAPSHOTS.md).
LongMemEval _s n=500 (session-id retrieval, RAW recall) β winner is mxbai-xs q8:
| System | recall_any@5 | R@5 (fractional) | MRR | NDCG@10 | Pref MRR | Wall |
|---|---|---|---|---|---|---|
| lotl / mxbai-xs q8 (default) | 98.4% | 93.7% | 0.917 | 0.913 | 0.745 | 26 min |
| lotl / UAE-Large 1024d | 98.0% | 93.8% | 0.921 | 0.919 | 0.714 | 145 min |
| lotl / gte-small 384d | 97.8% | 93.2% | 0.919 | 0.914 | 0.703 | 26 min |
| lotl / bge-large 1024d | 98.0% | 93.6% | 0.917 | 0.917 | 0.680 | 147 min |
| lotl / jina-v5-nano 768d | 95.4% | 89.6% | 0.860 | 0.849 | 0.533 | ~5 h |
| agentmemory hybrid | 95.2% | β | 0.882 | 0.879 | β | β |
| MemPalace raw | 96.6% | β | β | β | β | β |
n=500 sweep across 5 candidates concluded mxbai-xs stays default β challengers cost 5-15Γ params for tied or worse retrieval, all regressed on preference MRR.
LongMemEval _s n=500 with LLM judge (Phase 7):
| Generator | Judge | Judge-Acc | n | Notes |
|---|---|---|---|---|
| gpt-4o (Phase 7.1b, n=100) | gpt-4o | 64.0% | 100 | Matches LongMemEval paper baseline |
| Poe gpt-4o-mini | Poe gpt-4o | 47.0% | 134 | Quota hit at q55, partial result |
| Gemini-2.5-flash | Gemini-2.5-flash | 29.7% | 499 | Generator-bound β Gemini-flash hedges |
LoCoMo (10 convs, n=1986) with LLM judge β generator + judge = gemini-2.5-flash:
| Metric | Value |
|---|---|
| R@5 | 67.6% |
| MRR | 0.593 |
| F1 | 66.2% |
| Judge-Acc | 81.4% |
vs published LoCoMo: Mem0 91.6% (GPT-4 class), Hindsight 89.6% (top backbone). With a stronger generator (gpt-4o, gemini-2.5-pro), Lotl's 81.4% is expected to climb into the 85-90% range on the same retrieval layer.
See devnotes/metrics/metric-discipline.md for recall_any@K (binary, agentmemory/mem0/MemPalace style) vs R@K (fractional, LongMemEval paper) distinction. Eval harness CLI flags + reproduction recipes in evaluate/longmemeval/README.md and evaluate/locomo/README.md. Honest-harness rationale (top-k=10 not the MemPalace top-k=50 cheat) in evaluate/locomo/HYBRID_HARNESS.md.
What these four lines do:
@huggingface/transformers β no cmake, no GPU, ~50 MB download on first use.LOTL_VEC_MIN_SIM=0.1 β overrides the adaptive cosine acceptance floor (tight-cluster q8 models need this; default floor prunes too aggressively).Under the hood (all shipped, no config needed):
LOTL_MEMORY_RERANK=on (optional, +1-2pp MRR; blend hardcoded 0.5/0.5).All tunables hardcoded in src/store/constants.ts (validated at n=500 LME). See docs/ROADMAP.md "2026-04-17" for full sweep history.
Copy .env.example to ~/.config/lotl/.env. Loaded automatically.
Each operation (embed, rerank, query expansion) is configured independently:
QMD_{OP}_PROVIDER= # local | api | url | gemini (or alias: siliconflow, openai, zeroentropy, dashscope)
QMD_{OP}_API_KEY= # Bearer token
QMD_{OP}_URL= # base URL (api) or full endpoint (url)
QMD_{OP}_MODEL= # model name
Provider modes:
api β OpenAI-compatible base URL (paths /embeddings, /rerank, /chat/completions auto-appended)url β direct endpoint URL (used as-is)gemini β Google Gemini (x-goog-api-key auth)siliconflow, openai, zeroentropy, dashscope set mode + default URL automaticallyLOTL_EMBED_PROVIDER=zeroentropy
LOTL_EMBED_API_KEY=ze_your-key
LOTL_EMBED_MODEL=zembed-1
LOTL_RERANK_PROVIDER=zeroentropy
LOTL_RERANK_API_KEY=ze_your-key
LOTL_RERANK_MODEL=zerank-2
LOTL_RERANK_MODE=rerank
LOTL_QUERY_EXPANSION_PROVIDER=api
LOTL_QUERY_EXPANSION_API_KEY=nebius-key
LOTL_QUERY_EXPANSION_URL=https://api.studio.nebius.ai/v1
LOTL_QUERY_EXPANSION_MODEL=meta-llama/Meta-Llama-3.1-8B-Instruct
LOTL_EMBED_PROVIDER=siliconflow
LOTL_EMBED_API_KEY=sk-your-key
LOTL_EMBED_MODEL=Qwen/Qwen3-Embedding-8B
LOTL_RERANK_PROVIDER=siliconflow
LOTL_RERANK_API_KEY=sk-your-key
LOTL_RERANK_MODEL=BAAI/bge-reranker-v2-m3
LOTL_RERANK_MODE=rerank
LOTL_QUERY_EXPANSION_PROVIDER=siliconflow
LOTL_QUERY_EXPANSION_API_KEY=sk-your-key
LOTL_QUERY_EXPANSION_MODEL=zai-org/GLM-4.5-Air
Embed can also run locally via LOTL_EMBED_BACKEND=transformers (ONNX, opt-in). Rerank and query expansion are remote-only.
Agents store and recall memories alongside document search. Same database, same providers, same search pipeline.
memory_store({ text: "David prefers ZeroEntropy for reranking", category: "preference", importance: 0.8 })
β Embeds text β checks hash dedup β checks cosine dedup (β₯0.9) β stores in memories + memories_fts + memories_vec
memory_recall({ query: "what reranker does David use" })
β FTS search + vector search β RRF fusion β keyword boost β decay weighting β top results
memory_extract({ text: "We decided to use SQLite. I prefer TypeScript for backend work." })
β Pattern matching detects [decision] and [preference] β stores each with category + importance
preference β "I prefer X", "I don't like Y"fact β "The API limit is 100/min", "David works at Tanarchy"decision β "We decided to use X", "Let's go with Y"entity β "Vincent runs Ubuntu", "Arachnid is the code agent"reflection β "I realized that...", "Looking back..."other β everything elseAuto-classified when no category is provided, using 16 regex patterns.
Memories fade over time unless accessed frequently or marked important.
composite = 0.4 Γ recency + 0.3 Γ frequency + 0.3 Γ importance
Three tiers with automatic promotion:
Run manage({ operation: "decay" }) to evaluate and promote/demote.
Store facts with time validity windows. When facts change, old values are preserved with timestamps.
knowledge_store({ subject: "David", predicate: "prefers", object: "ZeroEntropy" })
β Later:
knowledge_store({ subject: "David", predicate: "prefers", object: "Nebius" })
β Auto-invalidates old "ZeroEntropy" fact, stores new "Nebius" fact
knowledge_query({ subject: "David", predicate: "prefers" })
β Returns: David β prefers β Nebius (current)
knowledge_query({ subject: "David", as_of: <last week> })
β Returns: David β prefers β ZeroEntropy (was valid then)
Entity names are auto-normalized: "David Gillot" β "david_gillot".
Query
β
βββββββββββββ΄ββββββββββββ
βΌ βΌ
BM25 (FTS5) Vector (sqlite-vec)
keyword match semantic similarity
β β
βββββββββββββ¬ββββββββββββ
βΌ
RRF Fusion (k=60)
BM25 weight: 0.8
Vector weight: 0.2
β
βΌ
Zero-LLM Score Boosts
β’ keyword overlap (+30%)
β’ quoted phrase match (+60%)
β’ person name match (+40%)
β’ stop word filtering
β
βΌ
LLM Reranking (optional)
(ZeroEntropy / SiliconFlow / Gemini / OpenAI)
β
βΌ
Rerank Blend (hardcoded v1.0.0)
50% RRF / 50% reranker
(was position-aware pre-Phase-6;
sweep showed flat 0.5/0.5 wins)
β
βΌ
Final Results
All scoring parameters are env-configurable. See .env.example.
# Collections
lotl collection add <path> --name <name>
lotl collection list
lotl collection remove <name>
lotl collection rename <old> <new>
lotl ls [collection[/path]]
# Context
lotl context add [path] "description"
lotl context list
lotl context check
lotl context rm <path>
# Search
lotl search <query> # BM25 keyword search
lotl vsearch <query> # Vector similarity search
lotl query <query> # Hybrid + reranking (best quality)
# Retrieval
lotl get <file> # by path or docid (#abc123)
lotl multi-get <pattern> # by glob or comma-separated list
# Indexing
lotl embed # generate vector embeddings
lotl embed --force # re-embed everything
lotl update # re-index all collections
lotl sync # update + embed in one command
# Maintenance
lotl status # index status + remote providers
lotl cleanup # clear cache + orphans + vacuum
lotl vacuum # reclaim DB space
# MCP
lotl mcp # stdio transport
lotl mcp --http [--port N] # HTTP transport
lotl mcp --http --daemon # background daemon
lotl mcp stop # stop daemon
import { createStore } from '@tanarchy/lotl'
const store = await createStore({
dbPath: './my-index.sqlite',
config: {
collections: {
docs: { path: '/path/to/docs', pattern: '**/*.md' },
},
},
})
const results = await store.search({ query: "authentication flow" })
await store.close()
npx tsx src/cli/lotl.ts <command> # Run CLI from source (dev mode)
npm link # Install globally as 'lotl'
npx vitest run test/ # Run tests
npm run build # Compile TypeScript to dist/
npm run typecheck # tsc --noEmit (no emit, just type-check)
Node.js β₯22 required. Bun support was dropped β all code is Node-only.
Lotl ships a local-first, zero-cost benchmark loop that matches MemPalace's setup exactly: local ONNX embeddings via fastembed, no API keys, deterministic. The cost discipline is "iterate locally with --no-llm, validate answer quality with one paid Gemini run at the end." A full longmemeval_s_cleaned n=500 retrieval pass costs $0 and runs in ~25 min on a laptop.
Primary metrics: R@5 / R@10 (token-overlap recall), MRR (rank quality), F1 / EM / SH (answer quality). These actually discriminate pipeline changes. MemPalace-style session recall (SR@K) and dialog recall (DR@K) are reported as secondary reference rows; they're ceilinged or near-ceilinged on these datasets and should be taken with a grain of salt (see caveats below).
| Pipeline | n | R@5 | R@10 | F1 | EM | Cost | Time |
|---|---|---|---|---|---|---|---|
| MemPalace raw + fastembed (their published run) | 500 | 96.6% | 98.2% | β | β | $0 | 12.5m |
| Lotl raw + fastembed + scope-partitioned vec0 | 500 | 93.2% | 95.2% | β | β | $0 | 24m |
| Lotl raw + fastembed + adaptive cosine (n=100 first slice) | 100 | 97.0% | 97.0% | 64.9% | 48.0% | $0 | 5m |
Same embed model (all-MiniLM-L6-v2, 384-dim ONNX). Same dataset. Zero API keys for retrieval. Deterministic. Lotl additionally measures end-to-end answer quality (F1/EM/SH) that MemPalace's benchmark doesn't produce β their 96.6% is retrieval-only.
Per-category performance on full n=500 (pre-fix baseline β illustrates how diagnostic the per-category split is):
| Category | n | Lotl R@5 | MemPalace R@5 | Ξ |
|---|---|---|---|---|
| single-session-user | 70 | 99% | 97% | +2 β |
| single-session-assistant | 56 | 98% | 96% | +2 β |
| knowledge-update | 78 | 95% | 100% | β5 |
| single-session-preference | 30 | 93% | 97% | β4 |
| temporal-reasoning | 133 | 86% | 97% | β11 |
| multi-session | 133 | 80% | 100% | β20 |
The 7-pp overall gap was concentrated in the two largest categories (multi-session + temporal-reasoning, 53% of the dataset). DB inspection traced it to a real bug: the vec0 KNN query has no scope filter β it returned the K nearest memories across the entire 23,867-row index, and only ~0.3 hits per scope landed in the right one (we then dropped the rest in post-vector scope filtering, leaving most queries with mem=1-5 instead of mem=50). MemPalace doesn't hit this because they create a fresh ChromaDB EphemeralClient per question.
Two fixes shipped this session:
pickVectorMatches, 7 unit tests) β replaces the fixed 0.3 floor with max(0.05, top1 Γ 0.5). Quality fix for both production (open vaults) and benchmarks (focused haystacks). Documented in docs/EVAL.md.LOTL_VEC_K_MULTIPLIER=20) β workaround that fetches K=1000 vec hits instead of K=150, so the post-vector scope filter has enough candidates per scope to fill top-50. Architecturally proper fix (a scope partition key on memories_vec) is queued as a separate schema-migration commit.n=500 rerun with both fixes is in flight at session-close.
| Pipeline | R@5 | R@10 | F1 | EM | SR@5 (MP-compat) |
|---|---|---|---|---|---|
| Lotl v15.1 | 87.0% | 93.0% | 50.6% | 27.5% | 100% ceiling |
| Lotl v16.1 (reflect augment) | 84.5% | 91.5% | 49.4% | 27.0% | 100% |
| MemPalace (own benchmark) | 100% | 100% | β | β | 100% ceiling |
Oracle is pre-filtered to relevant sessions β SR@K hits 100% by construction for any retriever. Use the _s_cleaned row above for a meaningful comparison.
| Pipeline | R@5 | R@10 | F1 | EM | DR@50 (MP-compat) |
|---|---|---|---|---|---|
| Lotl v15-final | β | β | 60.9% | 38.6% | β |
| Lotl v15.1 | 50.0% | 60.9% | 58.6% | 36.2% | 74.9% |
| Lotl v16 (diversity only) | 50.9% | 60.9% | 58.9% | 37.2% | 75.7% |
| MemPalace (own benchmark) | β | β | β | β | 74.8% |
Single-conv breakdowns and v16.1 (reflect augment) detail live in docs/ROADMAP.md.
What this says:
longmemeval_s_cleaned dataset (the fully unfiltered haystack), not oracle. Comparing our numbers to that requires running _s β a future benchmark.Caveats on the MP-compat metrics (this is why we demote them to reference rows):
SR@K (session any-match) hits 100% on LME oracle by construction β doesn't discriminate retriever quality.DR@K (dialog fractional recall) is honest but only computable on LoCoMo where the dataset exposes dialog IDs.Reference SOTA on LongMemEval (per vectorize.io memory survey) β all reported on longmemeval_s_cleaned, not oracle:
# One-time
npm install fastembed
curl -L -o evaluate/longmemeval/longmemeval_s_cleaned.json \
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
# Run the same recipe MemPalace uses, on the same dataset
LOTL_EMBED_BACKEND=fastembed \
LOTL_RECALL_RAW=on \
LOTL_INGEST_EXTRACTION=off LOTL_INGEST_SYNTHESIS=off LOTL_INGEST_PER_TURN=off \
npx tsx evaluate/longmemeval/eval.mts --ds s --limit 500 --no-llm \
--workers 4 --tag local-baseline
Full retrieval pipeline. No API keys. ~$0 cost. ~25 min wall on a laptop. Reports R@5/R@10/MRR + (noisy but comparable) F1/EM/SH.
For end-to-end answer quality, add --llm gemini and a GOOGLE_API_KEY β that's the only paid call in the cycle.
QMD's benchmark methodology is documented in docs/EVAL.md. The headlines:
fastembed + --no-llm β costs nothing, deterministic, no rate limits.evaluate/run-mempalace-baseline.sh.Full version history, technique tables, lessons learned, and SOTA targets in docs/ROADMAP.md.
Lotl is a pile of good ideas from other projects, glued together with one SQLite database and a lot of benchmarking. Everything below is shipped and verified in code.
tobi/qmd β Tobi Lutke's original QMD. SQLite FTS5 + sqlite-vec hybrid search, AST-aware chunking via tree-sitter, the MCP server, session management, the entire CLI scaffolding. We forked this and grew the memory system on top.
MemPalace β the project that pushed us past 90% R@5 on LongMemEval.
memories_vec PARTITION KEY)Mem0 β atomic-fact extraction and dedup pipeline.
Mastra β TypeScript memory processing patterns.
scope field design)memory-lancedb-pro β the most complete memory lifecycle layer we found.
src/memory/decay.tssession_end hookZep / Graphiti β temporal knowledge graph schema.
valid_from / valid_until)knowledge_store / knowledge_query API and the
consolidateEntityFacts synthesis passLetta / MemGPT β agent
self-directed retrieval via tool calls. We expose this through the MCP
server's memory_recall and memory_store tools, letting the agent
choose when to recall vs ingest.
Tinkerclaw β Serra (2026) β three OpenClaw memory papers (Instant Recall, Total Recall, Sleep Consolidation). The most influential single source on our retrieval shape.
pushPack() in src/memory/index.ts:1400)effective = cos_sim Γ (1 + Ξ±Β·log(importance)), Ξ±β0.15applyDialogDiversity() + LOTL_MEMORY_MMR=session)runEvictionPass() in src/memory/decay.ts:135)Hindsight (architectural target, source-unconfirmed) β the
LongMemEval SOTA we benchmark against (91.4% R@5). Their published
4-parallel-path recipe is documented in our docs/EVAL.md SOTA table.
We adopted the post-retrieval reflect synthesis pattern: one LLM
call after top-K retrieval that reasons across the recovered memories
before the agent answers. Implemented as memoryReflect() in
src/memory/index.ts:1255. We have not been able to locate an open-
source repo for Hindsight β if you know the canonical link, please
open an issue.
Generative Agents (Park et al. 2023) β
periodic reflection over stored memory streams. We run this as
runReflectionPass() (src/memory/index.ts:1306) β pulls the last N
memories, derives meta-reflections via LLM, stores them as new memories
with category=reflection. Wired into the OpenClaw session_end hook.
bm25() ranking from FTS5's built-in implementation.RRF_K=60 smoothing constant fuses the BM25 + vector ranked lists.
Two-list RRF with 2Γ weight on the BM25 list.applyDialogDiversity. We use a
session-key variant instead of cosine similarity β cheaper, attacks
the multi-evidence retrieval pattern directly.runEvictionPass.src/memory/decay.ts:16.@xenova/transformers. Local ONNX embed backend
(default: mixedbread-ai/mxbai-embed-xsmall-v1 q8, confirmed at
n=500 LongMemEval after the Phase 11.8 sweep on 2026-04-18; Snowflake
arctic-embed-s was briefly a candidate but lost to mxbai-xs after
the metric audit).lotl mcp server.~/.cache/lotl/index.sqlite. No ChromaDB, no LanceDB, no separate
vector store, no Redis.LOTL_EMBED_BACKEND=transformers is opt-in β no cmake, no GPU
required for the default install.memories_vec ships with scope TEXT PARTITION KEY so vector KNN walks only the current scope's slice of
the index. Eliminated the n=500 89.4% R@5 ceiling caused by global
KNN bleeding across scopes.pickVectorMatches replaces the
legacy fixed 0.3 floor with max(absFloor=0.05, top1 Γ 0.5) and a
minKeep=5 safety net. Survives both open-vault and focused-
haystack regimes.LOTL_MEMORY_EXPAND=entities (proper nouns) and
LOTL_MEMORY_EXPAND=keywords (top-N keyword groups). The keyword
variant gave +1pp multi-session R@5 on LongMemEval n=500 in our
2026-04-13/14 night cycle.node-llama-cpp, no cmake
builds, no fastembed enum. Single ONNX backend that accepts any HF
repo via env vars.How Lotl started. Two weeks before this release (my first fork commit is 2026-04-04), I was running tobi/qmd (a local BM25+vector markdown search CLI) alongside memory-lancedb-pro for agent memory β and the mismatch between the two databases constantly bit me. Two separate stores, two separate ingest paths, two different query APIs, syncing by hand. I forked qmd because it was the easiest codebase to get running and modify, and set out to merge the memory layer into the same SQLite file.
That "just get them to sync cleanly" goal turned into a rabbit hole. The first attempts at a proper memory framework on top of qmd didn't work β recall was bad, decay was wrong, extraction produced garbage. That failure sent me into the research literature (LongMemEval, LoCoMo, Mem0, Hindsight, MemPalace, MemGPT, GraphRAG), and each paper changed a piece of the design. Every version below is my fork β not upstream tobi/qmd β evolving the memory layer while keeping the hybrid-search core qmd was already good at.
Two weeks later (2026-04-04 β 2026-04-18), the result was unrecognizable from the starting point. Hence the rename to Lotl.
Why rename instead of staying qmd. Tobi's qmd is a carefully-designed, focused search CLI β small, deliberate, well-scoped. What I built is the opposite: a vibe-coded rabbit hole, shipped fast, that ended up as a memory framework with its own opinions. Calling my fork qmd would have (a) risked confusing users about who authored what, and (b) attached my experimental, research-driven work to tobi's proper project in a way that doesn't honor the difference. Renaming to Lotl makes the boundary clean: tobi keeps qmd as the tight search-CLI he designed; this fork gets its own identity for its own trade-offs. Not stealing, not competing β just not squatting on his name.
| Version | Date | Configuration | rAny@5 | MRR | pref MRR | What changed |
|---|---|---|---|---|---|---|
| tobi/qmd upstream | 2025-12-07 onwards | BM25 + vector + RRF + rerank CLI, sqlite-vec, MCP server, no memory layer | β | β | β | Tobi Lutke's original. My fork branches off this. |
| fork v0 (cloud + rebrand) | 2026-04-04 β 04-08 | + ZeroEntropy cloud LLM config, per-op remote dispatch, @tanarchy/qmd rebrand | β | β | β | First David commits. Still mostly tobi's shape + minor cloud plumbing |
| fork v1 | 2026-04-09 β 04-12 | FTS AND, no memory vectors β first naive memory attempt | β | β | β | Baseline LoCoMo F1=8%. Tried to add memory by intuition; didn't work |
| fork v2 | 2026-04-12 | FTS OR + stopwords + sqlite-vec for memory | β | β | β | F1=22.5%, EM=6%. First usable recall |
| fork v3 | 2026-04-12 | + ZeroEntropy rerank + date-reasoning prompt | β | β | β | F1=27.7% |
| fork v4βv6 | 2026-04-13 | + query expansion + KG triples + adversarial-fix | β | β | β | F1=49β51%, EM=30%. Mem0 paper changed extraction |
| fork v7βv8 | 2026-04-13 | + decay + strong-signal, then rip KG-in-recall (regressed R@5) | β | β | β | F1=53%, then R@5=38.7%/F1=49.5% after KG-rip |
| fork v10 | 2026-04-11 | Mem0-style LLM fact extraction + KG auto-pop | **59.0%**ΒΉ | β | β | conv-30 F1=54.3%, EM=34.3% |
| fork v11βv16 | 2026-04-13 β 04-17 | RRF pipeline + keyword expansion + synonym expansion | 96.6 β 98.4% | 0.88 β 0.917 | 0.72 β 0.745 | LongMemEval era. Phase 1β7 sweeps |
| fork v17 (pre-rename) | 2026-04-17 | n=500 validated best-config | 98.4% | 0.917 | 0.745 | Last version under the qmd name |
| Lotl v1.0 | 2026-04-18 | n=500 sweep of 5 embedders, honest-eval harness, LoCoMo Judge-Acc | 98.4% | 0.917 | 0.745 | mxbai-xs q8 confirmed as permanent default. Renamed |
ΒΉ LoCoMo conv-30, 105Q sample
LoCoMo end-to-end at v1.0 (10 convs, 1986 QA, gemini-2.5-flash gen+judge): 81.4% Judge-Acc. Competitive with published LoCoMo claims (Mem0 91.6% on GPT-4-class, Hindsight 89.6%/83.6%). See evaluate/SNAPSHOTS.md for reproduction recipes.
R@5 β a metric-collision storyOne sentence: most memory-framework "R@5" claims are not apples-to-apples.
Three metrics all get called "R@5":
| Name | Definition | Who publishes this |
|---|---|---|
recall_any@5 | 1 if ANY gold session appears in top-5, else 0 | agentmemory, Mem0, MemPalace |
R@5 (fractional) | (gold sessions in top-5) / (total gold sessions) | LongMemEval paper (ICLR 2025) |
session_recall@5 ("sr5") | Set-membership on unique session IDs | our original metric pre-audit |
For a question with 3 gold sessions where top-5 contains 2:
recall_any@5 = 1.0R@5 (fractional) = 0.667sr5 = 1.0 (same as recall_any@5 modulo duplicates)Before the audit we compared our sr5 against MemPalace's R@5 label and thought we were 7pp behind. After the audit we realized MemPalace's 96.6% "R@5" is actually recall_any@5, and ours was already 98.4% β we were ahead, not behind. Six hours of chasing a fake gap.
Lessons that shaped Lotl's eval:
evaluate/SNAPSHOTS.md logs both.recall_any@5 for Mem0/MemPalace parity, R@5 (fractional) for LongMemEval paper parity, MRR/NDCG@10 for ranking-quality signal.top_k=50 on conversations that have β€32 sessions is a whole-conversation leak (MemPalace admitted this in their own BENCHMARKS.md). Lotl caps LLM context at top-k=10 (Mem0 paper default) regardless of retrieval-pool size.Full audit at evaluate/locomo/HYBRID_HARNESS.md and the competitor methodology table in evaluate/CLEANUP_PLAN.md.
Lotl stands on a lot of shoulders. This list is not exhaustive and reflects what moved the needle for v1.0.
src/memory/ either adopts or deliberately diverges from memory-lancedb-pro.git log: my first commit on this tree is 2026-04-04 ("feat: add cloud LLM support with ZeroEntropy"); the Lotl v1.0 rename is 2026-04-18 β exactly two weeks of work. Early iterations (fork v1βv8) in that window were my own failed attempts at adding a memory framework by intuition alone. It didn't work β recall was poor, extraction produced noise, decay was wrong. That failure made me sit down and read the research literature (below). Every version from v10 onward explicitly takes technique from a specific paper or framework. The BM25+vector+RRF+rerank search pipeline stays close to tobi's original shape; the memory system, KG, decay engine, extraction, honest-eval harness, and metric discipline were written from scratch in the last six of those fourteen days. Lotl is qmd-forked with most of the memory code written from scratch, renamed because the result is no longer "qmd with memory" β it's a different project.R@5 (fractional) definition used across all retrieval claims; the 6000-char per-memory cap in Phase 7 came from reading their eval harness carefully.src/memory/extractor.ts.evaluate/SNAPSHOTS.md.knowledge_* MCP tools' subject-predicate-object design.memex = MEMory EXtender, proto-ancestor of every personal knowledge system since. Lotl's architecture is literally what Bush described 80 years ago: associative trails through indexed records.BENCHMARKS.md for admitting it first).src/memory/decay.ts.valid_from / valid_until windows in src/memory/knowledge.ts.entityDensity + hasDecisionSignal in src/memory/extractor.ts are direct ports (minus the engagement component, which duplicated our length heuristic).evaluate/locomo/eval.mts.@huggingface/transformers (the JS port). Every local embed + rerank path goes through this; the direct-ORT backend's tokenizer (AutoTokenizer) lives here.tanarchy-lotl plugin manifest.If you'd like an attribution added or corrected, please open an issue.
MIT
(top 30 of 58)
TypeScript
86.5%
Shell
8.7%
JavaScript
4.8%