Skip to content

chore: sync upstream/develop through v3.4.0 (2ec4bae) - #348

Merged
jphein merged 115 commits into
mainfrom
sync/upstream-3.4.0
Jun 11, 2026
Merged

chore: sync upstream/develop through v3.4.0 (2ec4bae)#348
jphein merged 115 commits into
mainfrom
sync/upstream-3.4.0

Conversation

@jphein

@jphein jphein commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

What

Merges 113 upstream commits (~40 PRs) — the first sync since v3.3.6 (2026-05-24). Two commits: the merge itself (conflict resolutions in 21 files) and a post-merge reconciliation (test/lint/docs alignment).

In from upstream: the RFC-001 pluggable-backend stack (MemPalace#1679 + MemPalace#1727 metric-aware similarity, MemPalace#1731/MemPalace#1734 embedder identity, MemPalace#1732 advisory-locked maintenance hooks — the contract our conformance PR MemPalace#1769 exercises), wing-normalize (MemPalace#1675/MemPalace#1702, opt-in migrate-wings), delimiter-safe drawer ids (MemPalace#1666, new drawers only), the additive-mining file_already_mined ordering fix, miner robustness (MemPalace#1137/MemPalace#1100/MemPalace#1102/MemPalace#1622/MemPalace#1602), MCP mine + hallways tools (now 37 tools), antigravity harness support, v3.4.0.

Migration safety (vetted against the 409K-drawer production palace): wing normalization only happens via the explicit migrate-wings command; new drawer ids use the v2 delimiter recipe but existing drawers keep v1 ids and file-level dedup (source_file + mtime) keeps re-mines duplicate-free. One operational note: upstream bumped NORMALIZE_VERSION, so the next sweep will re-mine sources to apply noise-stripping — intentional and idempotent, but it will cost familiar some CPU.

Headline resolution decisions:

  • Diary checkpoints restored (JP's call, 2026-06-11): the silent stop-hook path writes a themed, agent_name-filed diary entry (Hook diary checkpoints are never visible to diary_read — wing derivation mismatch MemPalace/mempalace#1693/fix(hooks): file stop-hook diary checkpoints under the harness agent identity (#1693) MemPalace/mempalace#1694) AND ingests the verbatim transcript; the save marker advances only on confirmed diary write — the original silent-mode contract.
  • palace.get_collection unified: upstream resolution (explicit/config/env/markers + mismatch protection) + embedder-identity enforcement + EmbeddingCollection wrap, with the fork's postgres DSN options, KG write-through attach, and collection_name None/DEFAULT shim. RETIRED-marker refusal intact.
  • searcher keeps the fork pipeline (env-tunable hybrid weights, AGE fusion, multi-encoder, tags) with upstream's metric-aware _distance_to_similarity threaded through _hybrid_rank; union strategy now uses the RFC-001 lexical_search capability path (postgres tsvector branch kept for production).
  • Backend precedence aligned to RFC 001: config.json beats MEMPALACE_BACKEND; init() no longer seeds a backend key.

Testing

  • Full suite: 4230 passed, 51 skipped (collected 3908 → 4282; README updated)
  • ruff check + ruff format --check clean; scripts/check-docs.sh ✦ docs clean (changelog/llms-full/python-api regenerated, all tool-count claims at 37)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Antigravity IDE integration with installer and auto-save hooks
    • PostgreSQL, Qdrant, and SQLiteExact storage backends
    • Lexical search (BM25) capability across backends
    • Configurable backup retention pruning
    • Docker containerization with MCP server support
  • Enhancements

    • Auto-detecting Python interpreter from console scripts
    • Backend selection via --backend CLI flag
    • Hallway list/delete tools in MCP server
    • Wing-name normalization migration command
    • Silent diary checkpoints on auto-save
  • Documentation

    • PyPI publishing and release process guide
    • Antigravity integration examples and troubleshooting

lucian-the-traveler and others added 30 commits April 27, 2026 17:53
Two bugs found in production use with a ChromaDB palace of 1200+ drawers
ingested via mixed paths (bulk import + MCP tool calls):

1. searcher.py: filtered search (wing= or room=) crashes with "Error finding
   id" when the HNSW vector index is out of sync with the SQLite metadata
   store. The outer try/except swallowed the error as a search failure.
   Fix: inner try/except catches filter failures, retries unfiltered with
   n_results*15 (capped at 500), and post-filters by wing/room in Python.
   Degrades gracefully instead of returning an error.

2. mcp_server.py: diary_write requires 'entry' but add_drawer uses 'content',
   making it natural to pass content= by analogy. The mismatch returns a
   silent MCP -32000 error with no explanation.
   Fix: accept 'content' as an alias for 'entry' with a clear error message
   if neither is provided.

Both bugs were diagnosed and patched in a live palace. This contributes the
fixes upstream.
)

chromadb <= 1.5.8 writes config_json_str = '{}' (empty JSON) when
creating collections. chromadb 1.5.9 introduced a strict _type check
in the collection config deserialization path -- its absence raises
KeyError: '_type' on palace open. Since the pin allows >=1.5.4,<2,
any upgrade pulls 1.5.9 and breaks every existing palace.

Add a fourth pre-open migration step (_fix_missing_collection_type)
that injects "_type": "CollectionConfigurationInternal" into
collections.config_json_str rows that lack it. Same lifecycle and
marker-file pattern as the existing _fix_blob_seq_ids.

Co-Authored-By: nautis <nautis@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review feedback: `with sqlite3.connect() as conn:` only
manages transactions, it does not close the connection.  An open
connection before PersistentClient instantiation can leave WAL state.
Use explicit `try...finally: conn.close()` matching the read-only
helpers elsewhere in the module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… for ChromaDB 1.5.x compatibility

ChromaDB 1.5.x calls embedding_function.embed_query(input=...) via
keyword argument during collection.query(). EmbeddinggemmaONNX lacked
both embed_query and embed_documents methods, causing:

  TypeError: embed_query() got an unexpected keyword argument 'input'

whenever semantic search was triggered.

This patch adds the two methods required by the ChromaDB EF protocol,
using  (the ChromaDB kwarg name, noqa A002) so that palace
search works correctly with the embeddinggemma model.

Also downloads the companion  ONNX file alongside the main model
to prevent runtime InferenceSession failures.

Fixes silent search failures when  is set to
embeddinggemma.
…ests)

Adds first-class integration with Google's Antigravity IDE
(https://antigravity.google/) as a third sibling to the existing
Claude Code and Codex hook integrations. Strictly additive — no
existing files in main are restructured.

What ships
----------

* `.antigravity-plugin/` — verified-minimal plugin package:
  * `plugin.json` with `{"name": "mempalace"}` (no fabricated fields)
  * `mcp_config.json` registering the `mempalace-mcp` stdio server
  * `hooks.json.tmpl` templated with `__PLUGIN_DIR__` substitution
  * `skills/mempalace/SKILL.md` (real file — no symlinks)
* `hooks/antigravity/`:
  * `lib/common.sh` — shared bash 3.2.57-compatible helpers with
    sentinel-guarded camelCase JSON parser, antigravity_*-namespaced
    state files, every existing kill switch, `MEMPAL_SAVE_INTERVAL >= 1`
    floor (no /0), and fail-open emitters
  * `mempal_save_hook_antigravity.sh` — Stop event handler:
    increments per-conversation counter, defers when fullyIdle=False
    or terminationReason=error, validates transcriptPath against
    `..` traversal, spawns `mempalace mine --mode convos` in a
    detached subprocess with a per-conversation pending marker,
    ALWAYS emits `{}` (never `{"decision":"continue"}` — that would
    force an infinite agent loop)
  * `mempal_wake_hook_antigravity.sh` — PreInvocation handler gated
    to invocationNum==1 with an atomic mkdir loop guard, runs
    `mempalace wake-up` with a 500ms hard timeout, emits verbatim
    output as `{"injectSteps":[{"ephemeralMessage":"..."}]}` or
    `{}` on any failure
  * `install.sh` — idempotent installer with cmp-gated copies,
    `__PLUGIN_DIR__` substitution, relative path absolutization,
    `--dry-run`, and basename-guarded `--uninstall` (refuses to
    wipe a directory whose basename isn't `mempalace`)
  * `INVESTIGATION.md` — verbatim quotes + URLs + dates from the
    five official Antigravity doc pages, recording every surface
    shipped and every surface deliberately omitted
    (PreCompact equivalent, slash-commands, rules/, plugin
    permissions field — the latter is third-party fabrication)
  * `STDIN_SHAPE.md` — exact stdin/stdout contract per event with
    worked examples
  * `README.md` — local hook docs + troubleshooting
* `examples/antigravity/{hooks.json,mcp_config.json,README.md}` —
  standalone configs for users who don't want the full installer
* `website/guide/antigravity.md` + sidebar entry — VitePress guide
* Updates to `README.md`, `CHANGELOG.md` (Unreleased), `hooks/README.md`

Tests (56 new, all passing)
---------------------------

* `tests/test_antigravity_plugin_manifest.py` (11 tests) — schema
  contract on the in-repo `.antigravity-plugin/` directory, including
  guards against re-introducing the fabricated `permissions` field
  and against any symlink leak.
* `tests/test_antigravity_hooks_shell.py` (31 tests) — invokes the
  bash hooks via subprocess with synthetic camelCase stdin, asserts
  `{}` on every failure path, kill-switch coverage (env vars +
  config.json + palace nuke), divide-by-zero floor, transcript
  traversal rejection, namespacing, wing inference, and the hard
  refusal to ever emit `decision=continue` from the Stop hook.
* `tests/test_antigravity_hooks_install.py` (14 tests) — `--dry-run`
  side-effect-free, real install layout, executable bits preserved,
  byte-identical idempotent re-runs (md5 + filecmp), basename-match
  uninstall safety, refusal when plugin.json is missing or names a
  different plugin, relative path absolutization. Skipped on Windows.

Verification
------------

* `uv run pytest tests/ --ignore=tests/benchmarks -v` → 2314 passed,
  3 skipped (Windows), 1 unrelated warning
* `uv run ruff check .` → all checks passed
* `uv run ruff format --check .` → 139 files already formatted
* `bash -n` clean on common.sh, both hook scripts, install.sh
* Local install at `~/.gemini/config/plugins/mempalace/` verified end-
  to-end: layout correct, paths absolutized in hooks.json, both hooks
  fire with realistic camelCase JSON in <1s, wing inference picks
  `wing_mempalace` from workspacePaths[0], state files all
  `antigravity_*`-namespaced, second `install.sh` run produces
  byte-identical output (md5 snapshots match), uninstall removes
  only the mempalace plugin and leaves all 6 sibling Google plugins
  untouched.

Constraints honoured
--------------------

bash 3.2.57 (no mapfile / readarray / declare -A / `${var^^}`),
verbatim guarantee on all wake injections, hooks <500ms / startup
injection <100ms target (kill-switch path returns in <1.5s in CI),
zero new runtime dependencies, no telemetry, no external API,
strictly additive (existing Claude/Codex hooks unchanged).

Refs: hooks/antigravity/INVESTIGATION.md for the full audit.


Five fixes for issues called out by the gemini-code-assist[bot] review.
Each gets a regression test that locks in the correction.

1. CRITICAL: marker-cleanup watcher used POSIX `wait` on a sibling pid
   (save hook). bash `wait` only works on direct children of the
   calling shell — the `( wait $MINE_PID ... ) &` subshell runs as a
   sibling of MINE_PID, so wait fails immediately and the pending
   marker is deleted within milliseconds, defeating the concurrency
   guard. Replace with `while kill -0 $MINE_PID; do sleep 1; done`,
   which queries pid existence regardless of parent-child relationship.
   Test: test_save_hook_marker_watcher_uses_kill_polling.

2. Bare `mempalace` console-script invocation in the save hook fails
   when the venv's bin/ is not on the hook's PATH (e.g. uv tool
   install in some configurations, manually managed virtualenvs).
   Switch to `"$MEMPAL_PYTHON_BIN" -m mempalace mine ...` so the
   resolved interpreter runs the package directly via
   mempalace/__main__.py. Tests:
   test_save_hook_uses_python_module_invocation,
   test_save_hook_missing_mempalace_python_module_does_not_crash.

3. Same issue in the wake hook's inner Python helper. Switch
   `['mempalace', 'wake-up', ...]` to `[sys.executable, '-m',
   'mempalace', 'wake-up', ...]` — sys.executable is the same
   interpreter that resolved MEMPAL_PYTHON in lib/common.sh.
   Test: test_wake_hook_uses_sys_executable_module_invocation.

4. The Python parser in lib/common.sh wrapped `json.load` in
   `try/except` and silently fell back to `data = {}`. The script
   then printed the `__MEMPAL_PARSE_OK__` sentinel even on parse
   failure, so the bash sentinel-check on the caller side
   (`[ "$_marker" != "__MEMPAL_PARSE_OK__" ]`) never triggered the
   defense-in-depth `input parse failed` branch. Remove the
   try/except so the exception propagates, Python exits non-zero,
   and the sentinel is omitted on bad JSON. The traceback still
   lands in antigravity_last_python_err.log for debugging.
   Test: test_common_sh_parser_omits_sentinel_on_malformed_json.

5. `mempal_save_interval()` failed to strip leading zeros from
   MEMPAL_SAVE_INTERVAL. Values like "08" or "09" then crashed the
   modulo step `$((COUNT % INTERVAL))` because bash arithmetic
   parses tokens starting with `0` as octal, and 8/9 are not valid
   octal digits ("value too great for base"). Strip leading zeros
   while preserving the literal "0" (which is then floored to 15).
   Test: test_save_hook_handles_leading_zero_save_interval (4 cases).

Plus one cosmetic fix in install.sh: removed a no-op `(cd "$OLDPWD"
2>/dev/null || cd .) >/dev/null 2>&1` line in mempal_absolutize().
The subshell cd doesn't affect the parent shell, and the installer
never cd's in the main shell anyway, so $PWD is already correct.

Verification:
* 9 new regression tests, all 65 antigravity tests pass
* full repo: 2323 passed (was 2314), 3 skipped, 1 unrelated warning
* ruff check + ruff format --check both clean across 139 files
* bash -n clean on all four shell files
* clean reinstall to ~/.gemini/config/plugins/mempalace/ succeeds
* idempotent re-run produces zero file writes (cmp-gated)
* both hooks return {} exit 0 with synthetic camelCase stdin

Co-authored-by: Cursor <cursoragent@cursor.com>
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.14 to 0.15.15.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](astral-sh/ruff@0.15.14...0.15.15)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
…has multiple parent_drawer_id mining passes

Under the additive-mining model (drawer history preserved across re-mines),
a single source_file can have multiple parent_drawer_id groups in the
palace, one per mining pass. Each group carries its own stored
source_mtime and normalize_version.

`file_already_mined` previously used `collection.get(where={"source_file": X},
limit=1)` in the `extract_mode is None` branch and only checked the single
returned row's metadata. ChromaDB's `get(..., limit=1)` has no ordering
guarantee across multiple matching rows, so the returned row was effectively
arbitrary. When ChromaDB happened to return a stale group (older mining
pass with a different stored mtime), the function returned False, the
additive miner concluded the file had changed, and wrote yet another
duplicate group of drawers for a file that had not actually changed since
the last successful mine.

Steady-state failure: for any source_file that has ever been edited (so
that the palace contains groups with different stored mtimes), each re-mine
has a probability of spuriously concluding "file changed" and writing
another duplicate group. Each spurious group compounds the problem because
its stored mtime can also trigger the next spurious re-mine. Storage grows
without bound; search results, hallway counts, and entity-frequency stats
become inflated proportionally.

The fix mirrors the paginated-iteration pattern already used in the
`extract_mode is not None` branch — iterate every drawer for the
source_file in 1000-row pages, short-circuit on the first matching group.
A correct group is one that passes the existing checks: normalize_version
not stale; if check_mtime, stored source_mtime within 0.001 seconds of the
current file mtime. The two branches (extract_mode is None vs set) collapse
into one loop that skips the extract_mode check when no extract_mode was
specified.

Trade-off: average-case cost rises from O(1) limit=1 query to O(N/1000)
paginated scan. For typical sources (1-3 groups) the cost is unchanged
because the loop short-circuits on the first matching group within the
first page. For pathological sources with thousands of groups, the cost
is O(number-of-pages-until-match) — still bounded, no longer flaky.

RED test pins the failure space deterministically

`test_file_already_mined_handles_multiple_groups_under_one_source_file`
uses a MockCollection that simulates two parent_drawer_id groups under one
source_file: a STALE group (older mtime) and a CURRENT group (matching
mtime). The mock returns the STALE group when called with limit=1
(worst-case ChromaDB ordering) and returns BOTH groups when called with
limit=1000 (what a correctly-iterating implementation must do). Test asserts
file_already_mined returns True even when limit=1 picks the stale group.

  - Against pre-fix code: test FAILS (function returns False because
    limit=1 picks stale group, mtime mismatch returns False)
  - Against post-fix code: test PASSES (iteration finds the current group,
    short-circuits to True)

Verified RED-then-GREEN locally. Existing 4 file_already_mined tests in
tests/test_miner.py continue to pass:
  - test_file_already_mined_check_mtime
  - test_file_already_mined_scopes_convo_extract_mode
  - test_file_already_mined_extract_mode_paginates_large_sources
  - test_file_already_mined_returns_false_for_stale_normalize_version

Verification

  - macOS Python 3.12 (local) full pytest  : 2268 passed, 0 failed
  - Linux Python 3.9.25  (OrbStack)        : 2260 passed, 0 failed
  - Linux Python 3.11.15 (OrbStack)        : 2261 passed, 0 failed
  - Linux Python 3.13.13 (OrbStack)        : 2261 passed, 0 failed
  - ruff check + ruff format --check       : all clean

Provenance

Surfaced during the per-query audit on the PR MemPalace#1628 amendment cycle (the
search for every bare `where={"source_file": ...}` query in the repo).
One of six sites identified. The other five are legitimately file-global
in intent (closet purges, full-rebuild deletes, paginated mode-filtered
scans). This site is the one whose failure mode mirrors the cross-group
stitching pattern PR MemPalace#1628 fixed at the searcher layer.
…ed-multi-mtime-groups

fix(palace): file_already_mined iterates all groups when source_file has multiple parent_drawer_id mining passes
…0.15.15

build(deps-dev): bump ruff from 0.15.14 to 0.15.15
…tate-file GC

Addresses igorls' review on PR MemPalace#1633 (antigravity branch only):

- Atomic counter write: add mempal_write_counter_atomic (same-dir temp +
  mv -f rename) and use it in the save hook, replacing the truncate-then
  -write printf that the comment falsely called "atomic". Concurrent
  readers now always see a complete value.
- Background the expensive probe: `mempalace --version` pays the full
  chromadb/onnx cold-start import (the mine subparser imports
  mempalace.miner before argparse handles --version), so running it in
  the foreground blew the <500ms save budget. Probe + mine + pending
  -marker cleanup now run in one detached subshell; the foreground
  returns immediately. This also retires the kill -0 watcher (the prior
  gemini fix) since cleanup is now sequential within the mine's own
  shell, removing the sibling-PID hazard entirely.
- State-file GC: add mempal_state_ttl_days (default 30, env
  MEMPAL_STATE_TTL_DAYS) and mempal_gc_stale_state, a daily-throttled
  sweep (antigravity_last_sweep marker) that removes stale
  antigravity_save_count_*, antigravity_pending_*, and
  antigravity_woke_* artifacts. Called after the kill-switch check so a
  disabled hook touches nothing; specific name globs leave shared logs
  untouched.

Tests: atomic-counter behavior + no-temp-leftover, single-subshell
structure (no wait/kill -0/MINE_PID), backgrounded-probe timing (3s
stub returns in <2s), async log polling for the missing-module path,
GC sweep/throttle/TTL-validation, and GC gated by the kill switch.
Also hardens the wake-missing test to pin MEMPAL_PYTHON so a shell
-exported interpreter can't defeat the simulation.

bash 3.2.57 safe, fail-open on every path, {}-only Stop output.

Co-authored-by: Cursor <cursoragent@cursor.com>
…n-str-type

fix(backends): repair missing _type in collection config (MemPalace#1611)
…eights + tokenizer)

The EmbeddinggemmaONNX lazy-load now fetches the ONNX external-weights file
(model.onnx_data) in addition to the model graph and tokenizer, so a single
warm-up issues 3 downloads, not 2. The lazy-load-once invariant is unchanged
(InferenceSession and Tokenizer.from_file are still each built exactly once).
Windows exports of Claude Code JSONL sessions prepend a UTF-8 BOM
(\xef\xbb\xbf). With encoding='utf-8', json.loads() raises JSONDecodeError
on the first line, _try_claude_code_jsonl silently skips every line, and
the file falls through as raw text — losing all structured message content.

utf-8-sig strips the BOM transparently and is backward-compatible with
BOM-free files on all platforms.
…ace#1034)

GBK consoles (Windows PowerShell/CMD default) cannot encode U+2713 (✓),
U+2717 (✗), and U+2014 (—). The same class of UnicodeEncodeError fixed
in miner.py via MemPalace#681 affects closet_llm.py and cli.py.

Replace with ASCII equivalents: [OK], [FAIL], [!], and hyphen.
_chunk_by_exchange stripped every line, joined them with single spaces, and
silently dropped blank lines. That violated the verbatim-always principle
stated in CLAUDE.md and contradicted the function's own docstring, which
claimed 'The full AI response is preserved verbatim.'

Concrete consequences before this change:
- paragraph breaks fused: 'para1\n\npara2' → 'para1 para2'
- list items fused: '1. a\n2. b' → '1. a 2. b'
- code fences destroyed: indented code collapsed to a single line
- search quality degraded because tokenization changed at ingest

Fix is surgical: keep each line as-is, join on newline, trim only
trailing newlines produced by the loop stopping at the next '>' turn.

The fallback path _chunk_by_paragraph has a narrower version of the
same bug (it strips each paragraph); that is out of scope here and left
for a follow-up.
…st (MemPalace#1579)

_HNSW_BLOAT_GUARD set batch_size and sync_threshold to 50,000 to
prevent link_lists.bin sparse-file bloat in pre-1.5.x Python chromadb
(#344).  chromadb >=1.5.4 Rust bindings do not exhibit that bloat.

The 50k guard meant any mine under 50,000 drawers never triggered
chromadb's _persist(), leaving index_metadata.pickle absent and
link_lists.bin empty.  quarantine_stale_hnsw then renamed the segment
on every cold open after a 300s mtime gap, accumulating .drift-*
directories indefinitely.

Lower both thresholds to 2 (empirical Rust-side minimum; 1 is rejected
with InvalidArgumentError) so any mine of 2+ drawers triggers a natural
persist.  Verified: batch_size=2 with 20k records produces
link_lists.bin at 171 KB with zero sparse-file inflation.

Existing palaces retain the old 50k thresholds in their collection
metadata until the user runs repair --mode from-sqlite.

Co-Authored-By: Tim Harmon <tim-harmon@users.noreply.github.com>
… data size

chromadb pre-allocates data_level0.bin at index creation (~168 KB for
384-dim embeddings) regardless of record count, so the previous
data-size-vs-floor heuristic in _segment_appears_healthy could not
distinguish a single-record segment (sub-threshold, never persisted)
from an interrupted persist.

Restructure _segment_appears_healthy: when index_metadata.pickle is
absent, check link_lists.bin instead of data_level0.bin size.  Empty or
absent link_lists + absent metadata = sub-threshold (never persisted).
Non-empty link_lists + absent metadata = interrupted persist.

Co-Authored-By: 0xKingVee9527 <0xWinner98@users.noreply.github.com>
…it reconnect (MemPalace#1573)

The _quarantined_paths gate fired once per palace per process and never
re-armed after external in-place writes (closet_llm, mine, compress)
that drift HNSW segments.  The MCP server path (make_client static) had
zero discard logic -- quarantine never re-armed even on inode change.

Extend the discard guard in _client() from inode_changed-only to
inode_changed or mtime_changed or mtime_appeared.  Add a guarded
discard in mcp_server._get_client() before make_client(), and an
unconditional discard in tool_reconnect().

Remove dead _auto_repair / palace-daemon comment (does not exist in
this codebase) and correct misleading _get_collection retry-path
comments that overclaimed quarantine re-runs.

Co-Authored-By: C-LaForest <C-LaForest@users.noreply.github.com>
Prevent redundant quarantine re-run when a fresh ChromaBackend instance
opens a palace that was already quarantined by another instance in the
same process.  The mtime_appeared transition (cached 0.0 -> real mtime)
now only triggers a discard if the instance previously tracked the path,
distinguishing genuine file appearance from first-access default.

Addresses gemini-code-assist review on PR MemPalace#1602.

Co-Authored-By: C-LaForest <C-LaForest@users.noreply.github.com>
Drawer/closet/triple IDs built by concatenating strings without a
delimiter before hashing can collide: hash((s1 + str(i1))) ==
hash((s2 + str(i2))) whenever s1+str(i1) == s2+str(i2). Concretely,
source_file="foo" + chunk_index=12 produces the same hash input as
source_file="foo1" + chunk_index=2 — both yield "foo12". Under
ChromaDB's primary-key constraint the second write silently
overwrites the first; one chunk's content is lost without surfacing
an error (the surrounding broad `except Exception:` blocks swallow
any backend complaint).

The defect class is "string + string concat fed to a hash without
a delimiter." Per the styleguide's partial-scope-key-migration rule,
every site sharing the pattern is a candidate and must be triaged —
not just the ones the original issue named.

FIX — 6 sites
- mempalace/miner.py:1253        drawer_id, add_drawer() back-compat
- mempalace/miner.py:1386        drawer_id, batched mine loop
- mempalace/miner.py:1416        drawer_ids, closet-build re-derivation
- mempalace/format_miner.py:643  drawer_id, format_miner batched loop
- mempalace/mcp_server.py:1136   drawer_id, tool_add_drawer
- mempalace/knowledge_graph.py:305  triple_id, KG triple insertion

MIGRATED FOR CONSISTENCY — 2 sites
- mempalace/convo_miner.py:87    sentinel_key — was `:`, now `|`
- mempalace/convo_miner.py:422   drawer_key   — was `:`, now `|`

Pre-existing delimiter was `:`. Migrated because (a) source_file can
contain literal `:` on Windows paths (`C:\Users\…`) and URL-like
sources (`https://host:8080`), weakening `:` as a separator; (b) `|`
is reserved in Windows filenames so source_file cannot contain it,
making it strictly safer.

DELIMITER CHOICE
- `|` chosen over `:` (Windows / URL source_file edge cases)
- `|` chosen over `\x00` (NUL breaks log greppability + print()
  debug + downstream string handling for marginal extra safety)
- Matches the established precedent in mempalace/diary_ingest.py
  (lines 52, 76, 91, 98 — all already on `|`)

EXEMPT — audited and correct as-is
  Single-input hashes (nothing to delimit):
    - mempalace/miner.py:1432         closet_id (source_file only)
    - mempalace/format_miner.py:559   sentinel_id (source_file only)
    - mempalace/palace.py:433         lock filename (source_file only)
    - mempalace/palace.py:629         palace_key (lock_key_source only)
    - mempalace/diary_ingest.py:158   content_hash (text only)
    - mempalace/hooks_cli.py:329      pidfile digest (joined cmd only)
    - mempalace/sources/context.py:141  record digest (source_file only)

  Already correctly delimited:
    - mempalace/hallways.py:157     `f"{wing}::{a}::{b}"`  (`::`)
    - mempalace/palace_graph.py:454 `f"{a}↔{b}"`           (`↔`)
    - mempalace/diary_ingest.py:52,76,91,98                (`|` precedent)

  Protected by composition (uniqueness guaranteed by the ID prefix,
  not by the hash slice):
    - mempalace/mcp_server.py:1635  entry_id is
      `diary_{wing}_{strftime('%Y%m%d_%H%M%S%f')}_{sha[:12]}`.
      Microsecond-resolution timestamp prefix supplies uniqueness;
      the trailing hash is a content-discriminator, not the
      write-time uniqueness guarantor.

NEW MODULES
- mempalace/ids.py — single source of truth for ID construction.
  Five helpers (make_drawer_id_from_chunk, make_drawer_id_from_content,
  make_convo_drawer_id, make_convo_sentinel_id, make_triple_id) plus
  an ID_RECIPE = "v2" constant.
- mempalace/collision_scan.py — pre-mining defense. Runs immediately
  before each batched ChromaDB upsert; raises CollisionError naming
  the colliding (source_file, chunk_index) pairs if any proposed
  drawer_id appears more than once with conflicting metadata across
  the union of incoming and existing rows.

DETECTION
ChromaDB's primary-key constraint means past collisions were
silently resolved at upsert time (last-write-wins), erasing
evidence. A post-hoc audit cannot reconstruct what was lost from
palace state alone. Therefore:

- Pre-mining risk scan. Before each batched upsert, compute the
  proposed drawer_ids for the incoming chunk set AND query existing
  drawer_ids from the collection. If any proposed id appears more
  than once in the union (incoming-vs-incoming or incoming-vs-
  existing) with conflicting (source_file, chunk_index), abort the
  mine with an actionable error naming the colliding pairs.
  Collision is caught BEFORE it destroys data, which is the only
  point at which palace state still carries the evidence.

- New metadata key: `"id_recipe": "v2"` on every drawer written
  under the delimited recipe. Audits compare like-for-like;
  drawers without `id_recipe` are treated as v1 legacy (undelimited
  or `:`-delimited), not as collisions.

- Honest disclosure: palaces mined under any pre-v2 mempalace may
  carry silent past collisions whose original content is
  unrecoverable from palace state. Future library tier work will
  give users a per-drawer audit + opt-in archival path.

TESTS
- tests/test_ids.py: 17 unit tests covering all 5 helpers, the
  ID_RECIPE constant, the private `_delimited_sha256` helper, and
  the four defect-class collision shapes (chunk_index boundary,
  content boundary, extract_mode boundary, ISO datetime boundary).
  RED collision tests confirm the v2 recipe breaks the defect class.
- tests/test_collision_scan.py: 10 tests covering clean batches,
  idempotent re-mines, incoming-vs-incoming collisions, incoming-vs-
  existing collisions, error-message quality, empty batches,
  metadata without chunk_index, and ChromaDB backend errors
  propagating cleanly.
- tests/test_convo_miner_unit.py: FakeCol gains a stub `get()` so
  the pre-mining scan can probe an empty in-test collection.

BACKWARDS COMPATIBILITY
- Existing v1 drawers remain queryable; no rewrite of stored IDs.
- New mining passes write v2 drawers alongside v1 via PR MemPalace#1628's
  additive-mining model.
- No user action required; opt-in cleanup ships separately.

VERIFICATION
- macOS Python 3.12 (local): 2304 passed, 3 skipped, 0 failed,
  coverage 85.44% (above 80% threshold).
- Linux Python 3.9/3.11/3.13 (OrbStack with full `pip install -e
  '.[dev]'` flow): see PR body for per-version pass counts.
- `ruff check .` + `ruff format --check .`: clean.
- pylint score: ids.py 10.00/10, collision_scan.py 10.00/10; all
  modified files >= 8.92 (no regression).
- bandit: clean on all new files; the one MEDIUM finding in
  knowledge_graph.py is on lines 385/407 (pre-existing SQL string
  construction in timeline queries), not in code touched by this PR.
- pre_push_check.sh: ALL CHECKS PASSED.

Refs: deferred from PR MemPalace#1572 (Copilot finding #13). Styleguide audit
documented above: 8 fix/migration + 11 enumerated exempt sites.
`uv tool install mempalace` / `pipx install` place the mempalace
console scripts in an isolated environment whose interpreter is not
the system python3. mempal_resolve_python previously resolved
`command -v python3`, landing on a Python that cannot import
mempalace: the `-m mempalace --version` probe failed and mining
silently never fired (hit by a real user on PR MemPalace#1633).

Resolution now derives the interpreter from the mempalace-mcp /
mempalace console-script shebang on PATH (the same script the MCP
server launches) before falling back to python3. It is pure shebang
parsing + stat — no Python subprocess at source time — so the hook
performance budget is preserved. An env-style `#!/usr/bin/env python`
shebang and a non-executable interpreter are both rejected and fall
through. MEMPAL_PYTHON remains the explicit override.

Adds 6 resolver regression tests, documents resolution + MEMPAL_PYTHON
in the guide and hooks README (fixing the stale `command -v mempalace`
note), and a CHANGELOG entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
…hokepoint

MemPalace#1235 sanitised lone UTF-16 surrogates for the MCP write tools, but the bulk
ingest paths (miner, convo_miner, sweeper, diary_ingest) build documents
without routing through sanitize_content() and reach ChromaCollection directly.
A single lone surrogate in document text raises UnicodeEncodeError inside
chromadb and aborts the whole add/upsert batch with a -32000 Internal Error,
silently dropping every other row in the same batch.

Complete the chokepoint: add _sanitize_documents_for_chromadb (mirror of
_sanitize_metadatas_for_chromadb) and apply it in add/upsert/update so the
backend guarantees UTF-8-safe documents regardless of caller. IDs and dedup are
unaffected (IDs are computed upstream); only illegal lone surrogates become
U+FFFD, matching the errors="replace" behaviour used elsewhere.
Per Gemini review on MemPalace#1673: chromadb accepts OneOrMany[Document], so a bare
str document was iterated character-by-character by the list comprehension,
splitting it into per-character documents (the silent corruption this method
exists to prevent). Handle isinstance(str) explicitly; add a regression test.
A path-encoded dirname like `-home-user-proj` produced a leading-underscore
slug (`_home_user_proj`) that sanitize_name — and therefore the MCP write
tools — reject, so the conversation miner filed transcripts into wings the
MCP could never write to. Strip leading/trailing `_` after collapsing
separators so the slug is valid. Adds tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…emPalace#1676)

The write-ahead-log directory was created at module scope in
mempalace/mcp_server.py, so importing the MCP server ran
`_WAL_DIR.mkdir(parents=True, exist_ok=True)` and recreated `~/.mempalace`
even after a user removed it to engage the documented kill-switch
(`hooks_cli._palace_root_exists()`, MemPalace#1305). On every session start this
re-armed the autosave/mining hooks the user had disabled.

Move the WAL directory setup into a lazy `_ensure_wal()` called from the
write path (`_wal_log`). Importing the module no longer touches disk; the
directory is created on the first real write, when the palace is being
written to anyway. The WAL is intentionally not gated on
`_palace_root_exists()` (the ChromaDB/KG layer recreates the palace
regardless, so gating would only drop audit records); runtime kill-switch
enforcement for MCP writes is tracked in MemPalace#504.

Add regression tests: a subprocess import asserts `~/.mempalace` is not
created, and a write test asserts the directory is created lazily with the
expected permissions.

Co-Authored-By: Grace Gettert <9805362+ggettert@users.noreply.github.com>
igorls and others added 21 commits June 8, 2026 09:51
feat(backends): observable maintenance hooks + pgvector indexed-build path (RFC 001)
…FC 001, MemPalace#1730)

Completes the embedder-identity contract for the last backend (qdrant) and
unifies identity persistence across all local-path backends.

Identity is stored in a small per-palace sidecar (mempalace_embedder.json),
NOT in a backend's mismatch marker. The marker's presence signals "palace
initialized" (reads raise CollectionNotInitializedError when the marker exists
but the store doesn't), so recording identity at first empty open must not
create it — a sidecar is unguarded, so a brand-new palace records identity
immediately. This fixes a latent gap (caught in review) where pgvector/qdrant
palaces stayed permanently "unknown" because the marker isn't written until the
first real write.

- New mempalace/backends/_sidecar.py: shared read/write_embedder_sidecar with
  the isinstance robustness the review bots taught us; chroma, pgvector, and
  qdrant all use it (chroma's inline copy is removed, pgvector switches off the
  marker, qdrant adds it).
- QdrantCollection.get/set_embedder_identity delegate to the sidecar, so
  palace.get_collection enforces a model swap on a qdrant palace exactly like
  the other backends — no live server needed for the check.

Tests: sidecar roundtrip + brand-new-palace recording (creates sidecar without
a marker) + enforcement model-swap raise, for qdrant and pgvector, all
server-free; chroma identity tests unchanged. Full suite: 2495 passed, 82.52%.

Closes MemPalace#1730. Refs MemPalace#743, MemPalace#1724.
LLM clients (Claude, Codex) often autocorrect acronyms in tool
args (ps5→PS5). When the only difference is casing, treat it as
a no-op to prevent silent room fragmentation.

Genuinely different room/wing values still apply normally.

Fixes MemPalace#1621
Addresses review: old_meta values could be non-string after
corruption or external writes. str() prevents AttributeError.
Closes MemPalace#1739.

Hallways shipped in 3.3.6 (MemPalace#1558) with Python API entry points
`list_hallways(wing=None)` and `delete_hallway(...)` in
`mempalace/hallways.py`, but neither was registered as an MCP tool
in `mempalace/mcp_server.py`. Mining produces hallways visible in
the mine log ("Hallways: +N within-wing entity link(s)") but
they were not retrievable through MCP.

This change wires both functions into the MCP tool registry
mirroring the existing tunnel-tool pattern:

- `mempalace_list_hallways(wing: str | None = None)` wraps
  `hallways.list_hallways`. Optional `wing` filter goes through
  `_sanitize_optional_name` just like `tool_list_tunnels`, so
  invalid names surface a structured error instead of crashing.
- `mempalace_delete_hallway(hallway_id: str)` wraps
  `hallways.delete_hallway` and returns `{"deleted": bool}` so
  callers can distinguish a successful delete from a no-op.

Tests in `tests/test_mcp_server.py` cover:

- list returns all records without filter
- list filters correctly by wing
- list rejects invalid wing names with a structured error
- delete removes the targeted record and returns `{"deleted": True}`
- delete with an unknown id returns `{"deleted": False}`
- delete with missing/non-string id returns a structured error
- both tools are present in the public `TOOLS` registry

Pure pass-throughs of the existing Python API. No behavior change
in the underlying hallway storage, no schema change to the
`hallways.json` file format.
CI's test_no_undocumented_tools enforces that every tool registered
in the TOOLS dict has a corresponding section in mcp-tools.md.
The two hallway tools from b866f41 were missing — adding them here.

Sections mirror the format of the existing list_tunnels and
delete_tunnel entries directly above.
…-identity

feat(backends): shared embedder-identity sidecar + qdrant identity (RFC 001)
…eletions

fix(backends): keep post-deletion dim-None HNSW segments instead of quarantining (MemPalace#1710)
fix(backups): add max_backups retention to bound backup disk usage
…-skips-already-mined

fix(miner): count only new work toward --limit, not already-mined skips (MemPalace#1535)
feat(mcp): expose list_hallways and delete_hallway tools
…rawer-room-casing

fix(mcp): preserve room/wing casing on case-only update
…ity-support

feat: add Antigravity IDE support (hooks, plugin, skill, docs, tests)
Adds the recall layer to the Antigravity plugin, mirroring the three-
layer wiring from feat/cursor-hooks-support, adapted for Antigravity's
native plugin and hook surfaces.

The wake hook (PreInvocation, invocationNum==1) was already the eager
layer — it injects verbatim palace content via injectSteps[].
ephemeralMessage before the first model call, which is strictly better
than Cursor's static directive. This commit adds the on-demand layers:

- integrations/shared/recall-protocol.md: single canonical source of
  truth for "search before answering", shared across Cursor, Antigravity,
  Claude Code, Codex, OpenClaw. Ported from feat/cursor-hooks-support
  with the wake-up step de-coupled from any IDE-specific hook name.

- .antigravity-plugin/skills/mempalace-recall/SKILL.md: recall-only
  skill with Protocol, Tool selection, Unhappy paths, and Anti-patterns
  sections. Tailored to Antigravity: references injectSteps injection,
  not Cursor's additional_context; links to shared protocol via GitHub
  URL (relative file paths break in the installed copy at
  ~/.gemini/config/plugins/mempalace/).

- .antigravity-plugin/rules/mempalace-recall.md: plain .md recall rule
  (no .mdc extension, no YAML frontmatter — per Antigravity's plugin
  docs which specify rules/<name>.md). Body mirrors Cursor's rule;
  references the shared protocol via absolute GitHub URL.

- hooks/antigravity/install.sh: creates skills/mempalace-recall/ and
  rules/ in the install dir, copies both new files (cmp-gated,
  idempotent). Verified: first run writes both, second run is silent.

- .antigravity-plugin/README.md: updated layout tree + "Three recall
  layers" section documenting wake hook (eager), recall skill
  (on-demand), and optional rule.

- 11 new tests in tests/test_antigravity_plugin_manifest.py covering:
  shared protocol presence, recall skill structure (frontmatter,
  required sections, GitHub URL reference), rule format (plain .md, no
  frontmatter), and installer (mkdir + copy_file lines present).

Co-authored-by: Cursor <cursoragent@cursor.com>
…urce

Steps 2-6, unhappy paths, and anti-patterns now match
integrations/shared/recall-protocol.md verbatim. Step 1 remains
Antigravity-specific (PreInvocation injectSteps injection). Flagged
by gemini-code-assist on PR MemPalace#1771.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ity-support

feat(antigravity): add recall skill, optional rule, shared protocol
Advances the fork's upstream-tracking ancestry from v3.3.6 (6957c7e)
to current develop (2ec4bae): 113 commits / ~40 PRs including the
v3.4.0 release, the RFC-001 pluggable-backend stack (MemPalace#1679 + MemPalace#1727
metric-aware similarity, MemPalace#1731/MemPalace#1734 embedder identity, MemPalace#1732
maintenance hooks), wing-normalize (MemPalace#1675/MemPalace#1702, opt-in migrate-wings),
delimiter-safe drawer ids (MemPalace#1666, new drawers only — verified safe for
the 409K-drawer production palace), additive-mining idempotency fix,
miner robustness (MemPalace#1137/MemPalace#1100/MemPalace#1102/MemPalace#1622/MemPalace#1602), and antigravity
harness support.

Key resolution decisions:
- hooks_cli: diary checkpoints RESTORED per JP (2026-06-11) — the
  silent path writes a themed diary entry (agent_name-filed, MemPalace#1693)
  AND ingests the verbatim transcript; save marker advances only on
  confirmed diary write (original silent-mode contract)
- palace.get_collection: unified — upstream backend resolution
  (explicit/config/env/markers + mismatch protection) + fork's
  postgres DSN options, KG write-through attach, and collection_name
  None/DEFAULT shim; embedder-identity enforcement + EmbeddingCollection
  wrap from upstream
- mcp_server._get_collection: fork postgres branch + upstream generic
  non-chroma branch + fork chroma branch (RETIRED gate intact); 37 tools
- searcher: fork pipeline kept (env-tunable hybrid weights, AGE fusion,
  multi-encoder, BM25 backend dispatch, tags) with upstream's
  metric-aware _distance_to_similarity threaded through _hybrid_rank
- file_already_mined: upstream's additive-mining group scan (fixes
  limit=1 ordering re-mines)
- version 3.4.0; CHANGELOG upstream-first then fork entries; fork
  README kept on main

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ated

- cli: drop duplicated status subparser from the merge union
- config: backend precedence aligned to RFC 001 (config.json beats
  MEMPALACE_BACKEND); init() no longer seeds a backend key
- palace.get_collection: derive backend_name from resolve_backend_name
  (not the instance attr) so fakes/options keep working
- searcher: union merger uses the RFC-001 lexical_search capability
  path (UnsupportedCapabilityError propagates) with the fork's
  postgres tsvector branch kept for production; CLI prints
  metric-labeled similarity; tags threaded through the
  vector-disabled fallback; leftover merge fragment removed
- mcp_server: close-path uses get_backend_for_palace (PalaceRef API);
  handle_request noqa'd for merged fork+upstream dispatch complexity
- tool count 34 -> 37 across all doc claims; README badges/sync line
  to v3.4.0 (2ec4bae), 4282 collected tests; fork-changes.yaml entry
  + FORK_CHANGELOG/llms-full/python-api regenerated; check-docs clean

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@deepsource-io

deepsource-io Bot commented Jun 11, 2026

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in 8e0d896...ee78457 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Secrets Jun 11, 2026 3:21p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request performs a major synchronization with upstream v3.4.0 and introduces significant architectural enhancements, most notably the RFC-001 pluggable-backend stack. It adds robust support for Google's Antigravity IDE, introduces new storage backends (pgvector, Qdrant, sqlite_exact), and improves operational safety through backup retention and embedder identity enforcement. The changes also include a migration utility for wing-name normalization and various robustness fixes for the mining and search pipelines.

Highlights

  • Upstream Sync: Synchronized with upstream/develop through v3.4.0 (113 commits), including the RFC-001 pluggable-backend stack, wing normalization, and miner robustness improvements.
  • Antigravity IDE Integration: Added first-class support for Google's Antigravity IDE via a new plugin package, including an idempotent installer, MCP server registration, and lifecycle hooks for memory injection and background mining.
  • New Storage Backends: Introduced support for pgvector (Postgres) and Qdrant backends, alongside a new sqlite_exact backend for correctness-focused operations.
  • Migration & Safety: Implemented automatic backup retention (pruning) to prevent unbounded disk usage and added a wing-name migration tool to handle legacy naming inconsistencies.
  • Embedder Identity Enforcement: Added RFC-001 embedder identity tracking to prevent silent retrieval degradation when swapping embedding models.
Ignored Files
  • Ignored by pattern: *.lock (1)
    • uv.lock
  • Ignored by pattern: .github/workflows/** (2)
    • .github/workflows/docker-publish.yml
    • .github/workflows/publish.yml
  • Ignored by pattern: FORK_CHANGELOG.md (1)
    • FORK_CHANGELOG.md
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds Antigravity plugin packaging and hook runtime support, expands storage backends with backend-aware routing and identity handling, and updates release, Docker, versioning, and publishing metadata.

Changes

Antigravity IDE support

Layer / File(s) Summary
Plugin files and recall docs
.antigravity-plugin/*, hooks/README.md, hooks/antigravity/*, integrations/shared/recall-protocol.md, examples/antigravity/*, docs/*recall*, docs/integrations/opencode.md
Plugin manifests, recall docs, examples, and integration guidance define the packaged Antigravity surface and its recall protocol.
Installer and hook runtime
hooks/antigravity/install.sh, hooks/antigravity/lib/common.sh, hooks/antigravity/mempal_*
The installer and bash hooks render absolute paths, manage state, gate saves and wake injection, and emit JSON to Antigravity.
Antigravity contract tests
tests/test_antigravity_*, tests/test_hooks_cli.py, tests/conftest.py
Installer, shell, and manifest tests cover layout, hook contracts, parser behavior, state rules, and diary routing.

Storage backends and backend-aware routing

Layer / File(s) Summary
Backend contracts and identity
mempalace/backends/base.py, .../_sidecar.py, mempalace/ids.py, mempalace/config.py, mempalace/palace.py, mempalace/backups.py, mempalace/repair.py, mempalace/migrate.py, mempalace/cli.py
Backend contracts, embedder identity, deterministic IDs, backup pruning, backend selection, and wing migration helpers are added across the core path.
Backend implementations
mempalace/backends/chroma.py, .../embedding_wrapper.py, .../pgvector.py, .../qdrant.py, .../sqlite_exact.py, .../registry.py, mempalace/embedding.py
Chroma, SQLiteExact, PgVector, Qdrant, and the embedding wrapper implement the new storage, lexical search, maintenance, and marker behaviors.
Backend-aware CLI, search, and mining
mempalace/mcp_server.py, mempalace/searcher.py, mempalace/miner.py, mempalace/convo_miner.py, mempalace/format_miner.py, mempalace/hallways.py, mempalace/dedup.py, mempalace/hooks_cli.py, mempalace/layers.py, mempalace/knowledge_graph.py, mempalace/normalize.py, mempalace/collision_scan.py, mempalace/closet_llm.py
CLI dispatch, MCP tools, searching, mining, hallways, dedup, diary writes, and metadata flows are updated to use backend-aware IDs, metrics, and collection access.
Backend and CLI tests
tests/test_backend_conformance.py, tests/test_backends.py, tests/test_embedder_identity.py, tests/test_distance_metric.py, tests/test_maintenance_hooks.py, tests/test_mcp_server.py, tests/test_mcp_mine.py, tests/test_cli.py, tests/test_config.py, tests/test_backups.py, tests/test_collision_scan.py, tests/test_dedup.py, tests/test_embedding_wrapper.py, tests/test_embeddinggemma.py, tests/test_format_miner.py, tests/test_hallways.py, tests/test_hallways_pagination.py, tests/test_convo_miner.py, tests/test_convo_miner_unit.py, tests/test_clean_lone_surrogates.py, tests/test_hooks_cli.py, tests/test_migrate.py, tests/test_migrate_wings.py
Tests cover backend isolation, metric handling, maintenance, search, mining, collision detection, diary routing, and wing migration behavior.

Release, containers, and publication metadata

Layer / File(s) Summary
Version and release metadata
version.py, pyproject.toml, README.md, CHANGELOG.md, FORK_CHANGELOG.md, docs/fork-changes.yaml
Version numbers, tool counts, changelog entries, and fork-tracking metadata are bumped to the new sync point.
Docker images and compose
Dockerfile, Dockerfile.gpu, .dockerignore, docker-entrypoint.sh, docker-compose.yml
CPU, GPU, and compose-based container artifacts define runtime entrypoints, volumes, and build context filtering.
Workflows and release docs
.github/workflows/*, docs/RELEASING.md
Build/publish workflows and release instructions add GHCR, Trusted Publishing, and tag/version validation.

Sequence Diagram(s)

sequenceDiagram
  participant hooks_antigravity_install_sh as hooks/antigravity/install.sh
  participant mempal_save_hook_antigravity_sh as mempal_save_hook_antigravity.sh
  participant mempal_wake_hook_antigravity_sh as mempal_wake_hook_antigravity.sh
  participant common_sh as hooks/antigravity/lib/common.sh

  hooks_antigravity_install_sh->>common_sh: render hooks.json and copy hook scripts
  mempal_save_hook_antigravity_sh->>common_sh: parse stdin and schedule periodic save
  mempal_wake_hook_antigravity_sh->>common_sh: parse stdin and inject wake message
Loading

Changes

🎯 5 (Critical) | ⏱️ ~120+ minutes

Poem

A bunny hopped through hooks at dawn 🐰
New bytes and burrows now are drawn
I thump for backends, docs, and light
And nibble tags until they’re right ✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sync/upstream-3.4.0

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces first-class Google Antigravity IDE support, a pluggable-backend stack (supporting Chroma, pgvector, Qdrant, and SQLite-exact), backup retention pruning, and collision-safe drawer ID generation. The code review identified several critical issues: a bypass of the collision scan on non-Chroma backends due to missing dataclass attribute checks, thread-safety race conditions during connection initialization in both the SQLite-exact and pgvector backends, and a severe bug in the MCP server where globally redirecting standard output to capture miner progress hijacks the JSON-RPC stdio communication channel, causing the server to hang.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +88 to +89
existing_ids: list = result["ids"] if hasattr(result, "__getitem__") else []
existing_metas: list = result["metadatas"] if existing_ids else []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The collection.get call returns a GetResult dataclass on non-Chroma backends (such as sqlite_exact, pgvector, and qdrant), which does not implement __getitem__. As a result, hasattr(result, "__getitem__") evaluates to False, causing existing_ids and existing_metas to be silently set to empty lists. This completely bypasses the collision check against existing drawers for all non-Chroma backends.

To fix this, check for the dataclass attributes (ids and metadatas) first before falling back to dictionary-style lookup.

Suggested change
existing_ids: list = result["ids"] if hasattr(result, "__getitem__") else []
existing_metas: list = result["metadatas"] if existing_ids else []
if hasattr(result, "ids"):
existing_ids = result.ids
existing_metas = result.metadatas
elif hasattr(result, "__getitem__"):
existing_ids = result["ids"]
existing_metas = result["metadatas"]
else:
existing_ids = []
existing_metas = []

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked — GetResult subclasses _DictCompatMixin (backends/base.py:324), which provides __getitem__, so the hasattr guard evaluates True for every backend's result and the scan does run. The guard exists for third-party backends returning bare objects. No change needed.

Comment on lines +839 to +851
with self._clients_lock:
cached = self._clients.get(palace_path)
if cached is not None and not cached.closed:
return cached
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
lock = threading.RLock()
handle = _SQLiteExactHandle(conn, lock)
with handle.lock:
self._init_schema(conn)
with self._clients_lock:
self._clients[palace_path] = handle
return handle

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In _connect, the connection creation and schema initialization (_init_schema) are performed outside of the self._clients_lock block. If multiple threads attempt to connect to the same palace_path concurrently, they can race to initialize the schema on the same SQLite database file, leading to transient database is locked errors or duplicate connection handles overwriting each other in self._clients.

To ensure thread safety and prevent resource leaks, wrap the entire connection and schema initialization block inside the self._clients_lock context manager.

Suggested change
with self._clients_lock:
cached = self._clients.get(palace_path)
if cached is not None and not cached.closed:
return cached
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
lock = threading.RLock()
handle = _SQLiteExactHandle(conn, lock)
with handle.lock:
self._init_schema(conn)
with self._clients_lock:
self._clients[palace_path] = handle
return handle
with self._clients_lock:
cached = self._clients.get(palace_path)
if cached is not None and not cached.closed:
return cached
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
lock = threading.RLock()
handle = _SQLiteExactHandle(conn, lock)
with handle.lock:
self._init_schema(conn)
self._clients[palace_path] = handle
return handle

@jphein jphein Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real, but it's upstream code byte-identical to upstream/develop — this sync deliberately doesn't diverge from upstream inside the new backends. Filed upstream as MemPalace#1774 (concurrent first-open can double-create connections; the loser handle leaks).

Comment on lines +493 to +494
rows = None
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In _execute, self._connect() is called outside of the self._lock block. Since _connect modifies and accesses self._conn without synchronization, concurrent queries from multiple threads can cause a race condition where multiple connections are opened concurrently, leading to connection leaks or desynchronization of the active connection handle.

To ensure thread safety, move the self._connect() call inside the with self._lock: block.

        with self._lock:
            conn = self._connect()

@jphein jphein Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed real (shared-instance threading: _connect mutates self._conn outside self._lock), but byte-identical to upstream/develop — filed upstream as MemPalace#1775 rather than diverging in the sync. Our production shape (one connection per process via the daemon) isn't exposed, and the live conformance run used one backend per thread.

Comment thread mempalace/mcp_server.py
Comment on lines +2276 to +2284
try:
with tempfile.TemporaryFile() as tmp:
os.dup2(tmp.fileno(), 1)
try:
with contextlib.redirect_stdout(buf):
result = fn()
finally:
sys.stdout.flush()
os.dup2(saved_fd, 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The _capture_fd_stdout helper redirects file descriptor 1 (standard output) globally for the entire process using os.dup2(tmp.fileno(), 1).

Since the MCP server communicates with the client via JSON-RPC over stdio, redirecting fd 1 globally will completely hijack and block the communication channel of the entire server during the mine operation. Any concurrent JSON-RPC responses or background notifications written to fd 1 by other threads will be redirected to the temporary file instead of being sent to the client, causing the client to hang or disconnect.

Instead of redirecting fd 1 globally, consider suppressing C-level output by configuring the log levels of the underlying libraries (such as onnxruntime and chromadb) directly, or run the mining operation in a separate subprocess where redirecting standard output is safe and isolated.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is byte-identical to upstream's (_capture_fd_stdout exists in upstream/develop with the same body). The window is bounded: the stdio server dispatches requests serially, so no JSON-RPC write can interleave with the captured section, and fd 1 is restored in a finally. Agreed it's fragile if dispatch ever goes concurrent — that's an upstream design conversation, not a sync deviation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
mempalace/miner.py (1)

1356-1373: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep add_drawers() on the same ID recipe as the other insert paths.

add_drawers() still hardcodes the legacy drawer_{wing}_{room}_{sha256(...)} format, while add_drawer() and process_file() now use make_drawer_id_from_chunk(). Any caller of this batch API will mint different IDs for the same chunk, which breaks idempotent re-mines and makes id_recipe inaccurate for rows created through this path.

Suggested fix
     for chunk in chunks:
-        drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
+        drawer_id = make_drawer_id_from_chunk(
+            wing, room, source_file, chunk["chunk_index"]
+        )
         metadata = _build_drawer_metadata(
             wing,
             room,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/miner.py` around lines 1356 - 1373, The batch path in add_drawers()
generates drawer IDs using a legacy hardcoded pattern, causing inconsistent IDs
vs add_drawer()/process_file(); replace the manual id construction in the loop
with a call to the shared helper make_drawer_id_from_chunk(...) (pass the same
arguments used elsewhere: wing, room, source_file, chunk["chunk_index"] and any
other required params) so batch_ids uses the same id recipe and preserves
idempotency and accurate id_recipe. Ensure you remove the hashlib-based string
assembly and use the helper's return value for batch_ids.
mempalace/mcp_server.py (1)

1620-1630: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate the requested search strategy on the retry path.

Line 1620 retries search_memories(...) after a transient index error, but it drops candidate_strategy and fusion_mode. A request that started as vector or rrf can therefore recover into the default ranking mode and return a different result set than the caller asked for.

💡 Suggested fix
         result = search_memories(
             sanitized["clean_query"],
             palace_path=_config.palace_path,
             wing=wing,
             room=room,
             tags=normalised_tags or None,
             n_results=limit,
             max_distance=dist,
             vector_disabled=_vector_disabled,
             collection_name=_config.collection_name,
+            candidate_strategy=candidate_strategy,
+            fusion_mode=fusion_mode,
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/mcp_server.py` around lines 1620 - 1630, The retry call to
search_memories is missing propagation of the requested ranking parameters:
ensure the second call includes the candidate_strategy and fusion_mode arguments
(e.g. search_memories(..., candidate_strategy=candidate_strategy,
fusion_mode=fusion_mode, ...)) so a request that started with vector or rrf
stays consistent on retry; verify candidate_strategy and fusion_mode are in
scope and passed through unchanged from the original invocation/context.
mempalace/normalize.py (1)

546-552: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle all non-dict tool_use.input values, not just lists.

Line 547 only normalizes list inputs. If input is any other non-dict (e.g., string/number), Line 551+ calls inp.get(...) and crashes.

Proposed fix
     name = block.get("name", "Unknown")
     inp = block.get("input", {})
-    if isinstance(inp, list):
+    if not isinstance(inp, dict):
         inp = {}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/normalize.py` around lines 546 - 552, The code at
block.get("input", {}) only guards against lists but later calls inp.get(...)
(e.g., in the Bash branch: cmd = inp.get("command", "")), which will crash for
non-dict inputs; update the normalization so that after inp = block.get("input",
{}) you replace inp with {} unless isinstance(inp, dict) (not just list),
ensuring subsequent uses like inp.get(...) and other branches safely assume a
dict.
🟠 Major comments (21)
tests/test_collision_scan.py-124-136 (1)

124-136: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t lock in “same metadata means safe” for duplicate IDs.

This test assumes duplicate drawer_ids are harmless when (source_file, chunk_index) matches, but the scan never compares the document payload. A batch can still contain the same ID and metadata with different content, and that will collapse to one row at write time. This should either assert document equality as part of the contract or treat same-ID duplicates as collisions regardless.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_collision_scan.py` around lines 124 - 136, The test currently
treats duplicate drawer_id entries with identical (source_file, chunk_index) as
safe without verifying document payload; update the test for
test_assert_no_collisions_passes_on_incoming_duplicate_with_same_metadata to
either (a) include the document payload in each proposed tuple and assert that
assert_no_collisions verifies payload equality before allowing duplicates, or
(b) change the expectation to treat any duplicate drawer_id in the incoming
batch as a collision regardless of metadata by asserting that
assert_no_collisions raises/returns a collision for duplicate IDs; reference
assert_no_collisions, the proposed list entries, and _MockCollection to
implement the chosen fix.
mempalace/migrate.py-549-583 (1)

549-583: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope topics_by_wing rewrites to the palace being migrated.

migrate_wing_names() is palace-scoped, but topic_renames is built from the global known-entities registry and then applied wholesale. On installs with multiple palaces, migrating one palace can rename registry keys for another before that palace’s drawers/closets are migrated, leaving the registry and stored metadata out of sync. Filter the registry rewrite to only the old→new wing pairs planned from this run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/migrate.py` around lines 549 - 583, migrate_wing_names() currently
applies topic renames globally; restrict those to only the wing renames planned
for this run by filtering topic_renames before applying them. After computing
topic_renames = _plan_topics_by_wing_renames(), build the set of planned pairs
from d_updates and c_updates (e.g., set(d_updates.items()) ∪
set(c_updates.items())) and replace topic_renames with only entries whose
(old,new) pair is in that set, then call
_apply_topics_by_wing_renames(filtered_topic_renames) instead of applying the
unfiltered topic_renames.
.github/workflows/publish.yml-76-76 (1)

76-76: ⚠️ Potential issue | 🟠 Major

Pin GitHub Actions to immutable commit SHAs (and disable checkout credential persistence)

.github/workflows/publish.yml uses movable refs (actions/checkout@v6 at line 76, actions/setup-python@v6 at line 114, and pypa/gh-action-pypi-publish@release/v1 at line 124). Pin each uses: entry to a full commit SHA to prevent upstream retags from changing executed code.

Also, actions/checkout defaults persist-credentials: true (not overridden here). Set persist-credentials: false unless the workflow needs authenticated Git operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml at line 76, Replace movable refs with
immutable commit SHAs for all uses entries mentioned (actions/checkout@v6,
actions/setup-python@v6, pypa/gh-action-pypi-publish@release/v1) by updating
each `uses:` to the corresponding full commit SHA for that action, and for the
actions/checkout step (the one currently using actions/checkout@v6) add `with:
persist-credentials: false` to explicitly disable credential persistence unless
the workflow needs authenticated git operations; ensure the changes target the
checkout, setup-python, and pypa publish steps by updating their `uses:` tokens
to SHAs and adding the persist-credentials setting to the checkout step.

Source: Linters/SAST tools

mempalace/backends/qdrant.py-240-245 (1)

240-245: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't silently erase metadata on serialization failure.

If any metadata value is not JSON-serializable, _jsonable_metadata() returns {} and the upsert still succeeds. That drops every metadata field for the point with no signal to the caller, which will break later filters and provenance reads.

Suggested patch
 def _jsonable_metadata(meta: dict | None) -> dict:
-    try:
-        value = json.loads(json.dumps(meta or {}, ensure_ascii=False))
-    except (TypeError, ValueError):
-        value = {}
-    return value if isinstance(value, dict) else {}
+    if meta is None:
+        return {}
+    value = json.loads(json.dumps(meta, ensure_ascii=False))
+    if not isinstance(value, dict):
+        raise ValueError("metadata must serialize to a JSON object")
+    return value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/qdrant.py` around lines 240 - 245, The helper
_jsonable_metadata currently swallows serialization failures and returns an
empty dict; change it to preserve JSON-serializable fields and surface errors:
in _jsonable_metadata first try to json.dumps(meta) as now; if that fails,
iterate meta.items(), attempt to json.dumps each value individually, build a
dict of the successfully-serializable entries, and if any keys were dropped
raise a TypeError (or ValueError) listing the non-serializable keys so the
upsert caller fails rather than silently losing metadata; return the filtered
dict only when no keys were dropped, otherwise raise with a clear message.
mempalace/backends/qdrant.py-1373-1383 (1)

1373-1383: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the palace marker before deleting remote collections.

Every open/read path uses the local marker to catch URL/namespace drift, but delete_collection() rebuilds the target from the current config and deletes it directly. If the operator changed MEMPALACE_QDRANT_URL or the namespace since the palace was created, this path can delete the wrong remote collection.

Suggested patch
     def delete_collection(self, palace_path: str, collection_name: str) -> None:
         palace = PalaceRef(id=palace_path, local_path=palace_path)
         config = _QdrantConfig.from_options()
+        if os.path.isfile(self._marker_path(palace.local_path)):
+            self._validate_marker_target(palace, config)
         remote_collection = self._remote_collection_name(
             palace=palace,
             collection_name=collection_name,
             config=config,
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/qdrant.py` around lines 1373 - 1383, The delete_collection
method currently builds and deletes a remote collection from the live config
without checking the palace's stored marker; to fix, load and validate the
palace marker from the local palace (use PalaceRef/local marker loader) before
computing _remote_collection_name: read the stored marker from
PalaceRef(id=palace_path, local_path=palace_path), compare its Qdrant
URL/namespace (or equivalent marker fields) to the current
_QdrantConfig.from_options() values, and abort (raise/log and return) if they
differ; only call _remote_collection_name, client.collection_exists and
client.delete_collection after the marker matches to prevent deleting
collections from a different remote/namespace.
mempalace/config.py-956-962 (1)

956-962: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize backend aliases before validating/persisting.

backend_override accepts aliases through _normalize_backend_name(), but set_backend() validates the raw lowercased string. Calls like set_backend("chromadb"), set_backend("pg"), or set_backend("postgresql") now fail even though the rest of the config layer accepts them.

Suggested patch
     def set_backend(self, backend: str) -> None:
         """Persist the storage backend choice to ``config.json``."""
-        backend = str(backend).strip().lower()
+        backend = _normalize_backend_name(backend)
         from .backends import get_backend_class
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/config.py` around lines 956 - 962, The set_backend method currently
lowercases the input but doesn't apply existing alias normalization, causing
valid aliases to be rejected; update set_backend to call the project's
_normalize_backend_name (or equivalent) on the incoming backend string before
calling get_backend_class and before writing to self._file_config["backend"] so
validation and persistence use the normalized canonical backend name.
hooks/antigravity/lib/common.sh-240-263 (1)

240-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't strip spaces out of real filesystem paths.

safe() removes spaces before transcriptPath, workspacePaths[0], and artifactDirectoryPath are returned, so a valid path like /Users/alice/My Project/chat.jsonl is rewritten to a different, nonexistent file. That will silently break mining on a common macOS path shape.

Suggested patch
-def safe(s, allowed=r'[^a-zA-Z0-9_/.\-~]'):
+def safe_path(s):
+    return re.sub(r'[^ a-zA-Z0-9_/.\-~]', '', str(s))
@@
-transcript = safe(data.get('transcriptPath', ''))
+transcript = safe_path(data.get('transcriptPath', ''))
@@
-    workspace = safe(wp_arr[0])
+    workspace = safe_path(wp_arr[0])
@@
-artifact = safe(data.get('artifactDirectoryPath', ''))
+artifact = safe_path(data.get('artifactDirectoryPath', ''))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/antigravity/lib/common.sh` around lines 240 - 263, The current safe()
function strips spaces (regex r'[^a-zA-Z0-9_/.\-~]') which mangles legitimate
filesystem paths used when building transcript, workspace and artifact
(variables transcript, workspace, artifact); update the sanitization so
path-preserving characters (including space) are allowed—either relax safe()'s
regex to include space or implement a dedicated safe_path() used for
transcriptPath/workspacePaths[0]/artifactDirectoryPath that only removes truly
dangerous characters while preserving spaces and normal path chars (refer to
safe(), transcript, workspace, artifact in the diff).
mempalace/searcher.py-629-632 (1)

629-632: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

search() still rejects valid non-Chroma palaces before this backend-aware path can run.

The new metric probe is backend-aware, but the CLI still bails out earlier if chroma.sqlite3 is missing. A valid sqlite_exact palace only has sqlite_exact.sqlite3, so mempalace search will incorrectly report “has no chroma.sqlite3 yet” even though the backend can open it. Please move the preflight behind backend detection/opening, or make the filesystem check backend-specific.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/searcher.py` around lines 629 - 632, The CLI currently performs a
hard filesystem preflight that looks specifically for chroma.sqlite3 before
backend detection, causing valid non-Chroma palaces (e.g., sqlite_exact.sqlite3)
to be rejected; update the search() flow to defer that preflight until after
backend detection/opening by calling get_collection or _metric_for_collection
first (using palace_path) and only then perform backend-specific file checks, or
alternatively make the preflight file check conditional on the detected backend
(e.g., if backend=='chroma' check for chroma.sqlite3, if backend==
'sqlite_exact' check for sqlite_exact.sqlite3); modify code in search() to rely
on get_collection/_metric_for_collection to identify the backend and then
validate the corresponding file name instead of always requiring chroma.sqlite3.
mempalace/backends/sqlite_exact.py-839-851 (1)

839-851: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the handle cache atomic.

Two threads opening the same palace_path can both miss _clients, create separate SQLite connections, and then overwrite each other in the cache. That leaks one connection and, more importantly, splits in-process access across different RLock instances, so the serialization guarantee for SQLite writes disappears and database is locked failures become much more likely.

Suggested fix
         with self._clients_lock:
             cached = self._clients.get(palace_path)
             if cached is not None and not cached.closed:
                 return cached
         conn = sqlite3.connect(db_path, check_same_thread=False)
         conn.row_factory = sqlite3.Row
         lock = threading.RLock()
         handle = _SQLiteExactHandle(conn, lock)
         with handle.lock:
             self._init_schema(conn)
         with self._clients_lock:
+            cached = self._clients.get(palace_path)
+            if cached is not None and not cached.closed:
+                conn.close()
+                return cached
             self._clients[palace_path] = handle
         return handle
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/sqlite_exact.py` around lines 839 - 851, Two threads can
race and create separate _SQLiteExactHandle instances for the same palace_path;
fix by using a double-checked insert under _clients_lock: keep the initial quick
check of self._clients under self._clients_lock, if missing create the sqlite3
connection and handle, then re-acquire self._clients_lock and check
self._clients[palace_path] again—if another handle was inserted, close the
newly-created conn and use the cached handle; otherwise store the new handle in
self._clients; then perform schema initialization under handle.lock by calling
_init_schema(conn). This uses the existing symbols _clients, _clients_lock,
_SQLiteExactHandle, _init_schema, palace_path, conn, lock, and handle to ensure
the cache insert is atomic and avoids leaked connections or multiple locks per
DB.
mempalace/backends/sqlite_exact.py-1008-1027 (1)

1008-1027: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

delete_collection() leaves the old embedder identity behind.

set_embedder_identity() persists embedder_model:{collection_name} in meta, but collection deletion only removes documents and collections. Recreating the same collection name will inherit the stale model name, so a “fresh” collection can immediately report the wrong embedder contract.

Suggested fix
             try:
                 handle.conn.execute(
                     "DELETE FROM docs_fts WHERE collection_id = ?",
                     (collection_id,),
                 )
             except sqlite3.OperationalError:
                 pass
+            handle.conn.execute(
+                "DELETE FROM meta WHERE key = ?",
+                (f"embedder_model:{collection_name}",),
+            )
             handle.conn.execute("DELETE FROM collections WHERE id = ?", (collection_id,))
             handle.conn.commit()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/sqlite_exact.py` around lines 1008 - 1027,
delete_collection currently removes documents and the collection row but leaves
the embedder identity stored in the meta table (set by set_embedder_identity as
key "embedder_model:{collection_name}"), causing a recreated collection to
inherit stale embedder info; update delete_collection to also delete the meta
entry for the collection's embedder (delete from meta where key =
f"embedder_model:{collection_name}") before committing, and ensure any other
meta keys tied to the collection (if present) are removed as well to fully clean
up state.
mempalace/searcher.py-1950-1958 (1)

1950-1958: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t pre-rank and slice before fusion_mode runs.

_finalize_candidate_hits() now always does _hybrid_rank(... )[:n_results] before the caller applies _FUSION_RANKERS[fusion_mode]. That means fusion_mode="rrf" never sees the full union/hybrid pool, and BM25/graph candidates beyond the convex top-N are discarded before the later fusion and cross-encoder stages can consider them.

Suggested fix
 def _finalize_candidate_hits(
@@
 ) -> tuple:
     try:
         _apply_candidate_strategy(
@@
         return [], {
             "error": "candidate_strategy='union' requires a backend with lexical_search support",
             "unsupported_capability": "supports_lexical_search",
             "hint": "Use candidate_strategy='vector' or select a backend that supports lexical search.",
         }
-
-    hits = _hybrid_rank(hits, query, metric=_metric_for_collection(drawers_col))[:n_results]
-    for h in hits:
-        h.pop("_sort_key", None)
-        h.pop("_source_file_full", None)
-        h.pop("_chunk_index", None)
     return hits, None

Let the caller apply the requested fusion mode, rerank, and final trimming once.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/searcher.py` around lines 1950 - 1958, _fix in
_finalize_candidate_hits: don't pre-rank and slice the hybrid pool; call
_hybrid_rank(hits, query, metric=_metric_for_collection(drawers_col)) without
[:n_results], then pass the full ranked list into _FUSION_RANKERS[fusion_mode]
(or equivalent fusion step) and only apply the final [:n_results] trim after
fusion/reranking and cross-encoder stages so fusion_mode (e.g., "rrf") sees the
full candidate set; update any logic in _finalize_candidate_hits that assumes
pre-sliced results so callers still get a final trimmed list.
mempalace/backends/chroma.py-1724-1726 (1)

1724-1726: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Let sqlite failures fall back to the client scan instead of returning zero hits.

lexical_search() treats any non-None return from _lexical_search_via_sqlite() as authoritative. Right now sqlite open/read failures return [], so a transient lock or schema issue becomes an empty result set even though the slower self.get(...) fallback right below could still serve results. Reserve [] for “query succeeded with no matches” and return None on infrastructure failure.

Also applies to: 1780-1802, 1898-1900

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/chroma.py` around lines 1724 - 1726, The sqlite-backed
lexical search should signal infrastructure failures with None, not an empty
list, so update _lexical_search_via_sqlite to catch sqlite/open/read errors and
return None on failure while still returning [] for a successful query with zero
matches; then ensure callers in lexical_search (the blocks that currently do:
sqlite_hits = self._lexical_search_via_sqlite(...) if sqlite_hits is not None:
return LexicalResult(hits=sqlite_hits)) rely on that None sentinel—apply the
same change for the other sqlite call sites mentioned (the other lexical_search
code paths at the regions you flagged) so transient sqlite errors fall back to
the slower self.get(...) scan instead of producing empty results.
mempalace/backends/chroma.py-1723-1725 (1)

1723-1725: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Support tag predicates in lexical_search().

_validate_where() accepts $contains_all / $contains_any, but this path never strips and post-filters them the way query() / get() do. As written, a lexical search with tag filters falls through to _compare_metadata() and raises UnsupportedFilterError even though the backend advertises those operators elsewhere.

Also applies to: 1926-1928

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/chroma.py` around lines 1723 - 1725, lexical_search()
currently calls _validate_where() then _lexical_search_via_sqlite() but never
strips or applies tag predicates ($contains_all / $contains_any), causing
_compare_metadata() to raise UnsupportedFilterError; update lexical_search()
(and the same logic at the other occurrence around 1926-1928) to extract and
remove any $contains_all/$contains_any entries from the where dict before
calling _lexical_search_via_sqlite(), then post-filter sqlite_hits using the
same tag-predicate logic used by query()/get() (or delegate to the existing
_compare_metadata() once the tag predicates are stripped), ensuring tag
predicates are honored for lexical searches.
mempalace/backends/base.py-226-236 (1)

226-236: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep dimension mismatches hard-failing even when force_model_swap=True.

force_model_swap currently bypasses both name and width conflicts. That lets a caller re-record a collection built with one vector dimension as another even though the stored vectors are physically incompatible. The force path should only relax model-name mismatches after dimensions already match or are unknown.

Suggested fix
-    if force_model_swap:
-        return "known_mismatch"
-
     if dim_conflict:
         raise DimensionMismatchError(
             f"collection was built with a {stored.dimension}-dim embedder "
             f"({stored.model_name!r}) but the current embedder is "
             f"{current.dimension}-dim ({current.model_name!r}); the stored "
             "vectors are incompatible. Re-embed the palace to switch models."
         )
+    if force_model_swap:
+        return "known_mismatch"
     raise EmbedderIdentityMismatchError(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/base.py` around lines 226 - 236, The current logic lets
force_model_swap bypass dimension conflicts; change the control flow in the code
around force_model_swap and dim_conflict so that DimensionMismatchError is
raised unconditionally when stored.dimension and current.dimension are
incompatible (i.e., keep the check that raises DimensionMismatchError using
stored.dimension/current.dimension first), and only allow force_model_swap to
relax embedder name mismatches (EmbedderIdentityMismatchError) when dimensions
match or stored.dimension is unknown—ensure the DimensionMismatchError is raised
before any early return for force_model_swap and that
EmbedderIdentityMismatchError handling still considers force_model_swap only
after dimensions are validated.
mempalace/backends/chroma.py-1266-1268 (1)

1266-1268: ⚠️ Potential issue | 🟠 Major

_COLLECTION_TYPE_MARKER migration can permanently skip later collections

In mempalace/backends/chroma.py, _fix_missing_collection_type() returns early once the palace-wide marker exists, so it never re-scans collections again:

marker = os.path.join(palace_path, _COLLECTION_TYPE_MARKER)
if os.path.isfile(marker):
    return

The marker is then written after the first run (Path(marker).touch() at ~1303-1305). If any collections are created later by chromadb <= 1.5.8 (which persists collections.config_json_str='{}' without _type), future opens under chromadb 1.5.9+ (which deserializes via CollectionConfigurationInternal.from_json and expects _type) can fail with KeyError: '_type' on those newer rows. This means later collections can be missed indefinitely after the first successful migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/chroma.py` around lines 1266 - 1268,
_fix_missing_collection_type currently returns early when a single palace-wide
marker (marker = os.path.join(palace_path, _COLLECTION_TYPE_MARKER)) exists,
which prevents rescanning collections created after that first run; change the
logic so the function always iterates the collections and perform migration
per-collection, skipping only collections that have a per-collection marker
(e.g. derive a marker name that includes the collection id/name like
f"{_COLLECTION_TYPE_MARKER}-{collection_name}" and check os.path.isfile for
that), and replace the single Path(marker).touch() with writing the
per-collection marker after successfully migrating that collection; keep the
overall palace scan but use per-collection markers to avoid permanently skipping
later collections.
mempalace/cli.py-1483-1487 (1)

1483-1487: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Exit non-zero when backend resolution fails.

This branch prints an error but returns normally, so mempalace sync exits with status 0 even though it could not determine the backend. That will make wrappers/CI treat a failed sync preflight as success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/cli.py` around lines 1483 - 1487, The try/except around
resolve_backend_name(palace_path) should exit with a non-zero status when it
fails: after printing the error to sys.stderr (inside the except for Exception
as exc) call sys.exit(1) (or re-raise SystemExit) instead of returning so the
process exits with failure; update the exception handler in the CLI entrypoint
that calls resolve_backend_name to ensure mempalace sync returns a non-zero exit
code on backend resolution failure.
mempalace/cli.py-6439-6440 (1)

6439-6440: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope the backend override to a single main() invocation.

_apply_backend_arg(args) mutates os.environ, but none of the early-return paths in main() restore the previous values. After one in-process call like main() with --backend pgvector, a later main() call without --backend will still inherit that explicit override and can read/write the wrong storage backend.

Suggested fix
     args = parser.parse_args()
-    _apply_backend_arg(args)
+    _prev_backend = os.environ.get("MEMPALACE_BACKEND")
+    _prev_explicit_backend = os.environ.get(_EXPLICIT_BACKEND_ENV)
+    _apply_backend_arg(args)
+
+    try:
+        # existing post-parse handling / dispatch stays here
+        ...
+        dispatch[args.command](args)
+    finally:
+        if _prev_backend is None:
+            os.environ.pop("MEMPALACE_BACKEND", None)
+        else:
+            os.environ["MEMPALACE_BACKEND"] = _prev_backend
+        if _prev_explicit_backend is None:
+            os.environ.pop(_EXPLICIT_BACKEND_ENV, None)
+        else:
+            os.environ[_EXPLICIT_BACKEND_ENV] = _prev_explicit_backend
-
-    dispatch[args.command](args)

Also applies to: 6453-6550

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/cli.py` around lines 6439 - 6440, _main() calls parser.parse_args()
then _apply_backend_arg(args), which mutates os.environ and is never restored so
subsequent in-process calls inherit the override; change this so the backend
override is scoped to a single main invocation by saving the previous
environment value, applying the override only for the duration of the main
execution, and restoring the original value on all exit paths (including early
returns and exceptions). Wrap the logic that uses the override (the remainder of
main after _apply_backend_arg) in a try/finally or implement a small context
manager around _apply_backend_arg(args) that saves
os.environ.get('MEMPALACE_BACKEND') and restores it in finally so calls to
main() without --backend do not see a prior override.
mempalace/mcp_server.py-2647-2650 (1)

2647-2650: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The case-preservation branch is inverted and reintroduces raw input.

Lines 2647-2664 do the opposite of the comment: case-only updates keep the new sanitized value, while real renames overwrite it with the original wing/room argument. That bypasses sanitize_write_name()/sanitize_name() on actual metadata changes and can persist whitespace/casing variants that break later wing/room filters.

💡 Suggested fix
         new_meta = dict(old_meta)
         if wing is not None:
             wing_pass = sanitize_write_name(wing, "wing")
             if wing_pass["error"]:
                 return {"success": False, "error": wing_pass["error"]}
             update_sanitize_flags.extend(wing_pass["flags"])
             try:
                 new_meta["wing"] = sanitize_name(wing_pass["cleaned"], "wing")
             except ValueError as e:
                 return {"success": False, "error": str(e)}
-            # Preserve existing casing when the caller passes a case-only
-            # variant (LLM clients often "autocorrect" acronyms like ps5→PS5).
-            if wing.lower() != str(old_meta.get("wing") or "").lower():
-                new_meta["wing"] = wing
+            # Preserve existing stored casing for case-only changes.
+            old_wing = str(old_meta.get("wing") or "")
+            if old_wing and new_meta["wing"].lower() == old_wing.lower():
+                new_meta["wing"] = old_wing
         if room is not None:
             room = _config.resolve_room(room)
             room_pass = sanitize_write_name(room, "room")
             if room_pass["error"]:
                 return {"success": False, "error": room_pass["error"]}
             update_sanitize_flags.extend(room_pass["flags"])
             try:
                 new_meta["room"] = sanitize_name(room_pass["cleaned"], "room")
             except ValueError as e:
                 return {"success": False, "error": str(e)}
-            # Preserve existing casing when the caller passes a case-only
-            # variant (LLM clients often "autocorrect" acronyms like ps5→PS5).
-            if room.lower() != str(old_meta.get("room") or "").lower():
-                new_meta["room"] = room
+            # Preserve existing stored casing for case-only changes.
+            old_room = str(old_meta.get("room") or "")
+            if old_room and new_meta["room"].lower() == old_room.lower():
+                new_meta["room"] = old_room

Also applies to: 2661-2664

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/mcp_server.py` around lines 2647 - 2650, The branch comparing
wing/room casing is inverted and may store raw, unsanitized input; change the
logic so that case-only updates DO NOT overwrite existing metadata and real
renames DO store the sanitized name: when processing wing/room, compute a
sanitized value via sanitize_write_name() (or sanitize_name() where appropriate)
and compare the lowercased sanitized/new value to old_meta.get("wing"/"room")
lowercased; if they differ only by case, leave new_meta unchanged to preserve
existing casing, otherwise set new_meta["wing"] or new_meta["room"] to the
sanitized value (not the raw wing/room arg). Ensure the same fix is applied to
both the wing and room branches (references: old_meta, new_meta, wing, room,
sanitize_write_name(), sanitize_name()).
mempalace/mcp_server.py-3473-3539 (1)

3473-3539: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reconnect never drops the cached Postgres backend object.

tool_reconnect() clears _collection_cache, but _get_collection_postgres() only constructs a fresh backend when _postgres_backend_cache is None. This same file already nulls that cache on connection failure to avoid reusing a dead pool, so leaving it intact here means a reconnect after a postgres outage can reopen against the same stale backend object instead of forcing a real reset.

💡 Suggested fix
 def tool_reconnect():
     """Force the MCP server to drop cached ChromaDB + KnowledgeGraph state.
@@
     global \
         _client_cache, \
         _collection_cache, \
+        _postgres_backend_cache, \
         _collection_cache_backend, \
         _collection_cache_palace, \
         _collection_open_error, \
         _palace_db_inode, \
         _palace_db_mtime, \
@@
     _client_cache = None
     _collection_cache = None
+    _postgres_backend_cache = None
     _collection_cache_backend = None
     _collection_cache_palace = None
     _collection_open_error = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/mcp_server.py` around lines 3473 - 3539, tool_reconnect() currently
clears many caches but does not reset the Postgres backend cache, so a stale
connection pool in _postgres_backend_cache can be reused; update the reconnect
cleanup to set the module-level _postgres_backend_cache = None (same place you
clear _client_cache/_collection_cache/etc.) so that _get_collection_postgres()
will construct a fresh backend on next use and avoid reusing a dead Postgres
backend object.
mempalace/backends/_sidecar.py-67-70 (1)

67-70: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Persist sidecar atomically to avoid partial-write corruption.

Directly writing path at Line 67 risks truncating the sidecar on interruption/crash mid-write; that can lose all collection entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/_sidecar.py` around lines 67 - 70, The current
write-to-path in the sidecar saver risks truncation; change the implementation
that writes the sidecar (the block using path, json.dump(data, ...), and
os.chmod(path, 0o600)) to perform an atomic write: write JSON to a temp file in
the same directory (e.g., path + ".tmp" or use tempfile.NamedTemporaryFile with
dir=os.path.dirname(path)), flush and fsync the file descriptor, close it, set
the permissions on the temp file, then atomically replace the target with
os.replace(temp_path, path); ensure errors clean up the temp file and preserve
the existing sidecar on failure, and continue to catch
OSError/NotImplementedError as before.
mempalace/backends/_sidecar.py-39-42 (1)

39-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Malformed dimension can still raise despite “robust to malformed sidecar” contract.

Line 41 can throw ValueError/TypeError for malformed JSON ("dimension": "abc"), which breaks the documented degrade-to-None behavior.

Proposed fix
-    return EmbedderIdentity(
-        model_name=str(entry["model_name"]),
-        dimension=int(entry.get("dimension") or 0),
-    )
+    try:
+        dimension = int(entry.get("dimension") or 0)
+    except (TypeError, ValueError):
+        return None
+    return EmbedderIdentity(
+        model_name=str(entry["model_name"]),
+        dimension=dimension,
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/backends/_sidecar.py` around lines 39 - 42, The code currently does
int(entry.get("dimension") or 0) which will raise ValueError/TypeError for
malformed values like "abc"; update the conversion to be defensive: read raw =
entry.get("dimension"), if raw is None or raw == "" set dimension = None,
otherwise attempt to cast to int inside a try/except (catch ValueError and
TypeError) and set dimension = None on failure, then pass that variable to
EmbedderIdentity (reference symbols: entry, EmbedderIdentity and the function
that returns it in _sidecar.py).
🟡 Minor comments (6)
.github/workflows/publish.yml-76-82 (1)

76-82: ⚠️ Potential issue | 🟡 Minor

Disable persisted checkout credentials before building.

actions/checkout may leave the workflow token/credentials persisted in local git config; the subsequent build runs repo-controlled code. Add persist-credentials: false to reduce credential exposure (token is already limited to permissions.contents: read).

Suggested change
       - uses: actions/checkout@v6
         with:
           # Fully-qualified refs/tags/ so an unqualified name can't resolve to a
           # same-named *branch* instead of the tag (checkout prefers branches).
           # steps.tag.outputs.tag is format-validated above (ref-injection guard).
           ref: refs/tags/${{ steps.tag.outputs.tag }}
           fetch-depth: 0 # full history for the ancestry check
+          persist-credentials: false
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 76 - 82, In the
actions/checkout@v6 step where you set the 'with' inputs (the block containing
ref: refs/tags/${{ steps.tag.outputs.tag }} and fetch-depth: 0), add
persist-credentials: false to the 'with' map so the workflow token/credentials
are not left persisted in the local git config before running the build; update
the checkout step (actions/checkout@v6) to include that key alongside ref and
fetch-depth.

Source: Linters/SAST tools

hooks/antigravity/install.sh-72-93 (1)

72-93: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate option values before shift 2.

--install-dir and --log-level both read ${2:-} and then unconditionally shift 2. When the value is missing, set -e turns that into an immediate shell failure instead of your normal usage/error path.

Suggested patch
         --install-dir)
-            INSTALL_DIR="${2:-}"
+            if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
+                echo "ERROR: --install-dir requires a value" >&2
+                print_usage >&2
+                exit 2
+            fi
+            INSTALL_DIR="$2"
             shift 2
             ;;
@@
         --log-level)
-            LOG_LEVEL="${2:-info}"
+            if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
+                echo "ERROR: --log-level requires a value" >&2
+                print_usage >&2
+                exit 2
+            fi
+            LOG_LEVEL="$2"
             shift 2
             ;;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hooks/antigravity/install.sh` around lines 72 - 93, The parsing for
--install-dir and --log-level currently assigns "${2:-}" and does an
unconditional shift 2 which can trigger set -e when the value is missing; update
the option handling in install.sh to validate that a non-empty value exists in
"$2" before calling shift 2 (for both the case patterns handling INSTALL_DIR and
LOG_LEVEL), and if the value is missing, call the existing usage/error path (or
set an explicit error and exit) instead of shifting; ensure the --install-dir=*
and --log-level=* branches remain unchanged and only the long-form two-argument
branches reference INSTALL_DIR and LOG_LEVEL after validation.
tests/test_mcp_server.py-1274-1277 (1)

1274-1277: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert PalaceRef strictly in this contract test.

getattr(closed, "local_path", closed) lets a raw string still pass, so this no longer enforces the post-MemPalace#1679 API contract.

Suggested test-tightening patch
-        closed = stub.closed[0]
-        assert getattr(closed, "local_path", closed) == config.palace_path
+        closed = stub.closed[0]
+        assert hasattr(closed, "local_path"), "close_palace should receive PalaceRef"
+        assert closed.local_path == config.palace_path
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_mcp_server.py` around lines 1274 - 1277, The test currently allows
a raw string because it uses getattr(closed, "local_path", closed); change this
to assert the contract by verifying closed is a PalaceRef and that
closed.local_path equals config.palace_path (e.g., assert isinstance(closed,
PalaceRef) and assert closed.local_path == config.palace_path), so the
post-#1679 API requirement is strictly enforced for stub.closed[0].
tests/test_antigravity_plugin_manifest.py-149-151 (1)

149-151: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Require an explicit type field here.

This assertion currently passes when the key is missing, which weakens the manifest contract the test is supposed to pin. A template that silently drops type would still go green.

Suggested fix
-    assert handler.get("type", "command") == "command", (
+    assert "type" in handler and handler["type"] == "command", (
         f"{event}: only type=command is supported by Antigravity"
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_antigravity_plugin_manifest.py` around lines 149 - 151, The test
currently uses handler.get("type", "command") which hides missing keys; change
the assertion to require an explicit "type" key and that its value equals
"command" by asserting presence (e.g., "type" in handler) and then comparing
handler["type"] == "command" (or two assertions) so missing keys fail the test;
update the assertion referencing handler.get to instead reference
handler["type"] (and/or an explicit membership check) in the test
function/assertion that contains the current handler.get usage.
docs/fork-changes.yaml-32-32 (1)

32-32: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep manifest summary under the documented 100-char limit.

Line 32 exceeds the schema’s one-line summary length target, which makes rendered changelog rows harder to scan.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/fork-changes.yaml` at line 32, The manifest 'summary' value in
docs/fork-changes.yaml is longer than the documented 100-character one-line
target; shorten this string (the summary key's value) to be under 100 characters
and keep it on a single line by removing or abbreviating details such as the
long commit hash, parentheses or extra clauses (e.g., drop the hash or trim
"RFC-001 backend stack, diary checkpoints restored, 113 commits" to a concise
phrase) so the summary remains descriptive but within the length limit.
tests/test_hallways_pagination.py-27-30 (1)

27-30: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tighten the mock guard so any where usage fails this regression test.

Line 28 currently permits where calls when limit is present, so a future switch to get(where=..., limit=...) could slip through undetected.

Proposed fix
-        if where is not None and limit is None:
+        if where is not None:
             raise RuntimeError("Error executing plan: too many SQL variables")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_hallways_pagination.py` around lines 27 - 30, The mock _get
function currently only raises when where is set and limit is None, allowing
future calls like _get(where=..., limit=...) to bypass this guard; update the
guard in _get to unconditionally fail on any use of the where parameter (e.g.,
change the condition to check if where is not None) so any call to _get(...,
where=...) raises the RuntimeError and the regression test catches such usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e2d154ea-7a37-41f5-aff2-b7fcd80d50f9

📥 Commits

Reviewing files that changed from the base of the PR and between 8e0d896 and ee78457.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (141)
  • .antigravity-plugin/README.md
  • .antigravity-plugin/hooks.json.tmpl
  • .antigravity-plugin/mcp_config.json
  • .antigravity-plugin/plugin.json
  • .antigravity-plugin/rules/mempalace-recall.md
  • .antigravity-plugin/skills/mempalace-recall/SKILL.md
  • .antigravity-plugin/skills/mempalace/SKILL.md
  • .claude-plugin/README.md
  • .claude-plugin/marketplace.json
  • .claude-plugin/plugin.json
  • .codex-plugin/README.md
  • .codex-plugin/plugin.json
  • .dockerignore
  • .github/workflows/docker-publish.yml
  • .github/workflows/publish.yml
  • CHANGELOG.md
  • Dockerfile
  • Dockerfile.gpu
  • FORK_CHANGELOG.md
  • README.md
  • docker-compose.yml
  • docker-entrypoint.sh
  • docs/RELEASING.md
  • docs/fork-changes.yaml
  • docs/integrations/opencode.md
  • docs/recovery/wing-name-migration.md
  • docs/specs/auto-query-integration.md
  • examples/antigravity/README.md
  • examples/antigravity/hooks.json
  • examples/antigravity/mcp_config.json
  • hooks/README.md
  • hooks/antigravity/INVESTIGATION.md
  • hooks/antigravity/README.md
  • hooks/antigravity/STDIN_SHAPE.md
  • hooks/antigravity/install.sh
  • hooks/antigravity/lib/common.sh
  • hooks/antigravity/mempal_save_hook_antigravity.sh
  • hooks/antigravity/mempal_wake_hook_antigravity.sh
  • integrations/shared/recall-protocol.md
  • mempalace/README.md
  • mempalace/backends/__init__.py
  • mempalace/backends/_sidecar.py
  • mempalace/backends/base.py
  • mempalace/backends/chroma.py
  • mempalace/backends/embedding_wrapper.py
  • mempalace/backends/pgvector.py
  • mempalace/backends/qdrant.py
  • mempalace/backends/registry.py
  • mempalace/backends/sqlite_exact.py
  • mempalace/backups.py
  • mempalace/cli.py
  • mempalace/closet_llm.py
  • mempalace/collision_scan.py
  • mempalace/config.py
  • mempalace/convo_miner.py
  • mempalace/dedup.py
  • mempalace/embedding.py
  • mempalace/format_miner.py
  • mempalace/hallways.py
  • mempalace/hooks_cli.py
  • mempalace/ids.py
  • mempalace/knowledge_graph.py
  • mempalace/layers.py
  • mempalace/mcp_server.py
  • mempalace/migrate.py
  • mempalace/miner.py
  • mempalace/normalize.py
  • mempalace/palace.py
  • mempalace/repair.py
  • mempalace/searcher.py
  • mempalace/version.py
  • pyproject.toml
  • tests/_backend_conformance.py
  • tests/conftest.py
  • tests/test_antigravity_hooks_install.py
  • tests/test_antigravity_hooks_shell.py
  • tests/test_antigravity_plugin_manifest.py
  • tests/test_backend_conformance.py
  • tests/test_backends.py
  • tests/test_backups.py
  • tests/test_clean_lone_surrogates.py
  • tests/test_cli.py
  • tests/test_collision_scan.py
  • tests/test_config.py
  • tests/test_convo_miner.py
  • tests/test_convo_miner_unit.py
  • tests/test_dedup.py
  • tests/test_distance_metric.py
  • tests/test_embedder_identity.py
  • tests/test_embedding_wrapper.py
  • tests/test_embeddinggemma.py
  • tests/test_format_miner.py
  • tests/test_hallways.py
  • tests/test_hallways_pagination.py
  • tests/test_hooks_cli.py
  • tests/test_hybrid_search.py
  • tests/test_ids.py
  • tests/test_maintenance_hooks.py
  • tests/test_mcp_mine.py
  • tests/test_mcp_server.py
  • tests/test_migrate.py
  • tests/test_migrate_wings.py
  • tests/test_miner.py
  • tests/test_palace.py
  • tests/test_pgvector_backend.py
  • tests/test_qdrant_backend.py
  • tests/test_repair.py
  • tests/test_searcher.py
  • tests/test_sqlite_exact_backend.py
  • website/.vitepress/api-sidebar.json
  • website/.vitepress/config.mts
  • website/guide/antigravity.md
  • website/guide/claude-code.md
  • website/guide/configuration.md
  • website/guide/mcp-integration.md
  • website/guide/openclaw.md
  • website/public/llms-full.txt
  • website/reference/mcp-tools.md
  • website/reference/modules.md
  • website/reference/python-api/backends/_sidecar.md
  • website/reference/python-api/backends/base.md
  • website/reference/python-api/backends/chroma.md
  • website/reference/python-api/backends/embedding_wrapper.md
  • website/reference/python-api/backends/pgvector.md
  • website/reference/python-api/backends/qdrant.md
  • website/reference/python-api/backends/registry.md
  • website/reference/python-api/backends/sqlite_exact.md
  • website/reference/python-api/backups.md
  • website/reference/python-api/cli.md
  • website/reference/python-api/collision_scan.md
  • website/reference/python-api/config.md
  • website/reference/python-api/dedup.md
  • website/reference/python-api/embedding.md
  • website/reference/python-api/hallways.md
  • website/reference/python-api/ids.md
  • website/reference/python-api/index.md
  • website/reference/python-api/mcp_server.md
  • website/reference/python-api/migrate.md
  • website/reference/python-api/miner.md
  • website/reference/python-api/palace.md
  • website/reference/python-api/searcher.md

Comment thread mempalace/ids.py
Comment on lines +40 to +53
def _delimited_sha256(parts: tuple[object, ...], truncate: int) -> str:
"""Hash parts joined by the unambiguous delimiter, truncate to N hex chars.

Internal helper. Call sites should use the named ``make_*`` wrappers
below so the per-site contract is documented in code, not derived
from caller arguments.

Each part is coerced to ``str`` before joining so the helper mirrors
the pre-v2 behavior of ``f"{a}{b}"`` for ``None`` and numeric inputs —
e.g. ``valid_from=None`` joins as the literal string ``"None"`` rather
than crashing.
"""
key = _DELIM.join(str(p) for p in parts).encode()
return hashlib.sha256(key).hexdigest()[:truncate]

@coderabbitai coderabbitai Bot Jun 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

_delimited_sha256() is still ambiguous when a field can contain |.

Joining parts with a raw delimiter only works if that delimiter is impossible in every input component. That is not true here: make_drawer_id_from_content() hashes arbitrary content, and POSIX source_file names can also contain |. For example, ("a", "b|c", "d") and ("a|b", "c", "d") produce the same hashed key. That reopens the silent-overwrite class this module is meant to eliminate.

Suggested direction
 def _delimited_sha256(parts: tuple[object, ...], truncate: int) -> str:
-    key = _DELIM.join(str(p) for p in parts).encode()
-    return hashlib.sha256(key).hexdigest()[:truncate]
+    h = hashlib.sha256()
+    for part in parts:
+        data = str(part).encode("utf-8")
+        h.update(len(data).to_bytes(8, "big"))
+        h.update(b"\0")
+        h.update(data)
+    return h.hexdigest()[:truncate]

This changes the on-disk ID recipe, so ID_RECIPE will need a new version as part of the fix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mempalace/ids.py` around lines 40 - 53, The current _delimited_sha256(...) is
vulnerable because raw delimiter collisions are possible; change the
canonicalization to an unambiguous encoding per part (e.g., coerce each part to
str, then prefix each with its byte-length or escape it — e.g., produce a
sequence like "<len>:<part>" for every part) before joining and hashing in
_delimited_sha256, so inputs like ("a","b|c","d") and ("a|b","c","d") no longer
collide; update any callers such as make_drawer_id_from_content to continue
using _delimited_sha256 and bump the module's ID_RECIPE version constant to
reflect the on-disk ID format change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and agreed in principle — though it requires a | inside source_file/content, so it's a much rarer shape than the #80 class. ids.py here is byte-identical to upstream/develop; per this sync's policy of not diverging inside upstream-new modules, filed upstream as 1776 → MemPalace#1776 with the length-prefix (netstring) direction and the id_recipe-v3 migration note.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jphein — Understood, and thanks for the thorough triage. The "requires | in a source path or content" bar does make it a lower-probability shape than the positional-concat class. Tracking it upstream at MemPalace#1776 with the netstring (length-prefix) direction and an id_recipe-v3 migration note is exactly the right call — that way the fix lands with the recipe bump and migration path all in one coherent upstream commit rather than as a fork-local divergence.

If you'd like me to resolve this review comment now that it's tracked upstream, just say the word.

@jphein
jphein merged commit 6e2d449 into main Jun 11, 2026
16 of 17 checks passed
@jphein
jphein deleted the sync/upstream-3.4.0 branch June 11, 2026 15:53
jphein added a commit to techempower-org/palace-daemon that referenced this pull request Jun 11, 2026
* feat(hooks): restore diary checkpoints in the silent stop path

The 2026-05-14 mine-only refactor left the diary empty, so the
session-start greeting's diary_read context went stale. The detached
child now writes the AUTO-SAVE checkpoint entry via the daemon's
mempalace_diary_write (agent_name=harness, topic=checkpoint) before
triggering the transcript mine — diary as the marker the greeting
reads, mine as the verbatim source. Mirrors the same restoration
landing in the mempalace fork's hooks_cli (techempower-org/mempalace#348).

Requested by JP 2026-06-11 ('we can have diaries again').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review(gemini): unwrap diary_write response, drop whitelisted-out session_id

- session_id rides only in the entry text; the daemon executor
  whitelists agent_name/entry/topic/wing and silently drops the rest
- _post_mcp only fails on transport errors — unwrap with _extract_inner
  and require the tool-level success flag before logging 'saved'
- failures now log the actual inner/transport error detail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.