Musubi: a history of everything you asked
I have thousands of conversations with AI tools scattered across four applications. Musubi reads all of them, distils them, and hands the result back to any agent over MCP, with nothing ever leaving the machine.
I talk to models through six different tools, and each one keeps its own record of what I said. Claude Code writes JSONL session logs. Cursor keeps SQLite blob stores. Antigravity writes protobuf into a database per conversation. ChatGPT and Claude hand it back as a zip file when you ask for an export. Obsidian holds the notes I wrote myself.
Between them they hold most of my thinking from the last few years. A decision about a database index sits in one tool. The constraint I explained three times is in another. The conclusion I reached at 11pm and forgot by Thursday is in a third. None of these tools can see each other’s history, and none of them can see mine from last month.
I wanted one place. Everything I’d ever asked, from every tool, in one file I own, searchable, and readable by whichever tool I happen to be in next. That’s Musubi.
One place had to mean my laptop. The point was to stop my history being spread across services I don’t control, and uploading it to a seventh service to get it out of the first six would have defeated that. So there’s no API key, nothing gets uploaded, and that one decision shapes everything below.
The name is musubi (結び, also written むすび) - the noun form of the verb musubu (結ぶ), to tie or bind. It’s the word for a knot, and for the closing lines of a speech or a story, and in Shinto it names the force that joins things together and makes something new out of them. All of that fit. Musubi ties together conversations that each tool keeps to itself, and what comes out the other end is a conclusion rather than a transcript. A rice ball pressed into shape by hand is an omusubi (おむすび) for the same reason, and it’s the closest picture I have for what distillation does to a thousand transcripts.
The build went in three stages, and so does this post. First, get everything into one file. Then find out that a searchable archive answers a different question from the one I was asking. Then hand the result back to the tools that produced it.
Here is where it ended up, on one screen:
Musubi is an npm workspace monorepo with 4 packages. The engine is the whole system, and the CLI, the MCP server and the desktop app are thin wrappers that construct the same Engine class and call the same methods, so the app can’t drift from what an agent sees.
packages/
engine/ headless core: adapters, store, search, distillation
cli/ thin command-line interface over the engine
mcp/ MCP server exposing the engine's read side
desktop/ Electron app
The toolchain is bare on purpose. Node 24 runs the TypeScript directly through type stripping, storage is node:sqlite, and there’s no build step outside the desktop package. node packages/cli/src/index.ts ingest works on a clean checkout after npm install.
Stage one: everything in one file
The first version did one thing. Read every conversation out of every tool, write it into one SQLite file, and make it searchable. That meant six adapters, one schema, and an index.
Six sources, one shape
Every adapter yields the same two types:
export interface Message {
id: string;
role: "user" | "assistant" | "tool" | "system";
text: string; // flattened, human-readable
toolCalls?: unknown[]; // preserved where present
createdAt: string | null; // ISO 8601, or null when absent
}
export interface Conversation {
id: string;
source: Source;
title: string | null;
project: string | null;
createdAt: string;
updatedAt: string;
sourceRef: string; // original path/key, so the engine can re-read
messages: Message[];
}
Identity is derived, never generated. A conversation’s id is source:nativeId, a message’s is conversationId#nativeId, and sourceRef keeps the pointer back to the original file or row, so anything derived later can be traced to the transcript it came from. Run ingest twice and the second run changes nothing.
An adapter implements isAvailable(env) and an async generator scan(env). It streams because a large Claude Code history is thousands of files, and the engine indexes each conversation as it arrives.
Adapters only ever read. Past that principle, each source is its own small archaeology problem.
Claude Code stores append-only JSONL under ~/.claude/projects/. The format has shifted across CLI versions, so every field access tolerates absence and a corrupt session gets skipped instead of aborting the scan.
It also injects harness boilerplate into transcripts: <system-reminder> blocks, command output, raw ANSI escapes. You never see any of it in the CLI. A topic model sees all of it, and will happily decide that “Caveat” is one of your major interests. A stripInjected function removes 8 tag types inside the adapter, because the alternative is stripping them separately in search, embeddings, topics and distillation, and forgetting one.
Cursor keeps conversation state in SQLite files named state.vscdb, one global and one per workspace. The adapter opens both read-only and tolerates schema variation, because what’s stored there was never a public contract.
Antigravity is the most involved. Each conversation is its own SQLite database whose steps table holds a schema-less protobuf blob, with no .proto to compile against. So Musubi ships a generic wire-format walker that decodes the varint and length-delimited structure, recurses into nested messages that parse cleanly, and collects prose strings by field number. Step type 14 maps to user, 15 to assistant, everything else to tool.
If you ever need to do this yourself: protobuf’s wire format is self-describing enough to walk without a schema. You lose field names and types, but you can tell a nested message from a string by trying to parse it and checking whether every byte is consumed.
Obsidian turns each vault note into a single-message conversation, so my own notes land in the same index as my chat history. ChatGPT and Claude exports parse the conversations.json from an official data export.
The file
One SQLite file. node:sqlite opens it, loads sqlite-vec, then disables extension loading again. FTS5 ships inside Node’s bundled SQLite, so full-text search needs no extension at all.
The schema is 9 tables: the normalised transcript (conversations, messages), chunks with an FTS5 twin, a vec0 vector table created once the embedder’s dimensionality is known, notes, topics and their assignments, user overrides, token accounting and a key-value meta. The last four only matter from stage two on.
Ingestion is idempotent through a content hash over the source, title, project and every message. If the hash matches what’s stored, nothing gets rewritten.
Two things to know if you’re building on node:sqlite and sqlite-vec together. The vector table’s rowid must be bound as a BigInt, because node:sqlite binds plain numbers as REAL and vec0 rejects that with Only integers are allows for primary key values (upstream’s typo, not mine). And if the extension fails to load, vector search becomes a no-op instead of an exception, and keyword search keeps working.
Searching it
Messages get chunked at 1200 characters with 150 of overlap, breaking on the last space or newline in the back half of the window. Each chunk carries its lineage (chunk, message, conversation), which is what lets a citation deep-link into a transcript later.
Each chunk gets embedded by all-MiniLM-L6-v2 through transformers.js at int8 precision: 384 numbers per chunk, a 23MB model, downloaded once and run on the machine. The vectors are scaled to unit length so that comparing two of them is a plain dot product.
The provider’s identity string is transformers:all-MiniLM-L6-v2:384. It names the model and the dimensions and leaves the precision out on purpose. The store drops both indexes when that id changes, and int8 and float32 vectors of the same model agree to 0.997 cosine, so switching precision shouldn’t cost a multi-hour rebuild.
Keyword and vector search answer different questions. BM25 finds the conversation where you actually typed “sqlite-vec”. Vector search finds the one where you described the problem without ever naming the library. Their scores are incomparable, so Musubi fuses them by rank:
const RRF_K = 60;
for (const [rowid, rank] of keywordRanks) {
fused.set(rowid, (fused.get(rowid) ?? 0) + 1 / (RRF_K + rank));
}
for (const [rowid, rank] of vectorRanks) {
fused.set(rowid, (fused.get(rowid) ?? 0) + 1 / (RRF_K + rank));
}
The constant compresses the gap between ranks, which means corroboration beats a single strong hit. A chunk landing 40th in both lists scores 0.020; one that’s 2nd in only one scores 0.016. Every result keeps its keywordRank and vectorRank, and the UI shows them as kw#3 / vec#7 badges.
At this point Musubi did what I’d set out to build. Every conversation from six tools, in one file, searchable in both ways. And the first real question I asked it showed the gap.
“Where did I discuss the vector index” works. “What did I decide about the vector index” doesn’t. Search returns the six conversations where it came up and leaves me to reread them, which is the job I was trying to get rid of.
Stage two: from transcripts to conclusions
So every conversation gets distilled into a structured note, and the note is what the rest of the system reads:
interface StructuredNote {
title: string;
summary: string;
keyPoints: string[];
entities: string[]; // people, tools, libraries, services, files
decisions: string[]; // conclusions actually reached
facts: string[]; // durable personal facts, explicitly stated
actions: string[]; // open loops
topics: string[];
tags: string[]; // always empty; carried for compatibility
}
decisions is the field I built this stage for. facts and actions turned out to matter as much, because they’re what turn an archive into something that knows you. Facts are durable statements that stay true across projects: a preference, a constraint, a piece of background.
Telling a model to exclude “anything tied to the current project” stops working somewhere around the hundredth conversation. What works is a test it can apply one candidate at a time (would this still be true a year from now if you changed jobs?), a named reject list of the predictable failures, and the reminder that most conversations contain no durable facts at all, so an empty list is the expected answer.
The prompt’s examples of a good fact are about someone else on purpose: “lives in Lisbon”, “is colour-blind”, “prefers tabs over spaces”. A model shown plausible facts about the person it’s profiling will copy them into its answer whether or not the conversation supports them, and when the examples happen to be true you can’t tell from the result that anything went wrong.
The model that writes them
Distillation is where the laptop rule starts to cost something, because now a language model has to read every conversation.
It runs on Qwen3-30B-A3B-Instruct-2507, quantised to 4 bits. The 30B is misleading. It’s a mixture-of-experts model, so roughly 3B of those weights get used for any given token, which is why it writes at about 35 tokens a second on an Apple Silicon Mac while sitting in 16GB of memory.
LM Studio serves it on an OpenAI-compatible endpoint, and Musubi talks to it over plain fetch with no SDK. Ollama or llama.cpp’s server would work identically. How you load it matters more than which server you pick:
lms load qwen/qwen3-30b-a3b-2507 --context-length 16384 --parallel 2
--parallel 2 lets two distillation requests run at the same time, and --context-length is the total for the loaded model, split evenly across those slots. One distillation request needs about 4,000 tokens of room (a prompt of roughly 1,500, a sample of the transcript, and the JSON that comes back). Drop a slot below that and the server answers with context-size errors that look like a broken model. The rule is parallel × 4096.
Pick the Instruct variant. Qwen3 also ships hybrid reasoning models, and for schema-filling work the thinking pass is pure cost.
Structured output uses response_format: { type: "json_schema" }, so the server grammar-constrains decoding rather than the prompt asking politely. The parser still tolerates JSON wrapped in prose, and a token cap backstops a model that never emits a stop. Three more decisions in that client fall straight out of running locally.
Reasoning is off. Extraction into a fixed schema gains nothing from chain-of-thought, and on local hardware reasoning tokens are pure decode time. Requests carry chat_template_kwargs: { enable_thinking: false } and a /no_think marker, because no single switch works everywhere.
Output is capped. Generated tokens dominate the cost of local distillation, well ahead of prompt size, so every list in the schema carries maxItems and the summary is held to 2 sentences. There’s a correctness reason too. With grammar-constrained decoding, an array with no maximum never gives the model a reason to close it, so it keeps adding items until it hits the token limit or the connection times out.
Transcripts get sampled from both ends. Truncating a long session at the front spends the whole budget on the setup and throws away the conclusions. So inside an 8,000-character budget the renderer spends 45% on the head and the rest on the tail, and tool output gets clipped to 200 characters first.
Doing it 1,000 times
Conversations get distilled 2 at a time, both workers pulling from a shared position counter so a slow conversation holds up one worker instead of stranding the other. 2 is timid on purpose. Going to 4 bought between 1.1x and 1.3x on models that fit comfortably, and a 14.6GB model collapsed to a quarter of its serial speed once 4 KV slots pushed it into thrashing.
Anything that fails during the concurrent pass gets retried once, serially, because a request that would succeed alone can overflow its context share alongside others. Without the retry, a run reports success while silently leaving notes missing.
Conversations under 2,000 characters skip the model and get distilled heuristically. A 2-exchange session yields the same near-empty note either way, and on a 1,155-conversation library that’s 15% of the work avoided.
Turning 1,846 facts into a profile
Distillation leaves one note per conversation. The profile view has to turn all of them into a short list of things that are true about you, and on my library that means starting from 1,846 candidate facts.
They arrive filthy. The same fact appears in a dozen phrasings, contradictory facts sit side by side because you moved house, and a good deal of what the model labelled a fact is project state wearing a disguise. Three layers deal with that, cheapest first.
A better dedupe key. Stripping the “User” subject, a leading copula and trailing punctuation collapses 58 duplicate pairs on my library with no model involved. That’s also the fallback when the model is unreachable, and a fallback showing obvious duplicates makes the whole feature look broken.
A filter for things that aren’t durable, applied at read time: "User has 1,148 notes in the DB", "User plans to open-source the repo". 146 candidates on my library. It runs on the way out rather than during extraction because notes are expensive to regenerate, so a correction applied at read time reaches the whole library at once. The patterns are cautious, because a false positive silently deletes something true about you. A list of durable traits sits in the test suite and fails the build if a pattern ever catches one.
Only then does the model get involved, for the merges that need actual understanding. “User is named Ana” and “User is Ana Costa” are the same fact, and no string normalisation will tell you so. “Based in Porto” against “Based in Lisbon” needs the dates on each candidate, and the most recent wins. Only the 80 best-corroborated candidates go to the model, and the result is cached in SQLite against a signature of its inputs, because the call costs 30 to 40 seconds.
Topics
A thousand conversations in one file need a map, and asking each conversation to invent its own labels produces sprawl: hundreds of near-duplicate topics. Musubi inverts it. The model gets asked once to propose a taxonomy of 8 to 20 specific, non-overlapping categories from a sample of up to 500 notes, with explicit instructions to avoid buckets like “General” or “Misc”.
Each category and each note then get embedded, and each conversation goes to its nearest category by cosine similarity. Anything below 0.18 lands in an explicit Unsorted bucket instead of being forced somewhere it doesn’t belong.
Without a model, the fallback is k-means over the note embeddings, about 80 lines, seeded deterministically so the result is identical on every run.
The everyday path is incremental. Only new or changed notes get reassigned, against the existing taxonomy, so category names stay stable between runs. Your renames, hides and merges live in a separate table that a rebuild never touches.
The entities inside each note give a second structure for free. Two topics that keep mentioning the same libraries are related whether or not their names suggest it, so the map draws an edge between them, capped at roughly 3 per node to stop it collapsing into a hairball.
Node size is conversation count, which makes Unsorted the largest thing on screen. That’s the quality gate doing its job. Material the taxonomy can’t place stays visibly unplaced.
Stage three: giving it back
By now there was a file that knew what I’d decided, what was still open, and what stays true about me across projects. The last stage was getting that in front of the question, whether I asked it in Musubi’s own window or from inside one of the six tools that produced the history.
Asking it directly
The chat view answers questions across the whole library. The pipeline: expand the question into a standalone retrieval query, over-fetch from hybrid search, rerank, assemble context, synthesise a cited answer.
Reranking uses a cross-encoder, ms-marco-MiniLM-L-6-v2, on the same runtime as the embedder. It does one small forward pass per query-passage pair, no generation, at about 2 milliseconds each. Ranking dozens of candidates with the chat model instead would mean prefilling every one of them through a 30B model purely to get an ordering back.
Its scores also mean something on their own, which matters because retrieval always hands back its best 60 candidates. Ask something your history has no answer to and you still get 60 of them, ranked. Measured on a 1,155-conversation library:
| Query | Best candidate score |
|---|---|
| ”sqlite vector search” | +5.19 |
| ”what have I written about MCP” | +4.15 |
| ”how does the topic taxonomy get built” | +1.29 |
| ”what is the capital of Peru” | -9.82 |
| ”purple monkey dishwasher” | -10.40 |
The floor sits at -5. When nothing clears it, the synthesiser is never called and the answer says it found nothing rather than assembling a confident tour of whatever matched least badly.
The model that writes the answer reads notes rather than the chunks retrieval matched. The vectors decide which conversations answer your question, and each conversation’s distilled summary, key points and decisions are what the model reads about them. Raw chunks would give it the conversation as it happened; notes give it the conclusions, which is the whole reason stage two exists.
Answers stream, since local decode runs at tens of tokens a second and buffering means staring at a spinner for the whole generation. Citations are passage-level: clicking [3] opens that conversation and scrolls to the message the passage came from.
The one-word follow-up shows why query expansion is there. “croatia?” retrieves nothing on its own, and only resolves against the previous turn.
Asking it from the tools that made the history
A history locked inside its own app would put me back where I started, with a seventh tool that can’t see the other six. The point is for the tools I already work in to reach it, which is what the MCP server does. (If the protocol is new to you, I’ve written up what an MCP server is and how to build one and the client side of the same conversation.)
10 read-only tools:
| Tool | Purpose |
|---|---|
search_conversations | Hybrid search across the library |
read_conversation | Full normalised transcript by id |
list_topics | The current taxonomy, with each topic’s conversations |
generate_context | Grounded, cited answer to a question or a topic |
get_profile | Library statistics and durable facts |
who_am_i | The facts profile |
recall_decisions | Decisions, optionally about a subject |
list_open_loops | Unresolved next steps |
find_similar | Related conversations, by id or free text |
get_dossier | Everything known about an entity or topic |
They run over stdio by default, so any MCP client can spawn the server as a subprocess, or over Streamable HTTP with --http, localhost only. Every tool is annotated readOnlyHint: true, so an agent can recall what you decided but can’t rewrite your history.
This closes the loop. Claude Code wrote a session log in the morning, Musubi ingested it, and by the afternoon Cursor can ask what was decided in it.
Keeping the app alive while it works
The desktop app is Electron, and its process layout is dictated by 2 libraries that refuse to share an address space.
The engine runs in an Electron utilityProcess, because distillation and clustering are heavy synchronous loops and running them on the main thread beachballs the UI. A Web Worker can’t do the job. Electron won’t load a native module there, and the engine needs sqlite-vec.
Embeddings and reranking run in a separate child process that loads transformers.js and nothing else, because onnxruntime and the sqlite-vec extension crash the process when they’re loaded together. So the engine worker owns SQLite, one isolated Node child owns ONNX, and vectors travel between them over IPC. That child is treated as something that will die: batches of 48, a recycle every 2,000 texts, two retries on a fresh child, and a bisect to isolate the one text that can’t be embedded.
Auto-sync watches the 4 live source directories and re-ingests on change. It’s ingest-only. Distillation stays an explicit action, because quietly spending minutes of someone’s GPU because they opened a terminal is rude.
What the laptop rule cost, and bought
Building this without a cloud model imposes a discipline a hosted API lets you skip. When tokens are cheap and fast, you send the whole transcript, ask for everything, and let a reranker sort it out. When they’re neither, you find out exactly what you’re paying for, and the answers weren’t the ones I expected.
How much the model writes matters enormously. How much you send it barely registers by comparison.
A smaller model is no faster. Generating a token means reading the weights it needs out of memory, and a mixture-of-experts model only touches a few billion of them per token, so a 4B dense model and a 30B mixture-of-experts model finish in roughly the same time.
Parallel requests help less than you’d hope, because a single request already uses most of one GPU’s memory bandwidth. Speculative decoding can’t be combined with parallel requests in this setup, and without the parallelism it was slower than not using it.
The lever that works is the boring one: generate fewer tokens. Capping the schema halves the output and halves the time per conversation, and the same decisions surface from a note less than half the length.
Two more things generalise. Put corrections on the read path, because a stored note freezes whatever your prompt and budget were at the time, and redistilling a library costs an hour of GPU. And check what your relevance score is actually scoring. The cross-encoder floor rejects a question the library can’t answer, and does nothing about a greeting, because “hi” scores +3.62 against the best candidate while “how does the topic taxonomy get built” scores +1.29. A relevance model judges whether a passage relates to a string. Whether the string was a question at all is a different problem.
A hosted model would have made most of this easier. What I have instead is what I set out to get: a single SQLite file holding 1,209 conversations from six tools, that I can open with any sqlite3 binary, that no subscription can revoke and no policy change can reach, and that every one of those tools can now read. The 11pm decision is in there, and on Thursday I can ask for it.