A personal knowledge agent — 20 MCP tools over a 420-node graph, with an offline fallback that always answers.
Elody is the research assistant I actually use. She indexes a markdown knowledge graph, every repository I work in, and my Chrome bookmarks, then exposes the whole thing to Claude Code as 20 MCP tools — search, context loading, code search with project-aware routing, gates and milestones, session history, health checks.
She also runs as a standalone terminal app with an Ink TUI and animated sprites, tracks git commits across every repo, and handles two-way SMS with auto-reply through the same knowledge pipeline. An autonomous /loop engine generates plans and trips a circuit breaker when it stops making progress.
Pure semantic search is great at “what did I decide about caching?” and useless at “find the node called rate-limiting” — embeddings smear exact identifiers. Pure keyword search is the reverse.
So retrieval is hybrid, and — this is the part that matters in practice — it has to keep working when the embedding provider is unreachable, out of quota, or the machine is offline. An assistant that returns nothing on a flaky network is an assistant you stop trusting.
Pulled straight from the production codebase — unedited except for trimming.
score / (score + 5) so an unbounded term count can't dominate a bounded cosine similarity, and the two merge into one ranking.export function hybridSearch(queryEmbedding, nodeEmbeddings, query, graph, topK = 3) {
// If no embeddings available, fall back to keyword only
if (!nodeEmbeddings || nodeEmbeddings.size === 0) {
return keywordSearch(query, graph, topK);
}
const semanticResults = semanticSearch(queryEmbedding, nodeEmbeddings, topK * 2);
const keywordResults = keywordSearch(query, graph, topK * 2);
// Merge with weighted scores
const merged = new Map();
for (const r of semanticResults) {
merged.set(r.nodeId, (merged.get(r.nodeId) || 0) + r.score * 2);
}
for (const r of keywordResults) {
// Normalize keyword scores to 0-1 range
const normalizedScore = r.score / (r.score + 5);
merged.set(r.nodeId, (merged.get(r.nodeId) || 0) + normalizedScore);
}
const results = [...merged.entries()]
.map(([nodeId, score]) => ({ nodeId, score }))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
return results;
}
From the running application.

The repository is private, but I'm glad to walk through the codebase live — architecture, tradeoffs, the parts that went wrong first.