feat(memory): the backend defaults to SQLite/FTS5, and an existing memory.json is imported once - #505
Merged
brcampidelli merged 1 commit intoSep 17, 2026
Conversation
…mory.json is imported once Measured before it was flipped: bench/memory_recall (registered first, US$ 0) put both stores behind the production MemoryManager.search on 4,034 facts and 3,600 paired queries in four styles at three sizes. recall@5 differs by at most 0.01 on every cell — the two rank the same quantity and disagree on ties — while the JSON path re-tokenizes the whole store per query: 3 / 16 / 70 ms a search at 200 / 1,000 / 4,034 facts against 0.2 ms for FTS5 at every size, on every turn. The flip was one line; chimera/memory/backend.py exists because there was no JSON→SQLite import and the one line would have left every existing memory.json unread. open_memory_store resolves the backend (explicit as written; the default is sqlite where FTS5 exists and json where it does not — the LIKE fallback has no ranking), imports memory.json into a new memory.db once with every field intact, atomically, leaves the JSON in place, lets the database win when both exist, and never shadows a JSON store it could not read. Every surface builds its manager through it. Found on the way: the SQLite store had no created_at column and dropped the age the JSON store kept — added with a rebuild migration, old rows ageless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
brcampidelli
force-pushed
the
feat/the-memory-default-is-sqlite-and-nobody-loses-a-memory
branch
from
September 17, 2026 06:23
eca5643 to
0593a9b
Compare
brcampidelli
deleted the
feat/the-memory-default-is-sqlite-and-nobody-loses-a-memory
branch
September 17, 2026 06:49
brcampidelli
added a commit
that referenced
this pull request
Sep 17, 2026
…ackend refused every recall off the boot thread (#510) Found live minutes after #505 made sqlite the default: a guest's message on a shared conversation ran its turn on the guest listener's thread, recall reached the manager the app built at boot, and SQLite refused with `ProgrammingError: SQLite objects created in a thread can only be used in that same thread`. The Discord bot and the desktop's messaging adapters recall from their own threads through the same manager — one message away from the same error on the surfaces the VPS lives on. The connection is held with check_same_thread=False behind a lock, every statement's rows read in full before the lock is released. Reproduced by a test that uses the store from a second thread and from four at once; it raises the exact error against the previous code. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
CHIMERA_MEMORY_BACKENDdefaults tosqlite, and an existingmemory.jsonis imported once into the newmemory.dbso nobody loses a memory. The second half of item 5 of the list audited on 2026-09-16 — the audit note said measure JSON × SQLite recall before flipping the default, and this PR is the measurement first and the flip second.The measurement (
bench/memory_recall, registered before it ran, US$ 0)4,034 one-sentence facts (this repository's test names — one project's vocabulary, heavily shared words), 100 targets × 4 query styles (
sentencecontrol,needle,noisy,partial) × 3 seeds × 3 store sizes, both arms behind the productionMemoryManager.search,k = 5, paired per query.All three registered predictions held: the control is 1.000 on both; |Δ recall@5| ≤ 0.01 on every cell (registered ≤ 0.03) — they rank the same quantity, an IDF sum, and disagree on ties; the JSON median is linear in N because the path re-tokenizes every stored fact and recomputes IDF per query, and recall runs on every turn. The registered decision rule — (a) sqlite recall@5 ≥ json − 0.02 everywhere, (b) sqlite latency ≤ json at N = 1,000, (c) the flip ships with an import — held on (a) and (b), so (c) is in this PR.
RESULTS.mdhas the full table and what the bench cannot show.Why a module and not a one-line default
There was no JSON→SQLite import in the product. The one-line flip would have left every existing
memory.jsonunread — a memory that silently vanishes, the family of defect this project keeps a file about.chimera/memory/backend.py::open_memory_store:json/sqlite, case-insensitive; a typo is the default and logged). The default issqlitewhere this Python's SQLite has FTS5 andjsonwhere it does not: the SQLite store'sLIKEfallback has no ranking, and a default must not trade ranked recall for unranked. Asking forsqliteby name on such a build gets the documented degradation.memory.dbthat does not exist yet, beside amemory.jsonthat does: every fact copied with id, kind, key, source, metadata, provenance, project and age intact (a tainted fact stays tainted, an old fact stays ageless), written to a sibling.importingfile and renamed into place so a crash mid-import leaves no half store the next boot would mistake for a finished one. The JSON file is not touched — a copy, not a move.memory.jsonthat exists but did not load (MemoryStore.stale, a format from another version) stays the store in use; an empty database beside it would turn "these facts did not load" into "these facts are gone".Both files existing means the owner switched before the default did: the database wins, the JSON is left alone.
build_memory_manager, themigratecommand,memory_key(the boot-vs-live store check) and the config/doctor readers all go through the resolver, so the Settings and Doctor screens name the store the turns actually read.Found on the way
The SQLite store dropped
created_at.MemoryItemhas carried the age sincetest_a_memory_had_no_age.py, the JSON store round-trips it, andSqliteMemoryStorehad no column — a fact written through SQLite came back ageless, on the field whose docstring says an old fact must never be given a plausible age. Added with a rebuild migration in the same shape as_migrate_project; old rows readNone; the round-trip is pinned on the SQLite side now.Tests
tests/test_the_memory_default_is_sqlite_and_nobody_loses_a_memory.py(10, skipped where FTS5 is absent): default resolves tosqlitewith FTS5 andjsonwithout; explicit choice honoured, through kwargs and the environment; typo is the default; an existing JSON store is imported once with every field equal (==on the pydantic items), the JSON bytes unchanged, no.importingleftovers, a second open does not import again and a fact added after stays; a fresh install gets an empty database; both files → the database wins and nothing is re-imported; an unreadable JSON store is not shadowed (nomemory.dbcreated); a crash at the thirdaddleaves nomemory.dband no temp file, and the next boot imports all four;created_atround-trips through SQLite; a database built by hand with the pre-age schema migrates and reads old rows as ageless while keeping provenance and project;memory_keyandread_configreport the resolved store.Sabotage:
_importreplaced byimported = 0→ two tests fail (the import test and the crash test); restored. Full gate on a clean copy in WSL: ruff clean, mypy clean (359 files), 6526 passed, 18 skipped, 10 xfailed in 182 s.Operational note
On the VPS the next deploy will import
/opt/data/…/memory.jsonintomemory.dbon first boot (logged:memory: imported N fact(s)), unless its.envsetsCHIMERA_MEMORY_BACKEND=jsonexplicitly, in which case nothing changes there. The JSON file stays where it is either way.🤖 Generated with Claude Code