Anja Gutierrez
Developer tooling

Elody

A personal knowledge agent — 20 MCP tools over a 420-node graph, with an offline fallback that always answers.

Node.jsGemini 2.5 FlashOllamaMCP SDKInk TUIEmbeddings
20MCP tools
420Knowledge nodes
2xSemantic weight
0Required API calls

What it is

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.

The retrieval problem

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.

The code

Pulled straight from the production codebase — unedited except for trimming.

src/retrieval/search.js javascript
Hybrid retrieval with a real fallback path. The first branch is the load-bearing one: no embeddings available means degrade to keyword search, not fail. When both are available, semantic hits get double weight, keyword scores are normalized into a 0–1 range with 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;
}

Screens

From the running application.

Elody knowledge agent terminal UI
The Ink TUI — search results, session history, and sprite animation.

Want to see more of this one?

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