Why I Built My Own 'Second Brain' Instead of a Notebook (and What It Taught Me About Data)
An app built purely so that work spread across dozens of projects and two machines wouldn't forget what had already been done, and why — and one that, after a few months of daily use, earned enough trust to make publishing it worthwhile too.
The problem the app solves
When you're managing dozens of projects at once — homelab services, custom apps, client work, across multiple machines — a specific kind of problem shows up: context gets lost. You come back to a project a week later and have no idea what exactly was being done then, why something was done exactly that way and not another, and what's still waiting to be finished. The usual fix — "keep notes in .md files as you go" — sounds reasonable, but in practice has one weakness: it's an extra step with no mechanical connection to the actual work. It holds up for a short task. Over a longer, in-progress stretch of work, it gradually falls away.
That's exactly what an audit confirmed — for the kind of entries that were supposed to be logged continuously, as many as 10 of 16 expected entries for a single day were missing. The rule existed, it was even clear, but nothing enforced it — so it simply got skipped.
First attempt: write to two places at once
The first reaction was logical — if the .md write keeps getting skipped, add a second, redundant place to write to (dual-write): an app with an API that also keeps its own database. Every write should go to both targets at once.
The problem is that "write to two places" has exactly the same weakness as the original rule — it's still an extra step with no mechanical enforcement. It worked better for a while, but in the end it was the same kind of fragility, just with one more place to write to.
The decision: one write path, one source of truth
The eventual fix wasn't "be more disciplined about writing" — it was changing what counts as the source of truth in the first place. Instead of two equally-valid write targets, there's now exactly one — an app with an API — and the original .md files turned into a purely generated, readable backup, refreshed automatically once an hour by a script running in the opposite direction from the original importer.
The consequence is simple but important: there's now exactly one place where a write can go missing — and when it does, it's trivial to check (the last write timestamp, via a dedicated sync-status endpoint), instead of arguing about whether it's maybe just sitting in that other file.
The road there wasn't straightforward — data bugs found along the way
The move to a single source of truth uncovered a string of real bugs that simply hadn't been visible before, because the data lived in more than one place at once:
- Case-sensitive project duplication. The write code's own lookup for creating a new project handled case differently from the later lookup used for searching — the result was two separate projects,
Mimirandmimir, meant to hold the same thing. The exact same class of bug later turned up a second time, in a different part of the code (a live write from a parallel run on another machine), confirming it wasn't a one-off typo but a pattern — anywhere a lookup-or-create happens by text name, case has to be handled explicitly. - SQLite schema ordering. Adding a new column to an existing table crashed the backend right at startup with "no such column" — even though the command to add it was right there in the code. Cause: SQLite executes an entire block of statements in the order they're written, and a
CREATE INDEXon the new column tried to run before a separate function had finished runningALTER TABLE ADD COLUMN. In a system without a proper migration framework, the order has to be explicit: unchanged schema first, then an idempotent backfill of new columns, and only after that anything that depends on those columns. - API responses that were too large. The project list returned the full notes text for every project on every call — for some projects, over 70 KB. That was fine for the app's own normal reads, but reading it via the API exceeded a sane limit for a single fetch and ended up less convenient than just opening one
.mdfile. Fix: a lightweight list endpoint (just a "has notes" flag, not the content), with the full text only fetched on request for a specific record. The same principle was later applied to the entries list too (an optional trim of the text to a few hundred characters).
The big cleanup: when a duplicate doesn't look like a duplicate at all
The most interesting part came from hunting down duplicate entries created because the same event got logged twice during the transition period — once via the live write, once via a backfill import from the .md backup of that same period.
The first, simplest approach — an exact match on date and title — only found part of them, because the wording between the two writes of the same event differed slightly (punctuation, diacritics). Widening it to a "fuzzy" comparison of normalized titles found more pairs, but still not all of them.
The real fix only came from comparing content, not just the title — Jaccard similarity (word overlap) between the bodies of every entry within the same day, across the entire dataset, not just the part where duplicates had been reported. This approach found substantially more real duplicates, but it also brought an important trap that had to be avoided: in one particularly dense saga (several attempts in a row to fix the same bug), neighboring but genuinely different entries ("test succeeded" vs. "test initially mislabeled as solved — correction") had text similar enough that a cruder approach would have wrongly merged them as duplicates. Only a sufficiently high similarity threshold — combined with actually understanding the content, not just surface matching — could reliably tell apart "this is the same thing written twice" from "this is a claim and its later correction."
Altogether, 46 duplicate entries were found and cleaned up this way across several rounds — exact title match, fuzzy title match, content similarity, and finally one fully identical pair that turned out to be an artifact of the original parsing method for the old files.
What's left as a lesson
- A rule with no mechanical connection to the work fades away over a long enough stretch — no matter how reasonable it is. Dual-write is no exception; it has exactly the same weakness as the original manual rule.
- A single source of truth is easier to verify than two synchronized ones. Instead of "let's hope they both agree," it's one question: did it get written where it's supposed to?
- Splitting data across multiple places hides bugs. The case duplication, the schema ordering issue, and the duplicate entries were all present before — they only surfaced once the data had to live in one place and be internally consistent.
- Looking for duplicates by surface (title) alone isn't enough. Actual content is a more reliable signal, but the threshold has to be set carefully, so "the same thing twice" doesn't get confused with "similar, but deliberately different."
The app that started out just so I wouldn't have to remember context across dozens of projects in progress became a small exercise in data integrity along the way. That wasn't the end of it, though — after months of ordinary daily use, it turned out the scope of what the app holds reaches further than just the entries someone typed in by hand.
From a private tool to something that can exist beyond me
The app was built from day one purely for its own use, with no plan to share it. After long enough in daily use — once the log, the build notes, and the checklist had become an actually reliable source of truth, not just an experiment — a different question made sense to ask: is there any point keeping it to myself, or is it a solid enough tool by now to exist publicly too, as a demonstration of one way to solve this kind of problem.
The decision to publish it (Apache-2.0, a public repository) brought its own, smaller chunk of work — not cosmetic, but real. The README used to describe the app only to itself, with references to its own specific setup; it had to be rewritten to make sense to someone who's never seen it before — what the app does, why, and how someone else could deploy it with their own AI tool, not just the one I happen to use. Part of the pre-publish audit was scanning the repository for personal paths, old forgotten files, and anything that shouldn't leak out once it went public — including two unused backup files (.bak) left over from an earlier edit, with no reason to still exist.
The bigger, functional piece of this phase was adding a second layer of permissions. Until then the app had one password for everything — logging in and deleting. That's fine for a tool used by one person on one machine, but the moment an app is something someone else might deploy (or something that might run unattended for a long stretch), an accidental delete triggered by one careless click deserves a higher bar than an ordinary login. The fix: a separate, optional admin password that the app additionally requires only for destructive operations (deleting an entry, a project, a whole terminal session) — not for normal use. It's not a real security boundary — anyone with access to the machine or the database directly can bypass it anyway — but it's a deliberate second obstacle against an unlucky accident, not against an attacker.
What the app picked up since then
Alongside publishing, a few functional layers got added that moved the app from "a log I write into" toward something that notices most of what's actually happening on its own:
- Passive git commit logging. Instead of manually writing "I did X," the app now scans local repositories in the background on its own and bulk-imports commits as entries — through the same import endpoint the app already had for a different purpose, just with dedup by commit hash. What actually changed in the code is now visible without anyone having to remember to write it down.
- Cross-source timeline per project. Manual entries, automatically logged commits, and (described below) terminal history can now be searched for one specific project as a single, merged, chronological feed. The scenario it's built for: you come back to a project after a year, a bug turns up in some old module, you search that module's name, and the app shows everything that ever touched it — regardless of whether it was a manual entry, a commit, or a diagnosis run in a terminal.
- AI handoff briefing. On request, the app can generate a structured handoff document from a project's notes, its timeline, and its open checklist — current state, what's been tried (including dead ends, not just what worked), what's still open, and decisions that shouldn't get accidentally undone. Built exactly for the scenario where a project in progress has to be handed to someone else in a hurry — or just properly re-remembered after a long break.
Terminal history — the biggest addition
Most of the real diagnostic work doesn't happen inside the app — it happens in a terminal: debugging, watching logs, fixing things live over SSH. Until now, the only way to find it again afterward was memory — "roughly when did I do that" — never the actual content of what happened out there. The app can now capture entire tmux sessions — not just scrollback, the full output — and make them searchable by context afterward.
This part had two hard requirements from the start, not suggestions: private data must never end up in the app in readable form, and searching a large session can't become a chore.
Capture works through tmux hooks — the moment a new pane is created, the app attaches pipe-pane to it and continuously writes its output to its own raw log, independent of the app itself. Processing into the app, though, isn't triggered by a "session closed" hook — that's a fragile moment (a process can crash, an SSH connection can drop before the hook gets a chance to fire) — but by a periodic timer that finds logs belonging to panes that no longer exist and processes those. Resilience over immediacy.
The key decision is in what happens before the write into the app, not after. Redaction of sensitive data (passwords, private keys, bearer tokens, common API key prefixes, VPN key lines) runs as the first step, before anything ends up in the app — never the other way around. And the app deliberately doesn't pretend to fully trust its own regex: whatever redaction touches gets replaced in the stored text, but the whole surrounding chunk (not just the matched line) also gets flagged as quarantined and is physically excluded from the search index at the database level — not just filtered out at query time — until a human approves it by hand. That's the actual difference between "handled automatically" and "handled automatically, but nobody verified it."
Splitting into smaller, searchable chunks runs on gaps in time, not a fixed line count — a longer command together with its whole output stays together as one logical unit instead of getting torn apart across several chunks.
The first real test of this part immediately surfaced an actual edge of the design: a very short-lived pane (a command that finishes in under a second) can close before the hook has a chance to attach — in that case, nothing gets captured at all. It doesn't matter for normal work (an interactive session lives long enough for the hook to attach in time), but it's exactly the kind of edge case that only shows up once the thing actually gets used, not from a design on paper.
A new close
The app that started out just so I wouldn't have to remember context across dozens of projects in progress now also remembers what actually happened in the terminal and in git — not just what someone typed into it by hand. The data trustworthiness that mattered in the first phase now applies to a layer that used to simply vanish without a trace — with one difference: this layer has its own built-in, hard safeguard against anything ending up in it that doesn't belong there.