building vodou.

A save that produced nothing looks exactly like a save that failed

Browser chat capture has four outcomes, not three. If your provenance column appeared mid-life, a missing key reads as zero. Here is the SQL to check your own.

Chad Priest / / 8 min read

If you capture conversations into a memory store, your activity log almost certainly says something like “Saved 6 messages to memory.” It probably stops there. Whether those six messages became anything durable is a separate question, answered by a separate pipeline, often minutes later. Most systems never answer it at all, so “saved, nothing worth keeping” and “saved, extraction failed” end up as the same sentence on screen.

I found this out in my own stack. Five Character.AI conversations produced zero memories. That was correct: roleplay has no durable facts about the person in it. It also looked exactly like a silent failure, and I had no surface that could tell me which one it was.

What the bridge does: ChatGPT on Monday, Claude on Thursday

I built browser capture into Vodou, a local-first AI system with persistent memory and tool orchestration. The piece in this post is the Chrome extension (the Store build lives in extension/Store-vodou-bridge, documented in docs/vodou-bridge.md). It saves your chats on the AI sites you use, sends them to the gateway on your own machine, and lets you put relevant memory into your next chat with Ctrl+B. What you told ChatGPT on Monday is available in Claude on Thursday. You see what will be inserted before you send it. Auto-attach on send exists, but it is off by default and you turn it on per site.

Infographic: tell ChatGPT once and Claude already knows. Vodou remembers, acts and notices, kept on your computer.

conversation idChat on ChatGPTExtension captureto the local gateway onlyExtractionkeeps facts, drops smalltalkLocal memoryCtrl+B in Claudepreview before sendNothing leaves the machine, and nothing is inserted unseen.
One memory across chat sites

The part that matters for this post is the arrow labelled “conversation id”. Everything I got wrong was about that key.

2,008 memories knew their conversation and 8,263 did not

The feature I wanted was small. Each activity row in the side panel should say what came of it: “3 memories kept”, “nothing worth keeping”, “not read yet”. I made it a second pass on purpose. The feed is local and instant, and making it wait on a round trip to the memory store would trade a real answer for a slower one. So the row renders immediately, and the outcome fills in.

The counting was trivial. Join saved conversations to memory chunks on the column that records which conversation a chunk came from, count per conversation, done.

Then I looked at the column. It only started being written on 2026-08-18. In the live corpus, every capture chunk after that instant carried one and every chunk before carried none. A clean cut:

with conversation id2008 chunkswithout (pre 2026-08-18)8263 chunks
Capture chunks by provenance key

Built naively on that join, the feature would have reported “nothing worth keeping” for every conversation saved before August 18. Those conversations did produce memories. The memories just could not name their parent. A LEFT JOIN that returns no rows does not know why it returned no rows, and a UI that renders count = 0 as “nothing worth keeping” turns a missing key into a confident false statement about the user’s data. On the one surface where they are least likely to go check.

The work was not the counting. It was the fourth state: can’t tell. A conversation saved before the key existed gets exactly that, and never a zero.

Three stateskept Nnothing worth keepingnot read yet(old saves silently land in 'nothing')Four stateskept Nnothing worth keepingnot read yetcan't tell: saved before the key existed

Absent kept turning into false everywhere else too

Once I saw it, the same shape showed up in four other places within the same two weeks, and I shipped fixes for each.

The side panel’s link to the Brain view assumed that a missing capability flag meant “this world has no brain”. It meant “this build doesn’t report that flag yet”. I rewrote the check so absent is its own answer, not false.

When a user tapped an option on an approval card in the panel, the button sent the number as a chat message instead of answering the parked run. The run sat there waiting for an answer that had become a new, meaningless turn. The gate held and nobody was told. Channels had the same hole from the other direction: the chat forwarder had a switch with no default case, so a run that parked for permission was silent on every messaging surface. An approval nobody can see is not an approval.

When the gateway refused a browser that was not paired, the panel said “not running”. A refusal and an outage were the same sentence. The panel now renders the refusal above both messages and links to the pairing card.

And the vocabulary. A shared label function for memory scopes landed in the engine with a total mapping and a human fallback. Zero console files called it. Twenty-one sites kept rendering the raw scope, and two of them hand-rolled their own regex to prettify it. That is how a rule dies: not by being overruled, but by being copied. I moved the labels into one module, vocabulary.js, and added test/vocabulary-parity.test.mjs, which runs both implementations over 49 scope shapes and fails on any disagreement. The drift had taken under two weeks.

The invariant: a derived status over a provenance key must distinguish “no rows” from “no key”

Stated so you can check it: any status computed by joining on a provenance column must have a separate state for records that predate that column, and that state must never render as zero. It is true or false of a given codebase. If your outcome query is a LEFT JOIN plus COALESCE(count, 0), it is false.

The general form is “absent is not false”. A missing key, a missing flag, a missing event type, or a refused connection is information about your system, not about the user’s data.

Find your own cut date in five minutes

You need two tables: whatever records a saved thing (conversations, documents, uploads) and whatever is derived from it (chunks, facts, embeddings), with a nullable column pointing back. Names below are generic; substitute yours.

First, find whether your provenance key has a birthday:

SELECT date(created_at) AS day,
       SUM(source_id IS NULL)     AS without_key,
       SUM(source_id IS NOT NULL) AS with_key
FROM derived_chunks
GROUP BY day
ORDER BY day;

Passing output: without_key is zero on every day, or scattered and small (genuinely orphaned rows you can explain). Failing output: a run of days where with_key is 0, then a day where without_key drops to 0 and stays there. That flip is the day someone added the column, and everything before it is unjoinable.

Second, check what your UI would claim for saves before that date:

SELECT s.id,
       s.saved_at,
       COUNT(c.id) AS derived,
       CASE
         WHEN s.saved_at < :key_born_at THEN 'cant_tell'
         WHEN s.processed_at IS NULL    THEN 'not_read_yet'
         WHEN COUNT(c.id) = 0           THEN 'nothing_kept'
         ELSE 'kept'
       END AS status
FROM saved_items s
LEFT JOIN derived_chunks c ON c.source_id = s.id
GROUP BY s.id;

Run it without the cant_tell branch and count how many rows land in nothing_kept with a saved_at before the cut. If that number is above zero, your product is currently telling people their old saves produced nothing. If you have no processed_at either, you cannot distinguish “not read yet” from “nothing kept”, and that is the same bug one column over.

What the memory guides leave out

The good writing on agent memory is about policy. aiarch.dev splits memory into working context, durable user state and derived knowledge with different lifetimes. FintekCafe argues correctly that teams need a retention policy more than a database. MachineLearningMastery tells you to evaluate the memory layer in production. None of them mention that the provenance linking a derived memory back to its source is itself a schema that changes over time, and that your evaluation is only as honest as its oldest rows.

The browser bridge projects I read (koltyakov/browser-bridge, TNJ2026/browser-agent-bridge, dkisser/browser-bridge) are about control: giving an agent the DOM, the tabs, the session. That is a different problem. Once a bridge writes into memory instead of only reading the page, “what came of it” becomes the question users actually ask, and none of those designs have a place to answer it.

Still open: 8,263 chunks will say “can’t tell” until something backfills them

I did not backfill. Recovering the parent conversation for pre-cut chunks means matching text back to transcripts, and a wrong match is worse than an honest unknown. So those rows will say “can’t tell” indefinitely. The row labels also still undercount what a save is: “Saved 2 messages from Claude” means your prompt plus the reply, which reads like two captures and is one. And it is Chrome only.

If you want what you tell one AI to be there in the next one, kept on your own machine and shown to you before it is sent, that is what the bridge does in vodou.ai.