8 min read

I Put a Filesystem in the Middle of Everything

A man in a deerstalker hat looking through a magnifying glass, Sherlock Holmes style
Photo by Getty Images on Unsplash

The idea was elegant. Mount a FUSE passthrough filesystem at ~/knowledge/, let every file access flow through it, and log what gets opened alongside what. Semantic search tells you what documents say. Access patterns tell you how they're used. Two files can be about completely different topics but always get opened in the same session because they serve the same workflow. A contact card and a project dossier. A weekly plan and a team allocation spreadsheet. Content analysis can't find those connections. But a filesystem sitting in the middle of everything can.

In the previous post, I compared my search system to a librarian who knows which books are about similar topics because she's read them all. Now imagine she also starts watching the checkout desk. She notices the same person always borrows the astronomy textbook and a watercolor painting guide in the same visit. They have nothing in common by subject. But they belong to the same person's Tuesday evening routine. That's the kind of relationship I wanted to capture.

I'd seen this work before. Back in 2012, I worked at Electric Cloud (later CloudBees) on ElectricAccelerator, a distributed build system. Their secret weapon was EFS, the Electric File System. It intercepted filesystem operations at the kernel level to track which source files each compiler actually read during a build. GNU Make has declared dependencies in its makefiles, but those declarations are often incomplete or wrong. EFS didn't trust the declarations. It watched what the compiler touched, and built the real dependency graph from observed behavior.

Thirteen years later, staring at a knowledge base with 24,000 documents and a search system that could find things by content but had no idea which documents belonged together in practice, the connection clicked. Makefiles declare dependencies. My entity docs declare relationships. Both are incomplete. The filesystem knows the truth.

So I built a FUSE passthrough. Read-write, backed by ~/.knowledge-store/, mounted at ~/knowledge/, logging every open, read, stat, and readdir. Universal observability for free.

Day one, I checked the access logs.

690,000 events... ooof....

The infrastructure talks to itself

Most of that wasn't human. 70% was git. I run an auto-commit service (kb-git-auto) that snapshots the knowledge base every 15 minutes, and a git status on a 107,000-file repository means statting every single file. Every 15 minutes. All day. All night. Another 24% was the file watcher (kb-watcher) doing inotify-triggered reindexing, scanning directories to figure out what changed. That left 6% for actual human or agent access, buried under infrastructure talking to itself.

The FUSE process was eating 9.4 GB of RAM and 123% sustained CPU! Ten worker threads at 13% each, grinding through events that nobody would ever look at. The access logs were 123 MB per day of noise.

The fix was straightforward. Background services (git, watcher, garbage collection) got redirected to the backing store at ~/.knowledge-store/, bypassing FUSE entirely. Interactive tools (Claude Code, Emacs, file managers) kept going through the mount. The principle: FUSE passthrough is for capturing intent. Infrastructure should bypass it.

Observability without filtering is just disk I/O.

The benchmark

After redirecting services, the numbers collapsed. 642 MB RAM. Zero CPU at idle. Sub-millisecond latency for interactive file reads. The FUSE layer was effectively invisible for normal use.

Before After
RAM 9.4 GB 642 MB
CPU 123% sustained idle
Events/day 690,000 47,000

The passthrough worked fine for its intended purpose. It just couldn't survive everything going through it.

Sublime Text's hidden git integration

I thought I was done. Then the next morning's stats showed git_thread still at 21% of traffic. 167,000 readdir events overnight. But I'd redirected all git services away from the mount. There shouldn't be a single git operation going through FUSE.

The events came from short-lived PIDs, roughly 23 per burst, each doing about 97 readdir operations across the entire directory tree. The bursts correlated with sync service timing (Slack sync, WhatsApp sync writing new files through the mount). And there was one persistent PID, 42598, doing readdir /.git every 16 minutes, matching kb-git-auto's timer interval exactly.

So it looked like git. The timing matched git. But kb-git-auto runs as a oneshot service with a different PID each run, and PID 42598 had been alive all day. Oneshot services don't do that.

I checked /proc/42598/cmdline.

/opt/sublime_text/sublime_text --detached

Sublime Text. The text editor I use for quick file edits has built-in git integration. It spawns threads named git_thread that poll for repository state changes. When the sync services wrote files through the mount, Sublime detected the change and dispatched a thread pool (about 23 workers) to git status the entire working tree. Each worker walked roughly 97 directories. That's where the 167,000 readdir events came from.

I set "show_git_status": false in Sublime's preferences. Problem solved.

For three days.

The noise came back

215,000 events. git_thread at 36%. Same Sublime process, same thread ID, same full-tree scanning behavior. The preference I'd changed only controls whether Sublime shows git status indicators in the sidebar. It doesn't stop the underlying repository scanning. Opening or saving any file through ~/knowledge/ was enough to trigger Sublime's git backend to discover the .git directory (visible through the passthrough) and start background scans.

I could have gone deeper into Sublime's configuration. Found another setting, maybe a plugin to disable. But that's playing whack-a-mole. You can't reliably control what every desktop application does internally. Editor settings are a gentleman's agreement, not a contract.

So I moved the filter to the FUSE layer itself. The logger already knows who's accessing files (process name from /proc/PID/comm). Before writing any event, it checks a set of excluded process names. git_thread gets silently dropped. So does any other known-noisy process we discover later.

That inverts the responsibility. Instead of configuring every application to behave, the FUSE logger decides what's worth recording. Filter at the collection point, not at the source.

Four waves, one lesson

The overnight stats settled at 47,000 events. Zero unexpected processes. A flat 4-5,000 events per hour from the sync services doing their legitimate work.

Night Events Root cause Fix
Feb 16 ~690K git + watcher through FUSE Redirected to backing store
Feb 17 ~280K Watcher still on FUSE Added --watch-path flag
Feb 18 ~170K Sublime Text git integration show_git_status: false
Feb 19 ~215K Sublime git backend ignores setting Process filter at FUSE layer
Feb 20 47K Clean -

In other words: the system went from “observability as denial-of-service” to “observability as a free byproduct.

Each wave only became visible because the previous fix unmasked it. The git noise was hiding the watcher noise, which was hiding Sublime. Like peeling layers off an onion, except the onion keeps growing new ones.

The detective work was satisfying. /proc/PID/cmdline forensics, correlating burst timing with service schedules, tracking persistent vs oneshot PIDs. The kind of systems debugging that testers and ops people find genuinely fun (and everyone else finds baffling).

But the clean signal revealed a bigger problem.

What the filesystem can't see

A woman carrying a stack of books in a library
Photo by Kübra Arslaner on Unsplash

I looked at Claude Code's activity. My audit logs (which record every tool call with conversation IDs, tool names, and file paths done by my AI tools) showed 101 sessions that day. The FUSE layer had captured 5 of them.

Think of the FUSE layer as a checkout desk at a library. It sees a hand grab a book off the shelf. It sees another hand grab a different book. It logs both events. But it can't tell if those two hands belong to the same person doing research, or two different people who happened to reach for the shelf at the same time.

Claude Code spawns a new subprocess for every tool call. A single Grep operation scans 20,518 files in 49 seconds. That shows up as one massive burst from one PID, indistinguishable from a background indexer. Two intentional Read calls from the same conversation appear as two separate single-file sessions because they ran in different processes. The FUSE layer sees 40,000 events across 69 PIDs, but has no way to know which of those events represent deliberate information retrieval and which are search primitives doing bulk scans.

I tried the obvious fix. The FUSE logger captures each process's PPID (parent process ID, the process that spawned it). Claude Code conversations have a stable parent process, so all the subprocesses from one conversation share the same PPID even though they each get their own PID. Grouping by PPID instead of PID recovered some conversation-level sessions. Combined with burst detection (50+ files in under 5 seconds equals a bulk scan, not intentional reading), this got closer. But it still missed 2 of 7 multi-file conversations entirely. Better, but not ground truth.

For humans, filesystem behavior and intent are closely coupled. You open a file because you want to read it. You open three files in the same terminal session because they're related to whatever you're working on. One editor, one shell, a few files. The checkout desk sees one person, one visit, three books. The access pattern is the intent.

For AI agents, they're decoupled by design. Every tool call is a fresh subprocess. A Grep is a search primitive, not a statement of interest. The checkout desk sees 69 different hands grabbing books and has no idea they're all working on the same project.

The FUSE layer can see what files were touched. It can't see why.

Two layers, not one

The checkout desk can't tell you why someone grabbed a book. But the library card system can. It knows which patron checked out which books, on which visit, and whether they actually read them or just flipped through the index. The audit logs I built for Claude Code and my other AI tools are that library card system. They record every tool call with a conversation ID, a tool type (Read vs Grep vs Write), and the file path. They know the reasoning chain. They know which files were deliberately consulted and which were just scanned in passing.

So I merged both layers. The FUSE access logs capture human behavioral edges (which files get opened together by a person doing real work). The audit logs capture agent cognitive edges (which files were part of the same reasoning chain). Both feed the same graph database, tagged with provenance so you know which signal came from where.

Within 48 hours of running the merged analysis, real edges started appearing. Planning documents that cluster together. Project docs that travel in pairs. Draft files linked to their source material. Relative's documents when accessed while I was doing genealogy. Seven repeater edges across two days, from a system that had been drowning in noise a week earlier.

A dark library room with bookshelves and an open door letting bright light spill in
Photo by Peter Herrmann on Unsplash

But that's the next post. The filesystem got me clean behavioral data. The audit logs got me agent intent. What happened when I combined them into a graph with Hebbian decay, and left it running for eight weeks, was even better than I expected.


This is part of a series about building a personal knowledge base. Previously: I Gave My AI a Memory, I Removed the Friction. That Was the Problem., I Called Them Suggestions. There Was a Reason., Teaching a Knowledge Base to Search, and My AI Remembered Everything. That Was the Problem.