fix(repair): preserve embeddings on rebuild + cap divergence-floor - #1367
fix(repair): preserve embeddings on rebuild + cap divergence-floor#1367zhapostolski wants to merge 46 commits into
Conversation
release: v3.3.3 — sync develop → main for tag cut
Bumps every version source from 3.3.3 to 3.3.4: - pyproject.toml - mempalace/version.py (canonical) - .claude-plugin/plugin.json - .claude-plugin/marketplace.json - .codex-plugin/plugin.json - README.md badge Dates the CHANGELOG section and adds entries for the bug fixes that landed this cycle (MemPalace#1135, MemPalace#1191, MemPalace#1230, MemPalace#1231) plus expands the MemPalace#1194 entry to credit the lookup-side recovery path from MemPalace#1197. Pre-tag verification: - 1441 passed, 1 skipped (full suite minus benchmarks, all platforms) - ruff check + format clean - 44/44 in test_version_consistency + test_readme_claims (6-file sync) - JPH invariant: pyproject.toml + .claude-plugin/plugin.json both reference mempalace-mcp - Wheel build + fresh-venv install: mempalace --version reports 3.3.4, mempalace-mcp --help works (catches the v3.3.2-class regression)
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/01a1089d-da46-4dc8-85e8-d7e50763dd58 Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
The fix landed this cycle and is documented under 3.3.4. The 3.3.0 Bug Fixes block is shipped history and shouldn't grow new entries retroactively.
…entry The PR documenting the fix is MemPalace#1232; referencing it from inside its own changelog entry is circular.
…MemPalace#1288 Three fixes landed on develop after the initial release-prep cut and were brought in via the develop merge. Document them in the 3.3.4 Bug Fixes section so the release notes reflect what users will actually receive. - MemPalace#1287 - HNSW divergence floor scales with hnsw:sync_threshold (resolves a silent-fallback regression introduced by the interaction between MemPalace#1191 and MemPalace#1227 in this release) - MemPalace#1262 - ChromaBackend get_or_create_collection split, fixing the stop-hook SIGSEGV class on legacy palaces with mismatched stored metadata (MemPalace#1089) - MemPalace#1288 / MemPalace#1254 - repair --mode max-seq-id heuristic now decodes BLOB-typed embeddings.seq_id, restoring the un-poison path added in MemPalace#1135 for palaces where chromadb 1.5.x writes seq_ids natively
The release was originally cut on 2026-04-27 but did not tag that day. Three additional bug fixes have been folded in since then (MemPalace#1262, MemPalace#1287, MemPalace#1288) and the actual tag will happen on 2026-04-30. Update the header date to match.
…MCP server companion The same try/except split that MemPalace#1262 applied at the backend layer (ChromaBackend.get_collection) was needed at the parallel call site in mcp_server._get_collection(create=True), which carries the same metadata payload directly to chromadb's Python client. Both reopen paths in mempalace now bypass get_or_create_collection on existing collections, closing the SIGSEGV class for both tool_add_drawer and tool_diary_write (the Stop hook's path).
…prep chore(release): v3.3.4
A triple with valid_to < valid_from satisfies neither of the temporal
filter clauses in query_entity():
valid_from <= as_of AND valid_to >= as_of
so the triple is invisible to every query — silently corrupt. Reject
at write time with a clear error instead of letting bad data pile up
in the SQLite store.
The guard only fires when both bounds are present; open intervals
(only valid_from or only valid_to) are still accepted, and same-day
intervals (valid_from == valid_to, point-in-time facts) are explicitly
allowed.
…rash EntityRegistry.save() called Path.write_text() directly, which truncates the target file and then writes — so a crash mid-write (power loss, OOM, filesystem-full mid-flush) leaves an empty or half-written entity_registry.json. The whole people/projects map is lost; the system falls back to an empty registry on next load. Switch to the standard atomic-write pattern: serialize to a sibling .tmp file in the same directory (so os.replace stays on one filesystem), fsync, chmod 0o600, then os.replace over the target. The replace is atomic on POSIX and Windows, so any crash leaves the previous registry intact instead of a truncated file. Tests cover: no leftover .tmp on success, and previous content preserved when os.replace itself raises mid-save.
Without this, on ext4 (and similar) filesystems the rename ack does not guarantee durability across power loss — a crash can revert to a state where the temp file is present and the target is at the old version. Suggested by @jphein on MemPalace#1215.
…nflation Repeated upserts to the mempalace_compressed collection across runs cause the HNSW link_lists.bin sparse file to grow without GC, eventually filling the disk (observed: 1.7 TB physical, 17 TB logical, on macOS ARM with chromadb 1.5.8). Drop and recreate the collection at the start of each compress run so the HNSW index is rebuilt from scratch each time. Re-vectorizing ~10K embeddings costs a few minutes on the local ONNX backend; far cheaper than risking TBs of disk. The miner code already does the equivalent (delete-by-source_file before re-insert, see miner.py:718) for the same hnswlib behavior. This brings the compress path in line. Related: MemPalace#1092
… boundary Add validate_iso_date() to config.py and apply it at the MCP boundary in tool_kg_query (as_of), tool_kg_add (valid_from), and tool_kg_invalidate (ended). Previously, non-ISO date strings like 'March 2026' or 'Jan 2025' were forwarded to SQLite without validation, silently producing empty result sets instead of matching facts. Now the MCP tools reject malformed dates early with a clear error message. Fixes MemPalace#1164
The validate_iso_date function uses PEP 604 union syntax (str | None) which requires Python 3.10+ at runtime. Adding future annotations import makes it work on the project's minimum supported Python 3.9. Addresses Qodo-Free-For-OSS review comment.
Replace with in validate_iso_date() to avoid potential SyntaxError on Python 3.9, even though is present. Some type-checking tools and runtime introspection may still evaluate annotations. Also add 11 new edge-case tests: - Leap year / non-leap year Feb 29 - Rejected formats: slash, dot, datetime, month 0, day 0 - Non-string input handling - Param name in error messages Ref: Qodo review on MemPalace#1283
MemPalace#976 protects `mempalace mine`, but MCP/direct backend writers still call ChromaCollection.add/upsert/update/delete without the palace lock. This moves the lock boundary to the Chroma backend seam so all Chroma writes share the same palace-level serialization, with a re-entrant guard for miner paths that already hold the lock. mine_palace_lock(palace_path) gains a per-thread re-entrant guard (threading.local + pid-tag against fork inheritance) so ChromaCollection write methods can take the lock without self-deadlocking when called from inside miner.mine()'s outer hold. ChromaCollection.__init__ accepts an optional palace_path; when set, add/upsert/update/delete wrap their underlying chromadb call with mine_palace_lock(palace_path). palace_path=None preserves the legacy no-lock behaviour for direct callers and tests. ChromaBackend's get_collection/create_collection pass palace_path through; mcp_server._get_collection forwards _config.palace_path so all MCP write tools inherit the wrapping. Tests: 5 new in tests/test_chroma_collection_lock.py covering opt-in, writer-blocks-during-mine, re-entrant-inside-mine, two-process serialization, and a source-level read-path-not-locked pin. Plus 1 new + 1 rewritten in tests/test_palace_locks.py for the re-entrant semantics. 52 passed in 1.01s including the existing test_backends.py regression suite. Refs MemPalace#1161.
Rollback cleanup was instantiating a fresh ChromaBackend, so the live backend that had opened the PersistentClient could keep file handles alive during restore. Close the active backend instance instead so rollback and CLI recovery can release Windows-safe locks before copying the backup back into place.
…lace#1299) `mcp_server._get_collection` bypassed `ChromaBackend.get_collection` and called `client.get_collection` / `client.create_collection` without `embedding_function=`. ChromaDB 1.x does not persist the EF identity with the collection, so the MCP server's reopen silently bound chromadb's built-in `DefaultEmbeddingFunction` while the miner / Stop hook ingest path bound `mempalace.embedding.get_embedding_function()`. On bleeding-edge interpreters (python 3.14 + chromadb 1.5.x on Apple Silicon, per MemPalace#1299) the default EF's lazy ONNX provider selection could SIGSEGV the host process on first `col.add()`, killing the MCP stdio server and leaving every subsequent tool call returning `Connection closed` until Claude Code was relaunched. Reads worked because `col.get(ids=...)` and metadata fetches don't invoke the EF; the auto-ingest path worked because mining routes through the backend abstraction. Diary writes were the consistent failure surface. Resolve the EF up front (matching `ChromaBackend._resolve_embedding_function`) and pass it into both reopen branches. Falls back to the chromadb default only if `mempalace.embedding.get_embedding_function` itself raises. Regression test patches the chromadb client class to capture `embedding_function=` on every `get_collection` / `create_collection` call from `_get_collection(create=True)` and `_get_collection()`, and fails if any call omits it. Follow-up to MemPalace#1262 / MemPalace#1289 (which fixed the metadata-mismatch SIGSEGV path); this addresses the EF-mismatch SIGSEGV path on the same surface.
…emPalace#1314) `tool_kg_add` previously accepted only `valid_from` and `source_closet`, silently dropping `valid_to`, `source_file`, and `source_drawer_id` at the MCP boundary. Backfilling already-ended historical facts therefore collapsed to "still current," and adapter provenance never reached the SQLite layer even though `KnowledgeGraph.add_triple` already supported every column. `tool_kg_invalidate` returned the literal string `"today"` whenever the caller omitted `ended`, hiding the actual stamped date from anyone trying to verify what got persisted. Changes: - Extend `tool_kg_add` signature + MCP input_schema with `valid_to`, `source_file`, `source_drawer_id`; forward all of them to `_kg.add_triple` and to the WAL log. - Resolve `ended` to `date.today().isoformat()` in `tool_kg_invalidate` before logging / returning, so the response always reports the actual date stored in `valid_to`. - Add regression tests for valid_to round-trip, source_file / source_drawer_id provenance, and the resolved-ended-date contract. - Leave TODO(MemPalace#1283) markers so the open ISO-8601 validation PR can drop `validate_iso_date` over `valid_from` / `valid_to` / `ended` cleanly. The underlying `KnowledgeGraph.add_triple` already accepted these kwargs (RFC 002 §5.5) — only the MCP edge needed wiring up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…alace#1243) `tool_diary_write` stored the `agent` metadata verbatim after `sanitize_name` (which preserves case), while `tool_diary_read` filtered by exact match — so writing as "Claude" and reading as "claude" silently returned zero rows. Both endpoints now lowercase `agent_name` immediately after sanitization. The default per-agent wing slug is also stable across casings since it's derived from the same normalized form. Behavior change: entries written prior to this fix under mixed-case agent names will not match the new lowercase filter; documented under v3.3.5 in CHANGELOG with a `mempalace repair` pointer. Adds a regression test (`test_diary_read_case_insensitive_agent`) and updates the existing `test_diary_write_and_read` to assert the new lowercase agent identity. Closes MemPalace#1243 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Case-insensitive directory match (d.lower() == 'subagents') so the filter still kicks in if Claude Code or a plugin ever emits 'Subagents/' on case-preserving filesystems (Windows, macOS APFS). - Drop defensive getattr in cmd_mine: argparse always defines the attribute since --include-subagents is unconditionally registered. Direct args.include_subagents access matches every neighbouring field and fails loudly if the registration is ever removed. - Soften CLI help and docstring: drop the 80:1 ratio (reporter- specific) and the in-code (MemPalace#1217) reference. Add explicit default=False on the argparse flag for symmetry with --extract. - Add 2 negative tests: 'mysubagents'/'subagentsbackup' must still be mined (regression guard against substring-match), and 'Subagents/' must be skipped (case-insensitive coverage).
layers.py (wake-up) always opens knowledge_graph.sqlite3 from the palace directory. The MCP server previously fell back to ~/.mempalace/knowledge_graph.sqlite3 when --palace was not passed, causing a split: kg_add wrote to the root KG while session wake-up read the palace KG. New KG facts never appeared in the next session's context. Fix: always resolve the KG path from _config.palace_path, which already reflects --palace, MEMPALACE_PALACE_PATH, or the default from config.json. The --palace arg still controls the palace path; the conditional around it was only needed for the now-removed special case.
Previously hook_session_start was a no-op stub. Now it:
- Parses cwd + source from the harness input
- Matches cwd basename to a known wing via tool_status()
- Builds additionalContext from MemoryStack.wake_up() + a protocol nudge
telling the LLM to kg_query/search before responding and add_drawer/kg_add
when making decisions
- Returns Claude-Code-compatible {hookSpecificOutput.additionalContext}
shape; harnesses that ignore the key see a no-op
This makes SessionStart actually useful: the LLM gets ~700 tokens of
project context on every session start instead of an empty {}.
Tests: replaced the pass-through assertion with two cases (context
injected when palace resolvable, no-op when MempalaceConfig fails).
All 121 existing tests still pass.
Adds _mirror_local_memory() called from hook_stop on every fire.
Scans default memory locations (~/.claude/projects, ~/.codex/memories,
~/.gemini/memory, ~/.qwen/memory) for *.md files; for each new or
modified file, calls tool_add_drawer with wing derived from the
project slug and room from the filename stem.
Why: even with the SessionStart protocol nudge, models (notably
Claude Code) reflexively write to their per-CLI auto-memory dirs
instead of mempalace MCP tools. This makes mempalace a guaranteed
mirror of those .md files regardless of LLM cooperation.
Behavior:
- Idempotent via {path: mtime} state in ~/.mempalace/hook_state/mirror_state.json
- Skips index files (MEMORY.md, CLAUDE.md, GEMINI.md, QWEN.md, AGENTS.md)
- Skips empty files (still records mtime so we don't keep re-reading)
- Records duplicates (mempalace dedupe) as "seen" to avoid retry loops
- Wing slug parser: -home-...-projects-<wing> → <wing> (Claude convention),
else first directory after the root
- All errors caught — never blocks the harness
- Disable via MEMPAL_MIRROR_DISABLED=1
- Override roots via MEMPAL_MIRROR_ROOTS=path:path:...
Verified: real run on 312 .md files added 248 new drawers (rest were
chromadb dupes or empty). Second run: 0 added, 312 skipped_unchanged.
All 128 tests pass (8 new mirror tests).
The save and precompact wrappers ran via bare 'python3', which resolves
to whatever interpreter is on PATH. From a CLI launched in a clean shell
(or any shell without the right venv activated), python3 is system
python — which lacks chromadb and other mempalace deps. The hooks
silently degraded: hook_session_start would no-op, and the new
_mirror_local_memory() would log
"mirror: cannot import tool_add_drawer (No module named 'chromadb')"
and skip the entire mirror, defeating Option-2 dual-write.
Fix: each wrapper now resolves $MEMPAL_PYTHON, defaulting to
$HOME/.mempalace/venv/bin/python (the canonical install location), and
falls back to plain python3 if that doesn't exist. All three wrappers
(save, precompact, session-start) follow the same pattern so they
behave identically across Claude / Codex / Gemini / Qwen.
Verified end-to-end from a stripped env (env -i PATH=/usr/bin:/bin):
- save_hook returns {} cleanly with no chromadb errors in hook.log
- precompact_hook returns {"decision":"allow"}
- session_start_hook returns Claude-shape additionalContext with
wing-matched wake-up text
- mempal_enrich_knowledge.sh: enriches KG with session insights post-stop - mempal_verify_knowledge.sh: validates KG entries for consistency - mempal_semantic_search.sh: CLI wrapper for semantic search from hooks - mempal_opencode_hook.sh: Stop hook adapter for OpenCode harness - mempal_opencode_simple.sh: lightweight OpenCode hook (no KG enrichment) - mempal_save_hook_throttled.sh: rate-limited save wrapper (Python handles intervals) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ommit The fix(hooks) commit that corrected CLI entry points and harness detection inadvertently replaced normalize.py with a truncated version (207 lines vs the correct 676). Restore from origin/main verbatim. No functional change to normalize.py — only the hooks_cli.py content from that commit was intentional. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…apes Claude Code wraps every slash-command invocation in a five-tag envelope (<local-command-caveat>, <command-name>, <command-message>, <command-args>, <local-command-stdout>). strip_noise() previously covered only command-name and command-message; the other three survived into stored drawers as tag remnants like "<command-args></command-args>" or whole stdout payloads. Bash tool_result content captured by Claude Code preserves ANSI color codes verbatim. These pass through strip_noise() untouched, bloating embeddings (each escape is several BPE tokens) and garbling semantic search. Extend _NOISE_TAGS with the missing three, and add CSI + OSC ANSI strippers applied after tag removal in strip_noise(). Each ANSI pattern is anchored on the literal ESC byte so user prose that mentions e.g. "[1m]" by name stays intact — verbatim-safety preserved per the existing design. Tests: add coverage for each new tag, the full slash-command envelope, the empty <command-args></command-args> shape, ANSI CSI / truecolor / cursor / OSC-title / OSC-hyperlink sequences, ANSI inside a noise tag (no double-strip needed), and two preservation tests for user prose that documents these constructs by name. Closes MemPalace#1333
…hromadb version mismatch When an HNSW segment has a mtime gap > 7200s (2 h), quarantine it regardless of the metadata sniff-test result. ChromaDB flush-lag is measured in seconds; a 2+ hour gap means the segment was written by a different process/version. Observed: chromadb 0.6.x segfaults loading segments whose metadata passes the pickle format check but whose binary layout is incompatible with the current runtime. The original directory is renamed (not deleted), so manual recovery is still possible if the heuristic misfires. Includes cherry-pick of MemPalace#1334: fix(normalize): strip Claude Code local-command tags and ANSI escapes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… timeout to 60s SessionStart now fires a background ChromaDB get_collection so the HNSW index and ONNX embedding model are loaded before the first Stop hook fires. Eliminates the 7 cancelled Stop hooks (30s cold-start timeout) observed in the ananas-os session. Separately: ~/.claude/settings.json hook timeouts bumped 30→60s so a slow cold ChromaDB start still completes rather than being cancelled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- _ingest_transcript: check _mine_already_running() before spawning + register PID in _MINE_PID_FILE so subsequent Stop hook fires see the running mine - hook_stop (silent mode): skip diary save when mine is running; don't advance last_save counter so next Stop hook retries — eliminates 'another mine already running' failures seen in production hook.log - hook_precompact: change default mode from 'proceed' (no-op for claude-code and gemini) to 'block_once', so pre-compaction saves happen for all harnesses without infinite-loop risk (flag keyed by session_id) - _auto_kg_write: new helper that adds session-YYYY-MM-DD → covered_topic → theme relationships after each successful silent diary checkpoint - fix(repair): _detect_poisoned_max_seq_ids now handles BLOB-typed seq_id values from ChromaDB 1.5.x (8-byte big-endian uint64); previously crashed with ValueError: invalid literal for int() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs found when a 94k-drawer palace required emergency manual repair: 1. **_extract_drawers omits embeddings** — the rebuild passed only documents/metadatas to upsert(), forcing ChromaDB to recompute all embeddings from scratch. On CPU this takes hours for large palaces and causes heavy swap pressure. Fix: include \"embeddings\" in the get() call and pass them through to upsert(); fall back to recomputation when embeddings are unavailable (HNSW unreadable). 2. **Divergence floor ignores _HNSW_DIVERGENCE_FRACTION when sync_threshold is large** — PR MemPalace#1191 set hnsw:sync_threshold=50 000, making divergence_floor = 2 × 50 000 = 100 000. For a 94k-drawer palace the 10 %-fraction threshold (9 459) is never reached, so repair-status reported OK even with 47 % of drawers (44 592) missing from the HNSW. Fix: cap divergence_floor at _HNSW_DIVERGENCE_FLOOR_CAP (10 000) so large sync_thresholds cannot make DIVERGED unreachable. Also tighten _HNSW_DIVERGENCE_FRACTION from 0.10 → 0.05. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for catching this — the 94K-with-100K-floor blind spot is real and your reproducer is convincing. The On the threshold change, one concern worth raising about the opposite-direction blind spot the cap re-creates for I hit this from the other side back on a 98K palace right after rebuilding via the same extract-from-sqlite path you're now formalizing. Right after rebuild, chromadb had flushed exactly one window (50K of 98K) to HNSW; the remaining 48K sat in The hard part is that your 47%-actually-missing case and the 48%-queued-for-flush case look identical to a count-only probe. Both have ~half the drawers absent from the HNSW pickle. The signal that distinguishes them lives in Couple of shapes worth considering instead of a hard 10K cap:
Would any of these address the case you hit while leaving the 50K-window steady-state alone? Happy to test against the 98K palace if it helps — both the pre-rescue corrupted snapshot and the post-rebuild healthy one are still on disk, which gives concrete inputs for both sides of the boundary. (As the person who hit the original false-positive that led to #1287, I have a vested interest in the floor not collapsing back to 10K — but the underlying concern about false positives during normal flush cycles is independent of that history.) |
…MemPalace#1367 (embeddings portion only) Cherry-picks the `_extract_drawers` embeddings-preserve fix from upstream PR MemPalace#1367 (@zhapostolski), per the cherry-pick evaluation in #31 and @messelink's review guidance: take the embeddings fix, SKIP the divergence-floor cap (which would re-create _refresh_vector_disabled_flag false-positives on our sync_threshold= 50000 palace per MemPalace#1287's design). What the embeddings-preserve fix does: - _extract_drawers now includes "embeddings" in the col.get() call - Returns a 4-tuple: ids, docs, metas, embeddings (or None when any embedding slot is missing — HNSW unreadable case) - _rebuild_collection_via_temp accepts an all_embeddings= kwarg and passes embeddings through to upsert() when available - rebuild_index threads embeddings from _extract_drawers into _rebuild_collection_via_temp When embeddings ARE available (the common case), rebuild skips ONNX recomputation entirely. On a 150K+ palace this cuts rebuild from hours to minutes. Fallback to recompute when any embedding is None (the bug condition we're trying to repair). Updates two call sites in mempalace/cli.py (the cmd_repair path) and mempalace/repair.py (the rebuild_index path) for the new 4-tuple signature. Adds 3 tests: - test_extract_drawers_preserves_embeddings_when_all_present - test_extract_drawers_falls_back_to_none_when_any_embedding_missing - test_extract_drawers_embeddings_absent_in_batch_signals_recompute Plus updates the existing 5 _extract_drawers tests to unpack the new 4-tuple. All 75 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Hi @zhapostolski — pulled the embeddings-preserve portion of this PR into our fork at jphein/mempalace@aa84fa4 after the cherry-pick evaluation at jphein/mempalace#31. Followed @messelink's split-recommendation: take the Field data on the embeddings fix (151K-drawer palace, hook-driven writes, daemon-served chromadb 1.5.9):
The fix is clean and the metadata-sanitization fork-side addition ( |
Problem
Two bugs discovered when a 94k-drawer palace required emergency manual repair after HNSW drift:
1.
_extract_drawersomits embeddings → hours of unnecessary recomputation_extract_drawersonly fetcheddocumentsandmetadatas, then calledupsert()without embeddings. ChromaDB silently recomputed all embeddings from scratch on every rebuild. For a 94k-drawer palace on CPU this took 4+ hours and caused heavy swap pressure (process peaked at 1.9 GB RSS, 625% CPU, swap nearly exhausted).Fix: Include
"embeddings"in thecol.get()call and pass them toupsert(). Falls back to recomputation when the source HNSW is unreadable (the exact scenario being repaired).2. Divergence-floor exceeds collection size when
hnsw:sync_thresholdis large →repair-statusreports OK on 47% driftPR #1191 set
hnsw:sync_threshold = 50_000. This madedivergence_floor = 2 × 50_000 = 100_000. For a 94k-drawer palace the 10%-fraction cap (9_459) is never reached sincemax(100_000, 9_459) = 100_000, sorepair-statusreported OK even with 44,592 drawers (47%) missing from the HNSW.Fix: Cap
divergence_floorat_HNSW_DIVERGENCE_FLOOR_CAP = 10_000regardless ofsync_threshold. Also tighten_HNSW_DIVERGENCE_FRACTIONfrom0.10→0.05so that a 5%-or-greater drift is always flagged.Behavior after fix
repair-statuswith 47% drift,sync_threshold=50krepair-statuswith normal flush-lag (<10k),sync_threshold=50kTest plan
pytest tests/test_repair.py tests/test_backends.py— no new failures introduced (10 pre-existing failures unrelated to these changes remain)mempalace repairon a small test palace — should print "Re-using stored embeddings" and complete in secondsrepair-statuson a palace where HNSW < SQLite by >10k — should report DIVERGED🤖 Generated with Claude Code