Building Moneta Notes
I don’t like proprietary tools and vendor lock-in. That’s part of why I started keeping notes in plain Markdown years ago, long before it was the trendy thing to do. Eventually I found Obsidian, which I really liked — but I spend all day in a terminal and NeoVim, and I don’t like reaching for a mouse if I don’t have to. The obsidian.nvim plugin solved that. It let me keep working in Obsidian’s conventions, including wikilinks and frontmatter, without ever opening the Obsidian app itself.
That was the steady state for a long time. Then work got a lot more demanding of it.
Where This Started
I’m a software architect responsible for a platform made up of hundreds of separate projects maintained by multiple engineering teams, with a lot of different efforts running in parallel and a lot of meetings to keep straight. At some point I started taking note-taking a lot more seriously, specifically around capturing what actually happened in meetings, not just my own scratch notes about them.
When Copilot’s Teams meeting summaries were available, I use those. When they weren’t, I started
recording with the macOS Voice Memos app, pulling its transcript, and pasting that into Claude to
summarize. That manual process didn’t last long before I turned it into something I could trigger
straight from NeoVim. A light weight lua scripts that records a .wav file itself (no Voice Memos
involved), runs a local ML model to transcribe it, pipes the transcript into Claude for a summary,
and drops the result directly into the note file open in my current buffer.
I am using WAV specifically, not a compressed format, for a practical reason. If I closed my laptop before stopping a recording, I don’t want to lose the audio. Lossy formats encode as a stream that has to finish cleanly to be usable; an uncompressed WAV file is safe to walk away from mid-recording.
That pipeline solved capture. It created a new problem, recall. I was generating a lot of structured, useful notes and had no good way to actually find things across all of them later beyond rudimentary keyword search or grep. I’d already built an MCP system at work to give Claude direct access to my notes from any session, but it was missing real functionality. It had no CLI at all, and no indexing of any kind, so it leaned entirely on brute-force reads. That gap is the direct reason Moneta Notes exists.
The name comes from Juno Moneta — an epithet of the Roman goddess Juno, from the Latin monere, “to remind, to warn, to advise.” Reminding and advising seems like a good fit.
Building It Spec-First
Before any code existed, I spent real time with Claude on research and discussion, working out what
I wanted, and just as importantly what I didn’t want. I defined the project structure, tooling, and
architecture largely by hand. That work turned into twelve numbered specs
(docs/specs/ in the repo, S001
through S012), each one owning a specific slice of the system; the data model, search ranking,
note CRUD, the indexing daemon, the MCP tool surface, and so on. With later specs explicitly amending
earlier ones when design work surfaced a gap.
Only once a spec was solid did I let Claude build against it. Even then, not directly. For anything that needed implementing, Claude would first turn the relevant spec into an execution plan. A concrete, ordered task breakdown for actually building it. Once that plan was carried out, it got thrown away. The spec and the resulting code are the only things meant to persist as living documentation of how the project works; the plan was scaffolding for getting there, not a record worth keeping once it had served its purpose.
That process wasn’t even in paced. The first two days were almost entirely spec-writing. Then it went quiet for about a week. I was on vacation and had the time to focus, and when I sat back down, implementation detonated.
Writing the specs first paid for itself more than once. While drafting the data model spec, Claude
checked sqlite-vec’s actual export shape against the draft plan and caught that the vector table’s
DDL never set distance_metric=cosine — meaning it would have silently defaulted to L2 distance and
quietly contradicted the entire hybrid-ranking design before a line of search code existed. Cheaper
to catch in a Markdown file than in a shipped bug.
Under the Hood
Moneta Notes is plain Node.js, no TypeScript, no build step. Three components share one core
library: an indexing daemon, a mnotes CLI, and an MCP server for Claude Code / Claude Desktop. The
CLI and MCP server are thin wrappers that do their own argument parsing or protocol plumbing and then
call the exact same core functions, so the two surfaces can’t drift apart from each other.
The index itself is SQLite, doing double duty: FTS5 for full-text (BM25, contentless) and sqlite-vec
for semantic vectors, merged with Reciprocal Rank Fusion (RRF) for hybrid search. Embeddings come from
Qwen3-Embedding-0.6B, quantized and run in-process via @huggingface/transformers — no external
embedding API, no key, nothing billed per query. Notes get chunked at roughly 512 tokens with ~15%
overlap before embedding. I chose this model for a balance of quality text embedding for human
language but also better understanding of code for when I might include snippets in my notes.
A background daemon watches the vault with fswatch and keeps the index in sync through a
SQLite-backed retry queue. SQLite runs in WAL mode with the daemon as the sole writer and
everything else as readers, and every mutating write (edit, append, rename) requires the caller to
pass back the note’s current content hash — a stale hash is a hard error, not a silent overwrite,
which matters once you’ve got a human editing in NeoVim and Claude editing over MCP at the same time.
I also made a couple of deliberately low-dependency calls: node:sqlite, Node’s own built-in driver,
instead of better-sqlite3, and a hand-rolled plain-text logger instead of pulling in something like
Pino. Neither buys much for a tool meant to run as one personal, single-user process.
One challenge I ran into after my initial implementation was memory usage. For Node to use the embedding model it has to load it into memory which takes about 1GB of RAM. Not bad when I use machines with 64GB available. However, the model had to exist in the daemon process so it could be used to create the embeddings for the index, but also in all CLI and MCP processes so that they could create embeddings for performing the semantic search queries. With each active Claude session spinning up its own MCP instance this could eat a lot of memory pretty quickly. To solve this I implemented an inter-process communication (IPC) protocol so that the CLI and MCP processes could ask the daemon to produce the embeddings for them which they then use for the search.
What It Does Today
The full spec set is implemented and covered by 548 tests. Both the CLI and the MCP tools expose the same functionality:
- Hybrid, full-text, or pure semantic search, with a CLI-only
--explainmode for debugging why a result did or didn’t rank the way it did. -
grepover the vault via ripgrep, plus tag listing and tag-scoped note listing. -
Note read/write/edit/append/rename, all hash-guarded on the write side, with rename
automatically rewriting
[[wikilinks]]in every note that referenced the old title. - Links — backlinks, outbound links, and a dedicated command to list every broken wikilink in the vault.
- Attachment read/write for binary vault files (images, PDFs) referenced from notes, outside the index entirely.
-
stats,reindex, and daemon start/stop/restart for operating the thing day to day.
Every MCP tool also requires a reason argument, logged to an audit trail — mirroring the way
Claude Code’s own built-in file tools carry a description, so there’s always a record of why a
given read or write happened. That idea actually came from Claude itself, mid-design. Forcing an
agent to articulate its reasoning before acting reportedly leads to better decisions, not just a
better audit trail. I don’t know how rigorously that’s been shown to be true, but it cost nothing to
add and the audit log alone justifies it.
It’s macOS-only right now (Apple Silicon), and deliberately scoped to one vault per machine — I run it independently on both my personal machine and my work machine, each with its own vault, index, and config, never shared between the two.
What I Learned
The genuinely hard part of this project wasn’t the part that looks hard on paper — hybrid ranking, embeddings, RRF merging. While those were newer concepts for me, their implementations ended up be pretty straight forward. The hard part was making the daemon behave well on macOS under the non-admin constraints of my work laptop, since I wanted this to work there as much as at home. Two things in particular bit me: log rotation and process naming.
There’s no cron and no root on that machine, and no assuming any privileged scheduler is available —
so log rotation is its own dedicated, user-level LaunchAgent on its own schedule, not something
piggybacked on system tooling that assumes elevated permissions. And without deliberate effort, a
background Node process just shows up in macOS’s Background Task Management UI as generic
“Node.js Foundation” — indistinguishable from anything else Node running on the machine. The fix was
a small native launcher, compiled with clang at install time, that gives the daemon and the
log-rotation agent their own real identity (“Moneta Notes”) in that UI, with a plain shell-wrapper
fallback if Xcode Command Line Tools aren’t available.
The other lesson was more about process than code: writing specs first and letting Claude push back on them — checking a library’s actual export shape, catching a misleading table name, tracing one small question about title resolution all the way through the system — caught real problems while they were still cheap to fix. And the work itself came in bursts, not a steady trickle; having uninterrupted time to focus mattered more than any individual technical decision.
What’s Next
The roadmap lives directly in the repo’s docs/TODO.md, and it’s short on purpose:
- A real test suite for index stability and accuracy over time, not just unit coverage of the code.
- A proper reinstall/upgrade path, so pulling the latest changes from the repo doesn’t require manually reconciling config or schema changes by hand.
- Git-aware tooling for vaults that are themselves version-controlled — read-only
note_historyandnote_diffcommands, gated behind detecting a.gitdirectory at or above the vault root. Deliberately not yet specced, including how a futurenote_restore-style recovery would need to interact with the existing hash-guard model. - MCP prompts — weekly review automation, note triage, stale/orphan note detection, weekly note scaffolding. Also deliberately deferred, until the core tool set has actually been in daily use long enough to know which of these are worth building.
Moneta Notes is published on GitHub under Apache-2.0. If you’re the kind of person who keeps notes in an Obsidian vault, lives in a terminal, and runs Claude or another MCP-capable agent day to day, it’s built for exactly that overlap — I’d be glad to have you try it, file an issue, or send a PR.