Why we vendored 13,000 lines of open source
Neither a dependency nor a fork worked, so we vendored. The price is a discipline: no formatter over that tree, commits kept separate, and what we cannot fix written down.
The storage and retrieval engine behind the memory service wasn't written here. It came from an open-source library. But as described in the first post, that library's extraction prompt had to be changed — Korean conversations were being extracted into English.
That's where the choice came in.
Dependency, fork, or vendor
| Option | Our changes | Upstream updates | Diffability |
|---|---|---|---|
| Use as a dependency | impossible | automatic | not needed |
| Fork | free | cherry-pick required | blurs quickly |
| Vendor | free | selective | maintainable |
As a dependency the prompt can't be touched. As a fork it can, but within months nobody can tell whether a given line was always like that or something we did.
So I vendored it: pull the code into the repository wholesale, but keep it in a state where a diff against the original is always possible.
The rules we kept
Staying diffable against the original meant following a few rules without exception.
No formatter touches it. Linters and import sorting all go in the exclude list, because the moment it is formatted it becomes different code from the original and diffing stops working. If a commit hook objects, disable the hook and commit.
Every changed line carries a comment saying why. When comparing against upstream later, that comment is the only clue, and without it the next person just assumes the original was like that.
The import is split into three commits.
01The original, verbatim. This commit is the reference point. Mix anything else in and the reference point is gone.
Follow this and you can always diff commit 1 against today to see everything we changed.
What you learn from having to read it
You end up with no choice but to read someone else's code, and things that were invisible from the outside started showing up.
A class that is async in name only
The library ships both a synchronous and an asynchronous class. I took it for granted that we should be using the async one.
Then I opened it.
It was doing the same thing we already do by handing sync code to a thread pool. The only difference is whether you hand over a whole request or one call at a time.
Switching would gain essentially nothing, and we'd have to maintain our changes twice. All eighteen modifications live on the sync path, so they'd have to be duplicated and kept in step.
The bottleneck was somewhere else.
All that changed was doubling the connection pool. 2.3×. Going async would still have jammed at the pool — measuring the configuration was faster than changing the architecture.
A feature with no switch
Version 2 of the library contains an undocumented entity linking feature. It extracts entities from memory sentences, embeds them into a separate table, and boosts memories sharing an entity at search time. A reasonable feature.
It's also close to useless for Korean. The model is hardcoded for English and a token counts as a name if its first character is uppercase, so Korean, which has no uppercase, never qualifies. Turned on, it costs and does nothing.
It could have been fixed by swapping the model and rewriting the capitalisation test and the English stopword filter, but that collides head-on with "keep our diff minimal". So I didn't fix it, and wrote down instead what turns it on and what happens when it does.
Config keys that are silently ignored
Pass a wrong key name to the config object and it's simply ignored, with no error.
- Pass the database connection string under the key
dsnand the key is ignored as wrong; there's no connection error, it quietly falls back to a default - Point the history store at the wrong path and it drops to a local file database instead of PostgreSQL, with no log
- The graph config from v1 no longer exists in v2, but passing it is ignored without an error
All of these are the same kind of failure as in the previous post: nothing throws.
A delete path that isn't atomic
The delete path is not atomic. Removing the vector row, writing the history entry and cleaning up run in sequence on separate connections with no transaction. If the first commits and the rest fail, you get a memory that is gone with no record of its deletion.
Restructuring the vendored code to be atomic would blow up the diff, so we didn't, and put a compensating layer outside it instead. It backfills missing history and sweeps up rows left orphaned.
If the engine is replaced, the compensating layer goes with it. Until then, I judged that carrying this debt was better than tearing up the vendored code and losing the ability to diff against the original.
End