TL;DR - I open-sourced cairn, the local memory system my team and I use to give our AI coding agents persistent context across sessions. It runs entirely on your own machine. Add it to Claude Code in four lines, or check it out on GitHub:
/plugin marketplace add shavvimal/cairn
/plugin install cairn@cairn
/reload-plugins
/cairn:setupThe internal tool my team uses every day to give our AI coding agents memory is now a one-line install. cairn exports every Claude Code, Cursor, and Codex session, plus meetings and docs, into a searchable index on your own machine, so your agents never start from zero. I run a YC startup called SAMMY, and we've dogfooded this for months. Last week it became a Claude Code plugin anyone can add.
The name comes from the Scottish Gaelic càrn, a heap of stones. You stack them one at a time to mark a trail, so the next traveler never loses the path. That's the whole idea of a memory layer: stack the markers, and you never have to find the path twice. Three months ago I made a bet1: context outlives the tools you use it in. Workflows churn, Claude Code today and Cursor yesterday and something else next quarter, but if your decisions and debugging sessions and meeting notes are indexed locally in a format any tool can read, you're insulated from it. So I wired 4,000+ sessions into a local search engine, QMD2, and my agents stopped re-explaining themselves. Every time I pick up a feature Ive worked on across tools / meetings, opening with a /recall x before anything else primes my sessions perfectly.
Then the tools came to collect. Cursor moved where it hides your project, and my exporter started dropping sessions into a void. Granola encrypted everything I depended on, and the cron died. The four scripts in the initial version I wrote about in the last blog post had drifted into four dialects of the same idea. The next three months of using it daily are the rest of this post: four things that broke or got rebuilt, and what each of them did to the bet.
The first thing to move was Cursor.
Cursor Hid the Project
The symptom looked harmless. Cursor sessions started showing up at the top of the collection, named like cursor-sessions/2026-06-19-6d967f7c.md, instead of nested under their repo at cursor-sessions/sammy/api/.... A loose file in the root is unsearchable. Nothing groups it, no project context description boosts it in the index, and I can't scope a search to one repo and trust the foldering anymore. A session that lands in the root is a session I can't find later.
The storage hadn't changed. In post #1 I cracked open Cursor's two-level SQLite layout1 and it still holds: a global database (~/Library/Application Support/Cursor/User/globalStorage/state.vscdb) keeps composer metadata in composerData:<uuid> rows, with the actual messages split out into roughly 146K bubbleId:* rows because a single bubble can run to 250KB. Alongside that sit around 300 per-workspace databases, one per workspace hash, each mapping its composers to a real folder path. The global DB held the content; the workspace DBs held the project mapping. Then newer Cursor stopped writing the mapping down.
The reliable path used to be a map lookup: take the composer ID, find it in the per-workspace allComposers list, read off the folder. Newer Cursor stopped feeding that map. The global Agents window and the background agents now write the project path straight onto the composer at composerData.workspaceIdentifier.uri.fsPath and leave allComposers empty. So the old lookup returned nothing. The file-URI fallback that used to catch the stragglers missed too, because agent sessions touch files scattered across the tree and there's no clean common prefix to infer from. With both strategies blind, the session fell to the root.
The interesting part is where I hit this. Not in one codebase but two, the same week. The personal sync-cursor-sessions skill was dropping sessions in the root, and so was a production conversation_provenance_service in our product that reads these exact same Cursor databases for an entirely different reason. Same storage, same bug, same fix in both places. When your tool and your company's tool break identically, you stop blaming your code and start reading the host app's changelog...
The fix is a three-tier resolution chain, and the order is the whole point.
# Priority 1: workspaceIdentifier on the composer itself (newer Cursor / Agents window)
folder_path = None
wsid = composer_json.get("workspaceIdentifier")
if isinstance(wsid, dict) and isinstance(wsid.get("uri"), dict):
folder_path = wsid["uri"].get("fsPath") or wsid["uri"].get("path")
# Priority 2: workspace allComposers map (legacy sessions)
if not folder_path:
folder_path = workspace_map.get(composer_id)
# Priority 3: file-URI common-prefix fallback (fuzzy, last resort)
if not project and files_modified:
project, repo_subdir = _project_from_file_uris(files_modified)Tier 1 goes first because it's the composer telling you where it lives, with no inference involved. It's the most authoritative source there is, and it's exactly the one the newest sessions populate. Tier 2 is the legacy truth for older composers that predate that field, still correct whenever it's present, so it stays as the second look rather than getting ripped out. Tier 3 is the guess of last resort: take everything the composer touched, find the common path prefix, match it against the project catalog. It's cheap but fuzzy, and agent sessions confuse it, which is precisely why it sits at the bottom. Checking workspaceIdentifier first was the whole fix. The same test file that had landed in the root now lands under cursor-sessions/sammy/api/2026-06-19-6d967f7c.md. Grouped, and searchable again.
While I was rewriting the resolver, I reversed a decision from post #1. The original exporters captured user text only. I'd pulled just json_extract(value, '$.text') out of each bubble to save tokens, deliberately throwing away the model's side of the conversation. That was a mistake, and untangling the project pointer made it obvious. The index exists to reconstruct what happened and why, and the why lives in the parts I was discarding: the reasoning, the tool calls, the code blocks. So the contract now captures the full transcript for Cursor, Codex, and Claude, every prompt, every response, every line of extended thinking, every codeBlocks and toolFormerData entry. Where the reasoning is genuinely gone, the renderer says so. Codex encrypts its reasoning, and rather than silently drop it, a reasoning_recoverable flag drives an explicit > [reasoning: unrecoverable] marker so a reader knows the gap is real and not a parsing bug.
There was a performance cost lurking in all this. Building the workspace map means scanning those ~300 SQLite databases, and that's about 8 seconds every time. An hourly cron that does nothing 23 times a day shouldn't pay 8 seconds for the privilege of finding nothing. So the export runs cheap-first in two passes. The first pass decides what changed using only the metadata already sitting in each composerData row, a date filter and an mtime check against the existing markdown, no bubble fetches and no workspace map at all. The expensive map gets built only when that first pass finds real work. A no-op run never opens a single workspace DB. The floor is around 7.5 seconds, spent streaming roughly 1000's of JSON blobs just to read the timestamps, because those timestamps are buried inside a JSON value column with no indexable way to filter them in SQL. Everything stays read-only, every database opened with mode=ro, because Cursor is almost always running while the export does its work and I refuse to risk corrupting a store the host app owns.
Cursor only hid the project. It still handed over everything else the moment I asked. Granola hid the whole thing.
Granola Locked the Door
The cron died on a single line:
Error: Granola cache not found: .../cache-v4.jsonThe plaintext cache the exporter read every hour was gone. In its place sat cache-v6.json, a tiny file of UI state with not a single meeting document in it. The old plaintext supabase.json token was still on disk, but it was roughly six days stale and the API answered it with a flat HTTP 401. Two things had moved while I wasn't looking. The documents had gone server-side, and the live token had gone behind the macOS Keychain.
In post #1 I'd leaned on a thin trust boundary: it's your own token, in a plaintext file, the same as opening the file in a text editor. That boundary got encrypted shut. And it wasn't a random tightening. Over the past year Granola grew up in public. There was the rebrand to a new visual system, then a "2.0" built around team features and a shared Team Space, then SOC2, and eventually an official MCP3. The exact months don't matter; the sequence does. A product going from personal notepad to shared team infrastructure locks its store, and the local plaintext token was the obvious thing to lock.
The Dead End
My first instinct was that the content had to be local somewhere. Granola is an Electron app, and the new artifact on disk was hard to miss: a large SQLite file, granola.db, holding the meeting content as Yjs collaborative documents. So I pointed sqlite3 at it.
It told me "file is not a database." No SQLite format 3 header, no readable strings, nothing. The file is SQLCipher-encrypted, and no public source has cracked Granola's key. I could have kept pulling on that thread, but two facts ended it. The key derives from the same Keychain chain I'd need for the token anyway, so cracking the DB buys me nothing the token doesn't. And even with the key, the contents are Yjs CRDTs, which are genuinely painful to render down to clean markdown. Decrypting the tiny token file and calling Granola's existing private API hands back the same documents and transcripts for a fraction of the work. So granola.db was a documented dead end.
One thing from post #1 was still true and worth restating: transcription_retention_time_ms is set to 259,200,000 ms, which is three days. Transcripts get cached briefly and then purged. The cache was never a persistent store, so there was never a cache-only path back to old meetings. The token was the only door.
The Auth Chain
The data was reachable through the private API, but only with a fresh token, and the only fresh token on the machine was double-wrapped: Electron's safeStorage around Granola's own data-encryption key. Getting to the WorkOS access token meant a four-layer chain. I ported it from a couple of community tools45 and the earlier API reverse-engineering work6, then verified the whole thing end to end.
# 1. Keychain -> wrapping key
# security find-generic-password -w -s "Granola Safe Storage"
# PBKDF2-HMAC-SHA1(password, salt=b"saltysalt", iterations=1003, dklen=16)
# -> 16-byte AES key
# 2. Unwrap storage.dek (Electron v10 safeStorage blob)
# strip the 3-byte "v10" prefix
# AES-128-CBC, IV = 16 * b"\x20", PKCS7-unpad
# -> 44 bytes == base64 of the real 32-byte DEK -> base64-decode
# 3. Decrypt stored-accounts.json.enc (fallbacks: supabase.json.enc, plaintext)
# AES-256-GCM laid out as nonce[12] + ciphertext + tag[16]
# 4. Recurse the JSON-inside-JSON
# no top-level access_token; it hides in JSON-encoded string fields
# (accounts[].tokens, workos_tokens) -> re-parse until you reach
# the WorkOS access_tokenThe fallback ladder on layer 3 matters more than it looks. It tries stored-accounts.json.enc first, then supabase.json.enc, then the legacy plaintext supabase.json, so an older machine that never migrated still works without a special case.
With a live token in hand, the rest is the API I already knew. /v2/get-documents with include_last_viewed_panel: true for the meetings and their summary panels, /v1/get-document-transcript for the spoken text. A recent run pulled hundreds of documents this way. The old gzip gotcha still bites: responses come back gzip-compressed even when you don't send an Accept-Encoding header, so the script sniffs for the \x1f\x8b magic bytes and decompresses before parsing.
That whole chain is the one place cairn reaches for a third party. cryptography is the lone non-stdlib dependency in the entire engine, and I import it function-locally inside the Granola adapter so it only loads when you actually run a Granola export. It ships as an optional [granola] extra, so pip install cairn[granola] pulls it in and everyone else stays on a pure-stdlib install.
Skip, Don't Write
The interesting part wasn't the crypto. It was deciding what the exporter is allowed to do once it's holding someone else's keys.
If the Keychain is locked, the .enc files are missing, or the token is expired, the exporter skips cleanly and exits 0. It does not fail the cron, and it never writes a byte to Granola's store. The whole pipeline used to die on cache-v4.json not being there; now a Granola hiccup is a no-op and the other collections index fine.
The harder call was one I had to talk myself into. The token is a single-use, rotating WorkOS refresh token. Refreshing it myself would mean writing a new token back into Granola's encrypted store, and because the refresh token rotates on use, a botched write-back could corrupt Granola's own login state. The capable move was to auto-refresh the token. The right move was to never write to a store I don't own. So the rule is just: use whatever token Granola last saved, and if it's too stale, skip and catch the meeting next run. Granola refreshes its own token every time the app runs, which is exactly when new meetings happen, so by the time there's something new to export there's almost always a fresh token waiting. The thing self-heals without me touching the rotation.
Why Not Just the MCP
Around the same era Granola encrypted its local store, it shipped an official MCP that hands your meeting notes straight to Claude, Cursor, and ChatGPT7. So why spend an evening peeling apart safeStorage when the vendor will pipe the notes to my agent directly?
If all you want is notes-in-Claude, the MCP is the right answer, and I'd point you at it. But it's a live, per-tool connector, not a durable local corpus. The bet from post #1 is local, tool-agnostic, indexable markdown sitting in git, which QMD indexes alongside every Cursor, Claude, and Codex session and all the service docs. An MCP doesn't give me that. It gives me a connection, and connections come and go with the tool on the other end. The whole thesis is that the substrate outlives any one connector, so I went the other way: export to files I own, not a socket someone else hosts.
Two tools had now broken in two completely different ways. Cursor hid the project; Granola locked the door. And the fixes for both had to land inside four copy-pasted exporter scripts, which is exactly what made the duplication impossible to ignore any longer.
Four Scripts, One Engine
By the time Cursor and Granola had both broken, I was fixing the same bug in four places. There were four exporters by then, one each for Claude Code, Codex, Cursor, and Granola, and every one of them had started life as a standalone script and grown by copy-paste. Combined, they were over 4,000 lines. Roughly 300 of those lines were the same 300 lines, pasted into each script and then nudged out of alignment one edit at a time: the markdown renderer, the frontmatter parser with its preserved ## My Notes round-trip, the project and repo catalog, the session lister, the note/close/log mutations, and the QMD context registration. The duplication audit from the planning session put it at about 900 addressable duplicate lines, roughly 28% of the codebase. The three chat scripts alone ran past 3,000 lines, a few hundred of them byte-identical across the set.
Both of the textbook costs of that duplication had stopped being theoretical. A renderer fix didn't land once; it landed three or four times, once per copy, and I'd inevitably miss one. The copies drifted, too. At one point only the Claude script could annotate a session. Codex, Cursor, and Granola users had no note, no close, no log at all, because I'd written annotations into the Claude copy and never carried them across. The Cursor full-transcript work from the last section only existed in the one place I'd last pasted it. Four scripts had quietly become four dialects of the same idea.
The architecture I already had
I didn't have to design the answer, because I'd built it at SAMMY earlier fo our context system. The same week I hit the Cursor storage bug, I refactored to one normalized schema, one decoupled renderer, per-source adapters, a registry, and a shared CLI. The new service reads the same Cursor SQLite databases the personal tool does and now wears the same skeleton. That's the actual dogfooding payoff: I lifted the architecture straight from the service my team runs.
So the refactor became a transcription job. Pull the shared parts out of the four scripts and into a cairn package, and leave each adapter holding only what's genuinely its own.
The extracted engine is small and boring on purpose. rendering.py is the one markdown renderer: a pure function over a normalized list of message dicts that knows nothing about Claude or Codex or Cursor, only the shape of a message. It enforces a single fidelity contract, which is that a turn whose reasoning is encrypted renders an explicit > [reasoning: unrecoverable] marker rather than silently dropping it. schema.py is the normalized contract every adapter has to produce, expressed as TypedDicts: role, text, thinking, tool_calls, code_blocks, and the reasoning_recoverable flag. frontmatter.py is the hand-rolled YAML-subset parser plus the two source-agnostic halves of every exported file, the preserved status/tags/rating/comments/projects tail and the ## My Notes block, both round-tripped so your manual edits survive a re-sync. The rest of it is config.py, projects.py, paths.py, listing.py, mutations.py, and qmd.py. The control side lives in sources/base.py (the ConversationSource abstract base class that carries every shared CLI verb), the name-to-adapter dispatch tables in __main__.py, and cli.py.
What an adapter still owns
After all that, an adapter is almost nothing. It knows how to find and parse its native store, how to build its own frontmatter head and body, and how to mint its short IDs. Everything else it inherits. The whole interface comes down to one method:
assemble_document(head, body_extra, title, existing_fm, my_notes, body_main)The adapter hands over its frontmatter head and any extra body sections, and the base slots in the shared preserved tail, the title, and My Notes before appending the body. For the chat sources that body is a rendered conversation. For Granola it's a meeting transcript. The base doesn't care which, which is exactly why Granola, the one source that isn't a chat at all, drops into the same machinery as the other three without a special case anywhere in the core.
The split is the one part of this refactor I'd defend in a code review. The data side and the control side pull in opposite directions. The renderer and the schema are composition: a plain function consuming a dict contract, no class hierarchy, trivially testable in isolation and impossible to entangle with any one source's quirks. The control side is the single place a base class actually earns its keep, because the CLI verbs and document assembly are genuinely shared control flow, so ConversationSource is a real abc.ABC and it exists only there. The rule I followed, and the one I'd give anyone doing this: inheritance for control flow that's truly shared, composition for data transforms. Reach for a base class to share data-shaping logic and you've just glued your transforms to your class tree. The product service uses a Protocol everywhere; I kept the Protocol's spirit on the data side but used a real ABC on the control side, because a Protocol there would have re-scattered the verbs I was trying to consolidate.
The output is the memory
The part that kept me honest was also the scariest. Refactoring a memory system is terrifying because the output is the corpus. Change the renderer by a single byte and every file re-embeds, the index churns, and you lose the ability to tell a real change from refactor noise. The behavior I had to preserve wasn't a function's return value; it was the thousands of markdown files QMD had already indexed, and the only safe outcome was that QMD never notices I touched anything.
So the acceptance test wasn't "still works." It was byte-for-byte identical markdown to the old scripts, verified by re-exporting the real stores and diffing every file. Claude came out at thousands of files, all identical. Codex in the hundreds, Cursor in the thousands, identical. Granola matched across hundreds of render cases plus a live run. The earlier config refactor cleared the same bar from the other direction: projects.py output identical across every case, and the title-escape rewrite showed zero churn across every titled file in the corpus. Byte-identical meant the refactor was invisible to the index, which is the only acceptable result when the index is the thing you're protecting. The full-transcript upgrade from the Cursor section, the one genuine output change in this whole stretch, I deliberately landed as its own separately-verified commit. It never got smuggled in under cover of the refactor, because the moment you let one real change ride along with a no-behavior-change refactor, the diff stops being verifiable.
Closing the corruption bugs
With the parser finally in one place, the latent bugs in it got obvious, and there were more than I'd have guessed. They're all fixed now, all stdlib-only, all fail-loud where they used to fail silent. The mutations were the worst of them: note/close/log had been running un-anchored re.sub over the entire file, so a status: or rating: or tags: string that happened to appear inside a transcript body could get clobbered. They now route through rewrite_preserved_tail, which only ever rewrites the preserved tail using the same emitter the exporter uses, so a mutated file is byte-identical to a fresh export. A newline in a tag could inject a top-level frontmatter key; closed. Title escaping was made idempotent, which is what killed the backslash growth across syncs. And the silent fallbacks are gone: parse_frontmatter raises FrontmatterParseError on YAML it can't round-trip instead of mis-parsing it, listing.py surfaces unreadable files instead of quietly dropping them, qmd.py reports real success or failure with stderr attached, and the CLI no longer prints "ok" on a mutation that did nothing. In a memory system a silent fallback is silent data loss, so all of it stops loudly now, with a real tests/ unittest suite pinning each case.
No Personal Paths
The other thing the refactor forced was admitting how machine-specific some of the scripts had been. Every path, every native store location, the QMD binary, the whole project and repo catalog, hardcoded across roughly a dozen files. All of it moved into one cairn.config.json behind a typed, fail-loud loader in config.py, and a missing required key raises ConfigError rather than falling back to my directory layout, because a silent default would write one person's data into another person's folders.
While I was at it the engine package itself got renamed from session_core/ to cairn/, and hundreds of megabytes of personal synced data, the five collections and their logs, moved out of the repo root and into .context/, so the root finally became code and config and docs only, the kind of thing you can hand to someone. The whole engine stayed stdlib-only through all of this. No PyYAML, no pydantic, just a hand-rolled YAML subset and TypedDicts.
The payoff is the thing I check when I wonder whether a refactor was worth it. Adding a fifth source is now one adapter file and one @register(...) line. note, close, and log work on every source instead of just Claude. There's a real test suite. And the whole thing fails loud everywhere it used to corrupt quietly.
It was a clean package now, sharable in principle, with no personal paths left in the code and a root you could clone on its own. But it was still a package I ran from my own checkout, with my own config sitting next to it and a cron line pointing at my repo. To actually give it away, it had to stop being mine.
From a Checkout to an Install
What kept it mine was one line of config.py. It found its config by walking up the directory tree from Path(__file__) until it hit the repo root. That works perfectly for me, editing in place with pip install -e .. It breaks the instant the package lives in a venv with no repo above it. A global install copies cairn somewhere under ~/.local, the walk-up finds nothing, and the whole thing falls over before it does any work.
So the first thing I had to kill was the assumption that the code and its config live in the same place. To give cairn away, those two had to come apart.
Config Without a Repo
Config resolution became a short, boring precedence chain: $CAIRN_CONFIG first, then $XDG_CONFIG_HOME/cairn/config.json (falling back to ~/.config/cairn/config.json)8, and finally the repo root, kept only as a dev fallback so my own pip install -e . checkout keeps working. find_repo_root() stopped raising when it found nothing and started returning None. A new cairn config init writes that user config from a template bundled with the package, so a stranger never has to know the JSON shape by hand.
While I was at it, the package moved from cairn/ to src/cairn/. That sounds like bureaucracy, but it mattered for exactly this tool. A flat layout lets the current working directory shadow an installed package, and cairn walks the filesystem for a living. The last thing I wanted was for cd-ing into the wrong folder to change which cairn Python imported. The five adapters moved with it into src/cairn/sources/, and the sys.path.insert hacks that the old skill scripts needed to find each other all went away.
One Command, Lazily Dispatched
The old workflow leaned on five shell aliases - cs, crs, scs, grs, sds - one per source. On my machine they were muscle memory. On someone else's machine they're a PATH-collision landmine waiting to clobber whatever cs they already had. So pyproject.toml (hatchling, dynamic version, an optional granola extra for cryptography) exposes exactly one entry point: cairn.
Behind that single command sits a lazy dispatcher. __main__.py holds two plain name → "module:function" tables - one for sources, one for the top-level admin commands - and imports only the one a given invocation actually needs. That isn't premature optimization. This command fires from a hook on every session end, and importing all five adapters plus cryptography plus five config reads on every shutdown is a tax you pay constantly for a thing you rarely use.
_SOURCES = {
"claude": "cairn.sources.claude:main",
"codex": "cairn.sources.codex:main",
"cursor": "cairn.sources.cursor:main",
"granola": "cairn.sources.granola:main",
"service-docs": "cairn.sources.service_docs:main",
}
_ADMIN = {
"sync": "cairn.sync:main",
"recall": "cairn.recall:main",
"doctor": "cairn.admin:doctor_main",
"config": "cairn.admin:config_main",
"cron": "cairn.admin:cron_main",
}Pulling the aliases also handed me a free win. An audit found 13 drift issues across the five adapters, the inevitable result of args that grew independently over months. Normally fixing that means breaking callers and apologizing. Here there were no external callers to protect, so the cleanup cost nothing.
Argparse parent parsers now declare the shared flags once and attach them everywhere. export is the verb on every source, which meant service-docs' lonely sync became export like the rest; sync is reserved for the top-level fan-out. One unified time window replaced a zoo of --today-style flags: --since today|yesterday|Nd|Nw|YYYY-MM-DD, defaulting to the last 24 hours, mutually exclusive with --all. --json works everywhere, data goes to stdout and messages to stderr, and the exit codes are stable: 0 ok, 1 runtime error, 2 usage error. The point of all that uniformity isn't tidiness for its own sake - it's that an LLM driving the tool from cairn help sees the same shape no matter which source it pokes.
The Plugin Is the Repo
Distribution of plugins turned out to be the part I understood least. The answer reshaped the whole arc: Claude Code plugins have no install-time hook9. There's no postinstall, no moment where the plugin gets to run a script. The blessed way to do install-time work is a skill the user invokes themselves.
That's a constraint I ended up grateful for, because writing config, editing a crontab, and installing software are exactly the things that should ask first.
One repo plays both roles. .claude-plugin/plugin.json and .claude-plugin/marketplace.json sit side by side, the marketplace pointing at source: "./" and the plugin omitting its version entirely so every commit counts as an update. The SessionEnd hook that keeps Claude sessions fresh ships inside the plugin via hooks/hooks.json, wired to cairn sync --hook. The old approach rendered that hook into the user's global settings.json, which is invasive and a pain to cleanly remove. Bundling it in the plugin means enabling the plugin registers the hook and disabling it cleans up after itself. I never touch your settings.
The setup skill carries disable-model-invocation: true and a narrow allowed-tools, so the model can't decide on its own to go installing things. It checks that qmd is present and, if it isn't, tells you to run npm install -g @tobilu/qmd2 rather than silently installing a global npm package on your machine - the same restraint the LSP plugins use. Then it asks which sources you want - the session sources on one prompt, and a separate question for service-doc folders, each wired to cairn config add-service-doc <name> <path> so you never hand-edit JSON to point it at a docs directory. It installs the engine globally with uv tool install git+https://github.com/shavvimal/cairn10 (pipx as a fallback), writes your config, bootstraps the QMD collections and runs the first embed (~2GB of models, kicked off in the background and watched with the Monitor tool because it can take ten minutes), registers the cron, and doesn't call itself done until a real search actually returns results, not just until cairn doctor is green. A green doctor once masked a completely empty index. So doctor now also checks that the collections are registered and the embeddings exist, and green finally means a searchable index rather than just wired-up plumbing. The cron install writes the crontab line using the absolute cairn path from shutil.which, because macOS cron runs with a PATH of just /usr/bin:/bin and would never find a uv-installed command otherwise.
Proving It Survives Cron
The single scariest unknown in this whole model was whether "install globally, run from cron" actually works. Cron's stripped-down PATH is where these schemes usually die quietly, with no error and no exported sessions, just a memory layer that silently stops updating. I didn't want to find that out a week later. So I forced the question:
env -i HOME=$HOME PATH=/usr/bin:/bin "$(command -v cairn)" sync --hookenv -i wipes the environment down to nothing and hands cron's minimal PATH to the absolute cairn binary. If that line exports sessions, the model holds. It did. The reason it works is the same reason every alias-free, absolute-path decision exists: the sync orchestrator never relies on a PATH lookup. It invokes each source as [sys.executable, "-m", "cairn", <source>, "export", …], interpreter-relative, so sync behaves identically whether it's fired by cron, by the hook, or by me running it by hand.
The last piece was making "which sources, how often" a property of config rather than code. Each collection carries a sync policy, the SyncPolicy: {enabled, since, on_hook}. cairn sync builds its plan straight from the config, and cairn config init --enable claude,codex,… bakes the setup choices in. The static binding of source to collection stays in code, since that's just which adapter implements a source, never policy. The nice consequence is that turning an integration off can never crash sync on a store you don't have - a disabled source is simply skipped.
With distribution stress-tested, the repo itself needed the same discipline. Branch protection gates every change behind CI; a version-bump check refuses any source change that doesn't move __version__, and a release workflow turns that bumped version into a tagged GitHub release on its own. The tests proved the code worked; this proved it was safe for someone else to work on.
All of that collapsed into four lines a stranger types:
/plugin marketplace add shavvimal/cairn
/plugin install cairn@cairn
/reload-plugins
/cairn:setupSkills are namespaced, so they show up as /cairn:setup, /cairn:recall, and friends. The final set is four: setup, cairn, recall, search.
It installs in three lines now, and it has run every day on my own machine since.
What's Still Rough
This isn't a finished product, and a few things still bother me.
Cursor still has a ~7.5-second floor on every run, even when nothing changed. As I covered earlier, the timestamps that tell the exporter what to skip are buried inside the database's JSON columns, so it has to stream thousands of blobs just to read them. The expensive work after that is skippable. The floor isn't, and every no-op export pays it.
Granola is the part I trust least, and for good reason. The whole token-recovery chain hangs off Granola's current key derivation. If they change how they wrap that token, summaries access breaks again and I'm back to reverse-engineering the new scheme. It works today. I wouldn't bet it works next quarter. That's the nature of reaching into a store someone else owns: you're a guest who picked the lock, and the locks change.
The space keeps moving too. Post #11 named a dozen "memory for coding agents" projects, Total Recall, Ghost, Engram among them, and they're still multiplying. What's new is that the vendors are now closing the gap from the other direction. Granola shipping its own MCP7 is the clearest example: instead of you cracking the store open, the tool hands you the connector. Useful, but it points the data at the vendor's model on the vendor's terms. The right abstractions for any of this haven't settled, and I don't think they will for a while.
The next refactor is already specced. Right now each skill carries its own logic; the plan is to make them thin invokers over cairn help, so the CLI is the single source of truth and the skills just call it. Small change, fewer places for the two to drift.
So here's where the bet lands. Post #1 wagered that context outlives the tools you use it in. The three months since came with receipts. Cursor changed where it records a session's project. Granola encrypted its entire local store and shipped a different access model on top. The churn hit in exactly the places the bet predicted, the storage layer and the access layer, and the markdown-in-git substrate absorbed every bit of it. The index never noticed. The tools moved; the corpus didn't. That's the whole point of betting on a tool-agnostic substrate: the tools are disposable, and the thing you actually care about isn't.
It's the tool my team and I reach for every day, and now it's yours too. There will always be another store to crack open, another tool to absorb, another edge in the sync pipeline, but the alternative, starting every session from scratch, is worse. So you stack the next stone on the cairn11 and keep walking. The trail's still there when you come back.
References
Footnotes
-
Shav: "How I Gave My Coding Agents Persistent Memory", 2026 ↩ ↩2 ↩3
-
Tobias Lütke: "QMD - Query Markup Documents", GitHub, 2025 ↩ ↩2
-
Granola: "Granola updates" - the rebrand, "2.0", SOC2, and MCP timeline ↩
-
Tom Elliot: "obsidian-granola-sync", GitHub ↩
-
openclaw: "graincrawl", GitHub ↩
-
getprobo: "Reverse Engineering the Granola API", GitHub ↩
-
Granola: "Granola MCP" - meeting notes to Claude, ChatGPT, and Cursor via the Model Context Protocol ↩ ↩2
-
freedesktop.org: "XDG Base Directory Specification" ↩
-
Anthropic: "Claude Code plugins" and "plugin marketplaces" ↩
-
Astral: "uv - an extremely fast Python package and project manager" ↩
-
Shav: "cairn - local memory for AI coding agents", GitHub, 2026 ↩