Split compound multi-topic thoughts into atomic single-topic thoughts, plus Gmail-specific repair tooling for email ingestion pipelines.
The atomizer takes long, multi-topic text and breaks it into small, self-contained thoughts using an LLM. Atomic thoughts embed better, retrieve more precisely, and compose into higher-signal context packs than whole-body blobs.
This recipe ships two workflows:
- Generic pack atomizer (
atomize-packs.mjs) — walks local JSON "pack" files of memories from any capture source (Instagram, ChatGPT, X/Twitter, journals, etc.), detects compound memories using lightweight heuristics (sentence count, enumeration, semicolon clauses, conjunction density), and splits each compound into atomic children. Supports three LLM providers: OpenRouter (default), Anthropic API, and Claude CLI. - Gmail re-atomization + audit (
re-atomize-gmail-thought.mjs,audit-gmail-pipeline.mjs,backfill-gmail-correspondents.mjs) — heals an existing Gmail import where long messages were stored whole-body instead of atomized. Splits the body, inserts atoms viaupsert_thought, redirectsreplies_toedges, re-links correspondents, and deletes the original row. Comes with an audit script that reports scale, metadata completeness, entity-graph integrity, and retrieval probes.
- Working Open Brain setup (guide)
- Node.js 18+
- An extended
thoughtsschema — you need the following on top of the base table:thoughts.source_type textcolumnthoughts.metadata jsonbcolumnpublic.entitiestable (at minimum:id,entity_type,canonical_name,normalized_name,canonical_email,aliases jsonb,metadata jsonb,last_seen_at)public.thought_entitiestable (thought_id,entity_id,mention_role,source,evidence jsonb, unique key on(thought_id, entity_id, mention_role, source))public.thought_edgestable with arelationcolumn (the re-atomizer looks forreplies_to)- A
public.upsert_thought(p_content text, p_payload jsonb)function that inserts a new thought and returns{thought_id}
- For the Gmail workflow only: thoughts previously imported with
recipes/email-history-importso rows carrysource_type = 'gmail_export'and the[Email from X to Y | Subject: ... | date]content prefix - An LLM provider — one of:
- OpenRouter API key (same one from your Open Brain setup) recommended, default
- Anthropic API key (direct)
- Local
claudeCLI on PATH (must be run from a standalone terminal, not inside a Claude Code session)
Warning
The atomizer used to include a codex provider that ran codex exec --dangerously-bypass-approvals-and-sandbox. That path was removed before this PR. The atomizer feeds arbitrary user-controlled memory/email text into the LLM — running a sandbox-bypass agent on untrusted input is a prompt-injection → local-code-execution primitive. Use one of the three providers above; they only generate text. If you have an older checkout that still references --provider=codex, upgrade.
Collect these once. Keep them only inside recipes/atomizer/.env.local (already gitignored via the repo root .gitignore). Do not paste service-role keys into notes, a second text editor, chat, or screenshots — they grant full database read/write/delete access and should be treated like a password.
| Variable | Source | Required? |
|---|---|---|
SUPABASE_URL or SUPABASE_PROJECT_REF |
Your Open Brain Supabase project | required |
SUPABASE_SERVICE_ROLE_KEY |
Same project, "service_role" key | required |
OPENROUTER_API_KEY |
Same one from your Open Brain setup | required for the default (openrouter) provider |
ANTHROPIC_API_KEY |
Anthropic Console | required only with --provider=anthropic |
SELF_EMAILS |
Optional, comma-separated list of your own addresses — skipped on edge pass | optional |
# From the OB1 repo root:
cd recipes/atomizerOr copy the files into any working directory.
All scripts use the Node 18+ built-in fetch and node:* modules. There's no npm install step.
Create recipes/atomizer/.env.local with your credentials:
SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
OPENROUTER_API_KEY=sk-or-v1-your-key
# Optional: comma-separated list of addresses you want skipped on the edge pass
SELF_EMAILS=you@example.com
You can alternatively set SUPABASE_PROJECT_REF instead of SUPABASE_URL. If you prefer the direct Anthropic API, set ANTHROPIC_API_KEY and pass --provider=anthropic to the scripts. A .env.example template ships alongside this recipe; copy it to .env.local and fill in real values.
Important
.env.local is loaded script-relative (via import.meta.url → fileURLToPath), so you can run the scripts from any working directory — node recipes/atomizer/test-atomize.mjs, node test-atomize.mjs from the recipe folder, etc. Keys are UPPER_SNAKE_CASE and single-line values only; process.env takes precedence over file values.
node test-atomize.mjs --provider=openrouterExpected: the script prints 2–3 atoms it extracted from a deliberately compound synthetic paragraph. If this fails, nothing else in the recipe will work.
Use this when you have pre-import JSON pack files (arrays of memory objects) from a capture source and want to split compound memories before loading.
data/atomic-memories/standard/
instagram/
pack-2025-08.json
pack-2025-09.json
chatgpt/
pack-2025-10.json
Each pack is a JSON file whose memory array is either the top-level array or nested under memories / safe_memories. Each memory object must have memoryId and text; fields like importance, type, tags, sensitivity, metadata are preserved on the children.
node atomize-packs.mjs --source instagram --dry-runPrints how many compound memories were detected and shows 3 samples. No files change.
node atomize-packs.mjs --source instagram --provider=openrouter --concurrency 4Each compound memory becomes several children with memoryId = <parent>-split-<index> and metadata.atomization = {parent_id, split_index, split_total, provider}. Pack files are rewritten in place. A sidecar atomization-report.json is written next to each pack directory.
node atomize-packs.mjs --all --provider=openrouter --concurrency 4Iterates known sources: instagram, grok, x-twitter, claude, journals, gemini, google-activity, limitless, chatgpt.
| Flag | Purpose |
|---|---|
--source <name> |
Process one source |
--all |
Process all known sources |
--dry-run |
Detect compounds only; no writes |
--concurrency <N> |
Parallel LLM calls (default 1, clamps to 4 with a warning if higher) |
--data-dir <path> |
Override the pack root (default ./data/atomic-memories) |
--provider <name> |
openrouter (default), anthropic, or claude-cli |
Re-running atomize-packs.mjs on the same data is safe: children whose memoryId ends in -split-N or whose metadata.atomization.parent_id is set are skipped on subsequent runs (they are already atomic). By default, atomization-errors.json captures only a 60-char preview + fingerprint per failure; set ATOMIZE_DEBUG_ERRORS=1 to persist full memory text for debugging.
Use this when you already have Gmail thoughts in the database but some were stored whole-body (long emails were not split at import time).
node audit-gmail-pipeline.mjs --md > audit.mdThe markdown report tells you how many gmail_export thoughts exist, how many are atomized vs whole-body, metadata completeness, entity-graph coverage (author / recipient / cc edges), top correspondents, atom samples, and simple retrieval probes.
node re-atomize-gmail-thought.mjs --id=<thought_id> --dry-run --provider=openrouterPrints what the atomizer would produce without writing.
node re-atomize-gmail-thought.mjs --id=<thought_id> --provider=openrouterThe script:
- Parses
[Email from X to Y | Subject: ... | date]prefix + body. - Atomizes the body via the selected provider.
- Inserts each atom via
upsert_thoughtwithmetadata.gmail.atom_index/atom_countset. - Redirects
replies_toedges from the old thought id toatom_0. - Deletes the old whole-body thought.
thought_entitiesedges cascade. - Re-links correspondents for each new atom.
# Every whole-body gmail thought with body >=150 words
node re-atomize-gmail-thought.mjs --all --provider=openrouter
# Limit the batch
node re-atomize-gmail-thought.mjs --all --limit=50 --provider=openrouter
# Tighter body cut-off
node re-atomize-gmail-thought.mjs --all --min-words=300 --provider=openrouterNote
Without --limit, --all processes up to 1000 rows in a single pass. If you have more whole-body gmail thoughts than that, the script prints a cap warning — just re-run until the warning stops.
Partial-failure recovery.
Caution
The re-atomize pipeline is not wrapped in a single Postgres transaction. A crash mid-run can leave partially migrated state (new atoms inserted, old row not yet deleted, edges not yet redirected). Recovery: new atoms carry metadata.re_atomized_from = <old_id>. Find half-migrated source rows with select id from thoughts where id in (select (metadata->>'re_atomized_from')::int from thoughts where metadata ? 're_atomized_from'), then re-run --id=<old_id> — the script is idempotent and will finish the migration. If you need strict transactionality, wrap upsert_thought + edge redirect + delete in a single RPC.
Useful after a one-off resolver bug or when you add new SELF_EMAILS entries.
# Preview
node backfill-gmail-correspondents.mjs --dry-run
# Live
node backfill-gmail-correspondents.mjs
# Only thoughts created after a date
node backfill-gmail-correspondents.mjs --since=2026-04-20The script walks gmail_export thoughts, pre-filters on author-edge presence specifically (a thought with only recipient/cc edges is still re-processed), and ensures every From / To / Cc address resolves to an entities row + thought_entities edge.
node audit-gmail-pipeline.mjs --md > audit-after.mdDiff against your first audit to confirm: atomized count went up, whole-body count went down, author edge coverage is at 100%, retrieval probes still match.
After a successful atomize-packs.mjs --all run on a realistic pack corpus:
- A per-source
atomization-report.jsonwith fields like{source, total_memories, compound_detected, splits_generated, net_change, errors, timestamp}. - Pack files rewritten so long multi-topic entries are replaced by 2–14 atomic children.
- Errors collected into
atomization-errors.jsonper source; halt triggers if failure rate exceeds 2% across a 100-memory window.
After a Gmail re-atomization + audit cycle, an actual run over a 350-thought STARRED inbox produced:
- 94 of 350 Gmail thoughts atomized (~27%), the rest short enough to stay whole-body.
- 100% author-edge coverage after
backfill-gmail-correspondents.mjs. - 144
replies_toedges built (47 legitimately skipped because their parent thread root lives outside the imported corpus). - Entity-keyed retrieval probe (all thoughts authored by the top correspondent) matched the edge count exactly.
- Marketing emails atomized cleanly into 9-atom sequences — coherent atoms, but probably wasted compute; see Troubleshooting.
The Claude CLI refuses to run when it detects it's already inside a Claude Code session. Fix with one of:
- Run the script from a separate terminal window (not via Claude Code's tool interface).
- Use
--provider=openrouteror--provider=anthropic— pure HTTP, no CLI at all. OpenRouter is the default.
Note
Earlier versions of this recipe supported --provider=codex as the nested-Claude-Code workaround. That provider was removed after code review flagged it as a prompt-injection → local-code-execution primitive: the atomizer feeds arbitrary user-controlled email/memory text into a sandbox-bypassed agent. Use openrouter or anthropic instead.
The model returned prose, markdown fences, or a refusal instead of a JSON array. Options:
- Retry (transient prompt-drift is the most common cause).
- Lower concurrency:
--concurrency 1avoids rate-limit slicing. - Pick a more instruction-obedient model — on OpenRouter,
anthropic/claude-sonnet-4.5works reliably; lighter models may drift into commentary.
re-atomize-gmail-thought.mjs splits any body ≥ --min-words words regardless of sender. A marketing email with 9 paragraphs becomes 9 atoms, each of which later gets embedded and stored. Mitigations:
- Raise
--min-words=300(or higher) to skip shorter promo blasts. - Pre-filter the target list in your own SQL (e.g., exclude thoughts whose From contains
noreply/no-reply/ known marketing senders) before passing individual IDs. - Accept the churn and purge low-importance marketing atoms later using your normal
thoughtscleanup pass.
The re-atomization script requires a public.upsert_thought(p_content text, p_payload jsonb) Postgres function that inserts a new thought and returns a row containing thought_id. If your schema doesn't expose it yet, either create one (it can be a thin wrapper around an insert into thoughts ... returning id as thought_id) or rewrite re-atomize-gmail-thought.mjs to insert directly — the RPC is the only reason that script is not pure PostgREST.
atomize-packs.mjs halts if more than 2% of the last 100 memories failed. Inspect atomization-errors.json in the affected source folder — typical causes are (a) your provider hit a rate cap, (b) the CLI binary path is wrong, or (c) the prompt is being blocked by a content filter on certain memory texts. Fix the root cause and rerun; the script is idempotent — memories whose memoryId already ends in -split-N or that carry metadata.atomization.parent_id are skipped on re-run, so re-splitting the same pack is safe.
Error-path logging now redacts sensitive payloads by default:
atomize-text.mjstruncates raw model output in thrown errors to a length + provider string (raw text available viaATOMIZE_DEBUG=1).atomization-errors.jsonpersists only a 60-char preview + fingerprint per failure (full memory text viaATOMIZE_DEBUG_ERRORS=1).entity-resolver.mjslogs email domain only by default, strips query-string filter values from PostgREST error messages (full output viaENTITY_RESOLVER_DEBUG=1).
Flip each flag on only when actively debugging, then clear it.