653 Commits
Author SHA1 Message Date
Igor Lins e Silva 9a3afc9d64 Merge pull request #2220 from MemPalace/fix/windows-hybrid-search-empty-results
fix(tests): harden hybrid search against empty Windows Chroma reads
2026-08-11 11:47:26 -03:00
Igor Lins e Silva cb03ee61cb fix(tests): harden hybrid search against empty Windows Chroma reads
Windows CI intermittently returns zero hybrid hits right after a fast
seed write (same class as "Nothing found on disk" on tiny collections).
Close the palace client after seeding so the next open re-reads flushed
segments, retry search once if empty, and assert non-empty results with
a clear message instead of IndexError.
2026-08-11 11:20:09 -03:00
Igor Lins e Silva cb7ba7f573 Merge pull request #2218 from MemPalace/chore/sync-main-into-develop
chore: merge main into develop before 3.7.0 promotion
2026-08-11 09:26:33 -03:00
Igor Lins e Silva fd81cc55b3 chore: merge main into develop before 3.7.0 promotion 2026-08-11 09:26:21 -03:00
Igor Lins e Silva 1004572d53 Merge pull request #2217 from MemPalace/docs/changelog-370-release
docs(changelog): fold wave 1–3 notes into 3.7.0 for release
2026-08-11 09:25:31 -03:00
Igor Lins e Silva 7ba6c3a709 docs(changelog): fold wave 1–3 notes into 3.7.0 for release
Move Unreleased entries into the 3.7.0 section, add the Wave 2 headlines
(Hermes core, source adapters, stale-library MCP gate, date search,
openai-compat embeddings, integrity/encoding fixes), and set the release
date to 2026-08-11.
2026-08-11 09:25:14 -03:00
Igor Lins e Silva 1c6fad777d Merge pull request #2159 from amorphous-dreams/fix/hnsw-defaults-followup
test(chroma): assert the HNSW defaults rather than a threshold arithmetic
2026-08-11 09:24:14 -03:00
Igor Lins e Silva cedbba0621 Merge pull request #597 from grtninja/codex/mx3-public-shim-example
docs: add MX3 public shim examples
2026-08-11 09:24:08 -03:00
Igor Lins e Silva df857ccbeb Merge pull request #2163 from ATKabli/fix/rerank-label-dashless-model
fix: crash in --llm-rerank output for model names without a dash
2026-08-11 09:24:03 -03:00
Igor Lins e Silva b6eae9b0e5 Merge pull request #2191 from mbeacom/mbeacom-fix-numpy2-embedding-floats
test(backends): cover the _embed_texts fallback and empty-batch guard
2026-08-11 09:23:57 -03:00
Igor Lins e Silva 77b00d0ede Merge pull request #2204 from rubicon/docs/document-mcp-idle-hours
docs: document MEMPALACE_MCP_IDLE_HOURS
2026-08-11 09:23:50 -03:00
Igor Lins e Silva 5253819f69 Merge pull request #555 from grtninja/codex/fix-541-contributing-discussions
docs: remove GitHub Discussions reference from contributing guide
2026-08-11 09:23:44 -03:00
Igor Lins e Silva 7ebde7d1a5 Merge pull request #2216 from MemPalace/fix/wave2-windows-lock-reap-home
fix(tests): isolate lock-reap home on Windows via USERPROFILE
2026-08-11 08:25:28 -03:00
Igor Lins e Silva cb9356e549 fix(tests): isolate lock-reap home on Windows via USERPROFILE
The #2200 reap tests only monkeypatched HOME. On Windows expanduser("~")
reads USERPROFILE, so the reaper scanned the real home and the suite
failed on test-windows after Wave 2. Share _isolate_home() that sets both.
2026-08-11 08:25:19 -03:00
Igor Lins e Silva eb8090eb84 Merge pull request #2215 from MemPalace/fix/wave2-ci-hermes-chroma-race
fix(hermes): stop second Chroma client racing the filing worker
2026-08-11 08:13:45 -03:00
Igor Lins e Silva 94a41ee217 fix(hermes): stop second Chroma client racing the filing worker
MemoryStack/Layer1 opened a second PersistentClient on the same palace
while the Hermes provider already held one for live filing. Concurrent
access corrupted local Chroma SQLite (disk I/O / Failed to get segments)
and failed CI on develop after #1915.

Wake-up L1 now scans the long-lived collection under the collection lock,
and filing holds that lock for the full upsert. Also rewrite the RFC 001
section-4.4 docstring to avoid the internal §N jargon guard.
2026-08-11 08:13:19 -03:00
Igor Lins e Silva 97be77c0e9 Merge pull request #2000 from mvalentsev/feat/search-date-filter-463
feat(search): add since/before date window to mempalace_search and CLI search (#463)
2026-08-11 07:55:59 -03:00
mvalentsevandMatthew Clapp 5036e3c05e feat(search): add since/before date window to search surfaces (#463)
Co-Authored-By: Matthew Clapp <1807922+nautis@users.noreply.github.com>
2026-08-11 07:55:24 -03:00
Igor Lins e Silva 2174eb047c Merge pull request #2200 from fxinfo24/fix/reap-orphaned-mine-locks
fix(palace): reap orphaned per-source-file mine locks
2026-08-11 07:54:52 -03:00
Offbeat-Breed 27212e5c62 fix(palace): reap orphaned per-source-file mine locks
_cleanup_mine_lock_file reclaims a lock correctly on the happy path (see
its own docstring for the flock-based rendezvous safety it already
handles) — but only for the specific lock a mine_lock context manager
just released. A process that dies before reaching its own finally
block (SIGKILL, force-quit, host crash) never runs that cleanup, and
nothing else in the codebase later revisits that lock file.

Found in the wild: one long-lived installation had 5,636 stale lock
files in ~/.mempalace/locks/, the oldest several months old, none held
by any live process (confirmed via lsof before cleanup). This is
distinct from the #1264 lock-holder-diagnostics fix (identifies who
holds a live lock) and the #1299 mcp_server embedding-function fix
(unrelated code path) — neither addresses orphan reclamation, and the
2026-07-10 outage postmortem comment in mcp_server.py's stdio loop
covers graceful client disconnection, not abrupt process death.

Adds reap_stale_mine_locks(), which reuses _cleanup_mine_lock_file
itself for the actual removal — same nonblocking-flock-reacquire safety
mechanism, same Windows/POSIX handling already tested in this file, no
duplicated locking logic. A lock is only ever removed after this
process re-acquires it, so anything genuinely held by a live process is
left untouched regardless of age. Wired into mine_lock() via a
throttled opportunistic call (_maybe_reap_stale_mine_locks, at most
once per 15 minutes) rather than a new background thread, scheduled
task, or CLI surface — it piggybacks on the natural cadence of mining
rather than adding new infrastructure.

mine_palace_*.lock (the newer per-palace lock added for the #974/#965
fan-out fix) is explicitly skipped — it has its own lifecycle and
holder-identity tracking and doesn't have this failure mode.

Tests: 6 new cases in test_palace_locks.py covering removal of a
genuinely stale+unheld lock, preservation of a young lock regardless of
hold state, the core safety property (a lock held by another process is
never removed even when backdated past the age threshold), skipping
mine_palace_*-prefixed locks, a missing-lock-dir no-op, and the
throttle itself. Full existing test_palace_locks.py suite (19 tests)
passes unchanged. Broader tests/ -k 'palace or mine' run clean (730
passed) aside from two pre-existing failures confirmed unrelated and
present on an unmodified checkout (test_hnsw_capacity.py SQLite WAL
signature caching, test_repair.py FTS5 shadow-table write restriction —
both environment/SQLite-build-specific, neither touches locking).
2026-08-11 07:54:23 -03:00
Igor Lins e Silva 7eaa3bc3de Merge pull request #1915 from raman325/feat/hermes-provider-core
feat(integrations): Hermes memory provider core
2026-08-11 07:53:44 -03:00
Igor Lins e Silva f5a766a0de Merge pull request #2068 from ggettert/feat/2062-mine-source-registry-dispatch
feat(mine): route explicit source adapters through the RFC 002 registry (#2062)
2026-08-11 07:53:35 -03:00
Igor Lins e Silva be27e7852e Merge pull request #2081 from mvalentsev/fix/899-stale-library-detection
fix(mcp): refuse writes when the served library is no longer installed (#899)
2026-08-11 07:53:27 -03:00
Igor Lins e Silva b8c92f852f Merge pull request #2211 from MohabMohie/fix/windows-mcp-fd-capture-fallback
fix: handle unavailable MCP stdout redirection on Windows
2026-08-11 07:53:20 -03:00
Igor Lins e Silva 174dc31647 Merge pull request #743 from MemPalace/docs/rfc-storage-backend-plugin-spec
docs: RFC 001 — storage backend plugin specification
2026-08-11 07:50:32 -03:00
Igor Lins e Silva aeac794c1a docs(rfc-001): fold §4.4 review nits; accept storage-backend spec
Close the last open items on #743 before merge:

- Conformance: document two isolation arms (cross-id for all backends;
  same-id/different-namespace for supports_namespace_isolation advertisers).
- No silent drop: non-advertising backends must raise UnsupportedCapabilityError
  when PalaceRef.namespace is set, rather than accept-and-ignore.
- Wire require_namespace_support() into chroma/sqlite_exact; add conformance test.
- Refresh implementation-status banner now that #1727/#1731/#1732/#1734 landed.
2026-08-11 07:50:09 -03:00
Igor Lins e Silva 4416dbdff0 docs(rfc-001): resolve review blockers; accept storage-backend spec
- §7.4: pin canonical NAMESPACE_MEMPALACE to qdrant's shipped UUID (bensig block)
- §2.1/§4.4: supports_namespace_isolation contract (cschnatz)
- §5/§1.5: minimal Embedder protocol normative; nameless→unknown (kostadis, bensig)
- §2.1/§10: backend-declared distance_metric; searcher.py added to cleanup
- §7.3: observable/serializable run_maintenance; no-op-kind omission
- §2.4/§8.2: multi-collection-per-palace; exact-vector lossless both ways
- §3.3/§4.2/§9: env + versioning clarifications (bensig)
- §10/§11/§13: reconcile with #1679; resolve §12 open questions; Status Draft→Accepted

Follow-ups tracked: #1724 (embedder identity), #1725 (maintenance hooks),
#1726 (searcher.py backend-neutrality).
2026-08-11 07:48:28 -03:00
Igor Lins e Silva 96715514b1 docs(rfc-001): flag mcp_server cache/reconnect for §10 cleanup
#757 landed mtime/inode cache invalidation and mempalace_reconnect
in mcp_server._get_client(). Both are Chroma-specific (stat of
chroma.sqlite3). They should migrate into ChromaBackend.get_collection
and ChromaBackend.close_palace during the §10 cleanup so the freshness
contract lives inside the backend, not in the caller.
2026-08-11 07:48:28 -03:00
Igor Lins e Silva bd3ea6bde4 docs(rfc-001): close four spec defects surfaced in review
Addresses the actual spec defects flagged in #743 review, ignoring
operator-UX asks that are not plugin-contract concerns.

- Goal #3: 'without data loss' → mirrors §8.2's capability-conditional
  lossless-vs-reembed framing. No more overpromise.
- §1.5: `server_embedder` is no longer an implicit escape hatch from
  identity/dimension rules. Such backends MUST expose an effective
  identity via `effective_embedder_identity()` and are bound by the
  same three-state check.
- §7.3: adds `maintenance_kinds: ClassVar[frozenset[str]]` advertisement
  mechanism. `run_maintenance(kind)` must raise
  UnsupportedMaintenanceKindError for unadvertised kinds. Benchmark
  harness reads this set rather than guessing kind names. Reserves
  `analyze`/`compact`/`reindex` as well-defined names.
- §1.2: adds `update()` as optional method with a default get+merge+
  upsert implementation. §2.1: `supports_update` redefined to gate
  atomic single-round-trip semantics (not mere capability), since the
  default impl already supports partial updates.

Operator asks explicitly NOT adopted (diplomatic shims, not contract
defects): `.to_dict()` compat on typed results, migration progress
reporting, `BaseBackend.repair()` separate from `run_maintenance`,
per-palace capability variance, identity recording on read-only ops.
2026-08-11 07:48:28 -03:00
Igor Lins e Silva ed79ccfa0c docs(rfc-001): strengthen embeddings contract and migration safety
Incorporates review feedback from skuznetsov (Postgres, #665) and
dekoza (Lance, #574) on issue #737:

- §1.5: split 'accepts embeddings=' (signature compliance) from
  'persists embeddings as-is' (correctness). Adds
  supports_embeddings_passthrough capability; the former is universal,
  the latter is required to label a migration lossless.
- §1.5: model identity check becomes a three-state machine
  (known_match / known_mismatch / unknown) so legacy palaces without
  recorded identity don't hard-fail on upgrade.
- §1.4: makes explicit that supports_contains_fast is the ONLY
  performance floor the spec promises; without it callers MUST assume
  O(n). $contains is a correctness requirement, not a performance one.
- §3.3: clarifies auto-detect is an upgrade-compat path only, never
  the selection mechanism for new palaces.
- §8.2: migrate CLI refuses to run against a target lacking
  supports_embeddings_passthrough unless --accept-re-embed is passed;
  migration record now captures lossless status and model identities.
2026-08-11 07:48:28 -03:00
Igor Lins e Silva f142458a31 docs: align capability token names in RFC 001
Copilot review flagged back-references in §1.4 and §6 that still used
the pre-skuznetsov-rename names (`$contains_fast`, `sync_capable`,
`change_feed`). Updated to the `supports_*` prefix used in the §2.1
capability table.
2026-08-11 07:48:28 -03:00
Igor Lins e Silva 2a2466c656 docs: draft RFC 001 — storage backend plugin specification (#737)
Formalizes the BaseCollection/BaseBackend contract introduced as a seam
in #413 into an interchangeability spec that third-party backends can
build to. Driven by six in-flight backend PRs (#574, #643, #665, #697,
#700, #381) each implementing the interface differently.

Key decisions captured: entry-point distribution, typed QueryResult/
GetResult replacing Chroma dict shape, daemon-first multi-palace model
via PalaceRef, required where-clause subset (incl. $contains),
mandatory embedder injection with model-identity validation, capability
tokens, shared pytest conformance suite, and a backend-neutral
migrate/verify CLI.
2026-08-11 07:48:27 -03:00
Igor Lins e Silva 9bc8cdddc6 Merge pull request #1671 from maximilize/feat/openai-compat-embeddings
feat(embedding): add OpenAI-compatible /v1/embeddings backend
2026-08-11 07:47:28 -03:00
Igor Lins e Silva d9a24c7000 fix(embedding): land openai-compat EF cleanly on develop
After rebasing #1671 onto current develop:
- Opt test_embedding_api out of conftest's stable EF mock so the
  get_embedding_function selection tests exercise the real factory.
- Move the CHANGELOG entry from released 3.7.0 Performance into
  Unreleased Features (rebase context had drifted).
2026-08-11 07:08:02 -03:00
maximilize f272c84514 fix(embedding): harden API error handling (PR #1671 review)
Address the gemini-code-assist review on #1671:

- Wrap `http.client.HTTPException` (BadStatusLine / IncompleteRead — common
  with local/overloaded servers) and `ValueError` (invalid/missing URL scheme;
  also subsumes `json.JSONDecodeError`) in `EmbeddingAPIError` instead of
  letting them crash the caller.
- Reject a non-dict top-level JSON response before calling `.get()` on it, so a
  JSON list/`null`/string yields a clear `EmbeddingAPIError` rather than an
  unhandled `AttributeError`.
- Add tests for all three cases.
2026-08-11 07:07:13 -03:00
maximilize d471a9e262 feat(embedding): add OpenAI-compatible /v1/embeddings backend
Add an `embedding_model: "openai-compat"` option that computes embeddings
via any OpenAI-compatible `/v1/embeddings` server (LM Studio, llama.cpp,
vLLM, Ollama's OpenAI shim, self-hosted) instead of a local ONNX model.

- New OpenAICompatEmbeddingFunction (stdlib urllib, no new dependency):
  batches requests, asks for `encoding_format: "float"` and a custom
  User-Agent (avoids Cloudflare 403, see #1570), validates the response
  (contiguous 0..n-1 indices + well-formed vectors) before use, and
  L2-normalizes for the cosine collection. Exposes `embed_query` (ChromaDB
  1.5 dispatches query embedding through it, not `__call__`). `name()`
  encodes the model id so switching it forces `mempalace repair
  rebuild-index`. Failures raise a module-specific `EmbeddingAPIError`.
- Endpoint settings resolved by MempalaceConfig as a single source of truth:
  `embedding_api_url` / `embedding_api_model` / `embedding_api_key`, each
  overridable via the matching `MEMPALACE_EMBEDDING_API_*` env var.
  Whitespace-only values are treated as unset; the EF cache key fingerprints
  the key so a token rotation is picked up.
- The miner/MCP `Device:` header reports `openai-compat (<url>)` instead of a
  misleading local accelerator label when this backend is active.
- Opt-in; default stays minilm. Mirrors the existing `openai-compat` LLM
  provider naming; stays local when the endpoint is on the machine/LAN.
- Tests: tests/test_embedding_api.py (no server / no network required).
- Docs: README requirement note, module docstring, CHANGELOG.

Refs #1559.
2026-08-11 07:07:13 -03:00
Igor Lins e Silva db9c917078 Merge pull request #2212 from mvalentsev/fix/2207-repair-backup-non-regular
fix(backups): stop a socket in the palace from aborting repair (#2207)
2026-08-11 07:06:39 -03:00
Igor Lins e Silva 3161cae8a6 Merge pull request #1330 from mvalentsev/fix/convo-miner-skip-subagents
fix(convo-miner): skip Claude Code subagent transcripts by default (#1217)
2026-08-11 07:06:32 -03:00
Igor Lins e Silva b4345e84a7 Merge pull request #1104 from arnoldwender/fix/encoding-non-ascii-sweep
fix(encoding): replace non-ASCII symbols in CLI output (#1034)
2026-08-11 07:06:24 -03:00
Igor Lins e Silva c38cbf726f Merge pull request #2208 from arnoldwender/fix/encoding-repair-ambiguous-window
fix(encoding): require local evidence before repairing the ambiguous [ÂÃ] window (#2193)
2026-08-11 07:06:18 -03:00
Igor Lins e Silva ffb5559823 Merge pull request #2194 from arnoldwender/fix/repair-encoding-cli-stdio-utf8
fix(repair-encoding-cli): reconfigure stdio to UTF-8 like the other entry points
2026-08-11 07:06:11 -03:00
Igor Lins e Silva 05ae73f54f Merge pull request #2098 from KeilerHirsch/fix/utf8-encoding-hardening
fix(encoding): pin encoding=utf-8 on dialect.py + config.py text opens
2026-08-11 07:06:03 -03:00
Igor Lins e Silva cd1c27247a Merge pull request #2195 from KeilerHirsch/integration/spark-safety-current
fix(repair): hold writer lease across rebuild_index
2026-08-11 07:05:40 -03:00
Igor Lins e Silva a09ba417cd Merge pull request #2135 from fatkobra/fix/2112-sweep-repeated-uuid
fix(sweep): collapse repeated message UUIDs before upsert
2026-08-11 07:05:36 -03:00
Igor Lins e Silva 3db1c5bf1b Merge pull request #2038 from messelink/feat/pgvector-facet-counts
feat(pgvector): implement facet_counts server-side aggregation (#1868 contract)
2026-08-11 07:05:31 -03:00
Igor Lins e Silva 3f52734f85 Merge pull request #2192 from amorphous-dreams/fix/service-restores-palace-path-env
fix(service): restore MEMPALACE_PALACE_PATH after the call that stamped it
2026-08-11 07:05:26 -03:00
Igor Lins e Silva 5fa892eae0 Merge pull request #2178 from miky-mfw/codex/fix-codex-marketplace-install
fix(codex): make marketplace plugin installable
2026-08-11 07:05:22 -03:00
Igor Lins e Silva 441f4bbd39 Merge pull request #2203 from rubicon/fix/server-unit-survives-idle-exit
fix(deploy): survive the MCP idle watchdog exit in the systemd unit
2026-08-11 07:05:17 -03:00
Igor Lins e Silva fb0312f6c0 Merge pull request #2206 from bethaniamh/fix-wing-worktree
fix(hooks): derive wing from project, not the git-worktree dir
2026-08-11 07:05:12 -03:00
Igor Lins e Silva f44a95d545 Merge pull request #2199 from KeilerHirsch/fix/2197-markdown-emotion-marker
fix(extractor): ignore markdown emphasis as emotion
2026-08-11 07:05:07 -03:00
mvalentsevandmarcoaperez becc633654 fix(backups): stop a socket in the palace from aborting repair (#2207)
`repair` in its default mode and `migrate` both back up the whole palace
directory with `shutil.copytree` before they overwrite it. `copytree` cannot
duplicate a Unix domain socket or a named pipe: it copies everything else and
then raises `shutil.Error`, so one such entry anywhere under the palace killed
the command at the backup step, before any rebuild ran. Re-running only
repeated it, because the backup already written held a complete
`chroma.sqlite3`, so the next run deleted it and died in the same place.

Both call sites now go through `copy_palace_dir`, which hands `copytree` an
`ignore` callback. The predicate is an allowlist, matching how the rest of the
package treats directory entries: only regular files and directories are
copied, and anything else is named and left out. Device nodes are skipped for
the opposite reason to sockets, since the copy does not fail on them but
dereferences them; a link to `/dev/zero` wrote 493 MB in 8 seconds here before
it was stopped.

A failed `stat` is never a reason to skip. It says the entry cannot be
resolved right now, not that it holds no data, and no errno separates the two:
a symlink into a volume that is not mounted fails exactly like one whose
target was deleted, and on Windows an unmapped drive letter and an unreachable
network share both arrive as `ENOENT` as well. Such an entry is left for the
copy to fail on, exactly as before this change, because the backup is the
safety net for the rebuild that overwrites the palace next. Five tests pin
that boundary, each asserting `shutil.Error` still comes out.

What was skipped is printed for the operator, and printed for a failed copy
too, since that is when knowing what the backup lacks matters most. The report
can fail on its own, because both callers pass `print`: an stdout that cannot
encode an entry's name, or a report long enough to flush partway through onto
a device that refuses the write. One such line costs neither the lines after
it nor, on the failure path, the copy's own exception, which a report that
raises would otherwise replace. On the success path it is not suppressed, so a
caller that passed something other than a working logger hears about it.

Co-Authored-By: marcoaperez <25101951+marcoaperez@users.noreply.github.com>
2026-08-11 12:53:53 +05:00
Grace Gettert b676b2bd2f test(mine): complete source adapter dispatch coverage
Restore the fresh-palace dry-run regression alongside the existing-empty
and initialized-sqlite_exact cases. Cover explicit failure for unsupported
adapter result types and daemon source-adapter dispatch. Ignore Hypothesis'
test cache so it cannot be committed.
2026-08-10 17:19:48 +00:00
Grace Gettert ba163b1de3 test(mine): close round-3 review gaps with real regressions
- existing-uninitialized palace dir dry run: assert no chroma.sqlite3
- initialized sqlite_exact palace dry run: assert artifacts byte-identical
- replace mocked MineAlreadyRunning CLI test with a real held writer lease
2026-08-10 16:37:29 +00:00
Grace Gettert 466a1f8de3 Merge origin/develop into pr-2068 2026-08-10 16:12:59 +00:00
Grace Gettert f414bb881f fix(mine): make source adapter dry runs inert 2026-08-10 15:59:48 +00:00
Mohab Mohie 92140ca75c fix: handle unavailable MCP stdout redirection 2026-08-10 15:47:48 +03:00
Arnold Wender 854276c159 fix(encoding): require local evidence before repairing the ambiguous [ÂÃ] window
repair_mojibake rewrote clean text. The [ÂÃ][continuation] alternative of
_HIGH_CONFIDENCE_RUN is only a two-character window, and Portuguese, Vietnamese
and Turkish end all-caps words in Ã/Â — IRMÃ, MAÇÃ, MANHÃ, BÃO, NHÃ, HÂLÂ, IMÂ.
Prose then follows with a closing quote, guillemet, ellipsis or dash, and all of
those are in the continuation class, so "«MAÇû." and "“IRMÔ" matched a shape
that is also perfectly clean text. Each hit collapsed two characters into one
and was reported as a successful repair.

Three failures came out of the same root cause:

* clean prose destroyed — "«MAÇû." became "«MAÇû.", "“IRMÔ" became "“IRMÔ";
* visible text swapped for invisible C1 controls, because  plus 0x80-0x9F
  decodes to exactly that block, so "İMÂ… edildi." lost its ellipsis to U+0085;
* the max_passes loop was not idempotent: genuinely mojibaked Portuguese was
  repaired correctly on pass 1 and then destroyed on pass 2.

The fix follows the precedent already set in this module, where Ä/Å were
excluded as leads because "Ų can be legitimate scientific text" — the same
argument, applied to the continuation side. A lone [ÂÃ] window ending in
typographic punctuation is no longer high-confidence, so it is repaired only
when a lowercase letter runs directly into the lead ("coûte", "Noël"), which
is where mojibake actually occurs — inside a word. Chained units ("ação") and
the 3-4 character â/ð/ï windows are unaffected. A decode product containing a
C0/C1 control is now always refused.

Corroboration is deliberately local. Inferring "this drawer is mojibake" from a
run elsewhere in the text destroys clean prose in a mixed drawer, and drawers
are mixed by construction since the miner concatenates several sources.

The trade is asymmetric and chosen on purpose: an unrepaired string is unchanged
and can be repaired later, while a wrong repair is silent and irrecoverable.
2026-08-10 09:36:18 +02:00
Richard Joyce 8cf2be7e54 fix(hooks): derive wing from project, not the git-worktree dir
_wing_from_jsonl_cwd took the leaf segment of the transcript cwd as the wing name. When Claude Code runs in a git worktree, cwd is <project>/.claude/worktrees/<wt>, so the leaf is the worktree name and every worktree mines into its own wing_<worktree>, fragmenting a project's memory across throwaway dirs. Strip a trailing /.claude/worktrees/<wt> segment before taking the leaf so all worktrees of a project share that project's wing.
2026-08-10 02:12:40 +01:00
Dax Davis 618764e1a6 docs: document MEMPALACE_MCP_IDLE_HOURS
The variable decides whether a long-lived server is still running
tomorrow, and it appeared in no markdown in the repo. Add it to the
environment variable table and to the remote server operating notes,
including the two parts that are easy to get wrong: /healthz traffic
does not reset the idle timer, and the watchdog exits 0, so a
supervisor set to restart only on failure will leave the server down.
2026-08-09 16:22:51 -05:00
Dax Davis 351e62f736 fix(deploy): survive the MCP idle watchdog exit in the systemd unit
The idle watchdog terminates the process with os._exit(0). systemd's
Restart=on-failure does not restart a unit that exits 0, so a server
installed from this template stops for good after the idle timeout
rather than restarting.

Switch the unit to Restart=always and disable the watchdog in the
server env template, since a dedicated always-on server is not the
per-session case the watchdog was added for.
2026-08-09 16:22:27 -05:00
KeilerHirsch 2d4e108593 fix(extractor): ignore markdown emphasis as emotion 2026-08-09 14:40:39 +02:00
KeilerHirsch fec10df10a style: format encoding migration follow-up 2026-08-09 13:50:27 +02:00
KeilerHirsch a6b2a734cd fix(encoding): handle legacy codepage config migration 2026-08-09 13:33:28 +02:00
Igor Lins e Silva 8516db7fbc Merge pull request #2196 from MemPalace/docs/docker-pull-and-mounts
docs(readme): lead the Docker section with the published image
2026-08-08 20:48:00 -03:00
Igor Lins e Silva c88997b8d7 docs(changelog): note the Docker README rewrite under 3.7.0 2026-08-08 19:46:49 -03:00
Igor Lins e Silva a85c1fd786 docs(readme): lead the Docker section with the published image
The section only ever documented `docker build`, so every reader
compiled the image locally even though `ghcr.io/mempalace/mempalace` is
published multi-arch. Worse, a clone builds `develop` (the default
branch), not the release — a build and a pull could hand you different
versions with no hint that they differ.

Lead with `docker pull`, and cover the things that actually cost people
an evening:

- the container only sees what you mount, so the MCP client config now
  mounts a transcripts directory; without it the server starts fine and
  every mine finds nothing
- paths become container paths after that, and `~` / `$HOME` are not
  expanded by every MCP client
- the first embedding call downloads ~80 MB (minilm) or ~300 MB
  (embeddinggemma) into /data, which reads as a hung container
- bind mounts keep host ownership and the image runs as uid 1000, so a
  0700 directory fails with a bare PermissionError on Linux; Docker
  Desktop's uid mapping hides this on macOS and Windows. `--user` is
  called out as the wrong fix — /data is mode 700 owned by uid 1000, so
  another uid cannot write the palace at all
- mining never writes to its source, so the examples mount it read-only
- the GPU image is x86_64-only; onnxruntime-gpu has no aarch64 Linux
  wheels, so that build fails on Apple Silicon

Every command in the section was run as written against the published
image, and each claim checked rather than assumed: the uid and mode of
/data read out of the image, `--user` confirmed to break a *fresh*
volume, the read-only mount confirmed to still mine, and the aarch64 GPU
failure reproduced.
2026-08-08 19:46:05 -03:00
Arnold Wender 0e60e51824 fix(repair-encoding-cli): reconfigure stdio to UTF-8 like the other entry points
mempalace/_stdio.py states the rule in its own docstring -- "every console
entry point that touches stdio needs to fix this on Windows" -- and cli.py
and fact_checker.py both apply it. scripts/mempalace_repair_encoding.py,
added later, does not.

That matters more here than anywhere else: this tool exists for Windows users
whose palace carries legacy mojibake, and it prints a before/after preview for
every proposed change. Under the console codepage it was written for, the
preview cannot be encoded -- the lead bytes of the corruption it detects are
exactly the characters the codepage rejects -- so the run dies with
UnicodeEncodeError before repairing a single drawer.

Reproduced with stdout on cp936:

  UnicodeEncodeError: 'gbk' codec can't encode character '\xc3'
  in position 14: illegal multibyte sequence

U+00C3 is the lead character of the à family this tool repairs.

Uses replace on stdout/stderr, matching cli.py and fact_checker.py, because
the preview carries verbatim drawer text that may hold surrogate halves
round-tripped from filenames; strict would crash mid-preview.

Related to #1122 (same exception class on the main CLI's help output) but a
different entry point and a different fix -- that one can use ASCII-safe
static strings, this one prints arbitrary user content.
2026-08-08 23:21:55 +02:00
KeilerHirsch 89b2fa6410 fix(repair): hold writer lease across rebuild_index
Protect the complete rebuild_index snapshot, rebuild/swap, and cleanup
cycle with the palace writer lease.

Without whole-operation quiescence, a concurrent writer can land after
the snapshot and be absent from the rebuilt index, recreating SQLite/HNSW
divergence immediately after repair.

Add regression canaries proving that:
- ChromaDB is never opened when the writer lease is unavailable
- the rebuild body executes while the writer lease remains held
2026-08-08 23:11:15 +02:00
Arnold Wender 8c2856266f fix(encoding): extend non-ASCII console sweep to the 11 remaining modules (#1034)
The original sweep in this PR was scoped against develop as of April. Since
then upstream added console output carrying the same characters, so the fix
had drifted into covering roughly half of the affected surface.

Re-measured against current develop and replaced the remaining occurrences of
the same five characters this PR already targets -- U+2014 em-dash, U+2500 box
drawing, U+2192 arrow, U+25CF/U+25CB circles -- on every print(), input() and
logger call reachable from a terminal:

  repair.py (10), cli.py (7), format_miner.py (7), mcp_server.py (5),
  miner.py (2), onboarding.py (2), migrate.py (2), searcher.py (2),
  service.py (1), embedding.py (1), dialect.py (1)

Substitutions match the ones already used in this branch: em-dash -> "--",
box drawing -> "-", arrow -> "->", filled/empty circles -> "#"/".".

Four assertions matched the previous literals and were updated alongside the
strings they cover (test_cli, test_miner x2, test_repair).

Docstrings, comments and stored content are deliberately untouched -- only
what reaches a terminal, where GBK/CP1252 consoles raise UnicodeEncodeError.
2026-08-08 22:29:59 +02:00
Arnold Wender 5aa0e11810 fix(encoding): replace non-ASCII chars in entity_detector output paths
Replace em-dash in confirm_entities() input() prompt and filled/empty
circle chars in confidence bar print() with ASCII equivalents. Both
write to stdout and raise UnicodeEncodeError on Windows GBK/CP1252.
2026-08-08 22:24:29 +02:00
Arnold Wender 0ae26bda8d fix(encoding): replace non-ASCII symbols in CLI output (#1034)
Replaces em-dashes (—), arrows (→), and box-drawing characters (─) in
print() and logger.info() calls across 11 modules. These characters crash
Windows terminals using GBK or CP1252 encoding with UnicodeEncodeError.

Affected modules: repair, dedup, onboarding, entity_detector, convo_miner,
layers, migrate, exporter, split_mega_files, mcp_server, room_detector_local.

Replacements:
  — (U+2014 em-dash)          -> -- (two ASCII hyphens)
  → (U+2192 right arrow)      -> -> (ASCII arrow)
  ─ (U+2500 box-drawing dash) -> - (ASCII hyphen)

Updates test_onboarding::test_hr_prints_line to match the new output.

Closes #1034 (partial — remaining open() encoding fixes tracked separately)
2026-08-08 22:24:29 +02:00
Joshua Fontany accb4e3245 fix(service): restore MEMPALACE_PALACE_PATH after the call that stamped it
Each run_* entrypoint sets MEMPALACE_PALACE_PATH so downstream config
resolution sees that call's palace, and none of them put it back. In the
daemon that costs nothing — one call, one process. In any process making
two calls it leaks: config.py reads this variable with priority over the
config file, so the first call's palace silently becomes the second
caller's, and a tmpdir palace since removed reads back as
PalaceNotFoundError from an unrelated code path.

daemon.py already saves and restores exactly these keys around its own
dispatch. This gives run_mine, run_sync and run_diary_write the same
discipline through a small decorator.

Tests cover both directions — a caller whose variable was already set
gets it back unchanged, and a caller who had none keeps none. All four
fail with the decorators removed.
2026-08-08 13:15:12 -07:00
Igor Lins e Silva c7a026bbd0 Merge pull request #2189 from MemPalace/ci/docker-smoke-test
ci(docker): run the image before publishing it
2026-08-08 14:33:56 -03:00
Igor Lins e Silva 96bd4b4160 Merge pull request #2186 from MemPalace/fix/2185-diary-chunk-logical-id
fix(mcp): resolve chunked diary entries by their entry_id (#2185)
2026-08-08 14:33:03 -03:00
Mark BeacomandCopilot App 656e0a4dee test(backends): cover the _embed_texts fallback and empty-batch guard
#2187 fixed the default-backend ingest failure (#2190) and covered the
numpy path: an ndarray-returning EF plus a real EmbeddingCollection.upsert.
Two branches of the fixed function are still unexercised.

(The defect was not NumPy-2.x-specific. `np.float32` has never been a
`float` subclass in any NumPy major -- verified on 1.24.4, 1.26.4 and
2.4.4 -- so `list(arr)` output was always rejected. It was latent until
0e79797 added `requires_explicit_embeddings` and routed the default
Chroma backend through `_embed_texts` for the first time.)

`_embed_texts` branches on `hasattr(v, "tolist")`. The `float(x)` arm is
the one that serves embedders returning plain sequences — custom/BYO EFs,
and rows arriving as tuples. Nothing ran it, so dropping the `float()`
call there stays green and reaches users as the same production
ValueError, just on a non-default embedder.

The `if not texts: return []` guard is likewise untested. Callers pass
empty batches (a drawer set fully filtered by dedup), and loading the EF
is the expensive part — on the ONNX default it spins up a native session.

Both tests are mutation-verified against this tree:

  - replacing the fallback with `list(v)` fails
    test_embed_texts_handles_plain_sequence_embedders
  - deleting the early return fails
    test_embed_texts_short_circuits_on_empty_input

The plain-sequence EF yields `Decimal`, so the assertion proves a real
conversion rather than values passing through unchanged, and the
empty-batch test asserts by making `get_embedding_function` raise, so it
verifies the EF is never constructed rather than only checking the
return value.

Tests only — no production code changes. They live in `test_embedding.py`
for the reason #2187 documented: conftest's autouse
`_stable_embedding_function_for_tests` replaces `_embed_texts` outright
for every module outside `_REAL_EMBEDDING_TEST_MODULES`.

Refs #2190

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-08 09:59:34 -04:00
Igor Lins e Silva 0bb2d120a7 fix(ci): make the smoke fixture readable by the container's uid
The first CI run failed at step 3 with

    PermissionError: [Errno 13] Permission denied: '/work/mempalace.yaml'

`mktemp -d` creates the fixture 0700 owned by the runner user. Bind
mounts carry host ownership through unchanged, and the image runs as
uid 1000, so the container could not stat inside /work. Docker
Desktop's uid mapping hides this on macOS, which is why it passed
locally and only failed on Linux.

Model an ordinary project checkout instead — 0755 dir, 0644 file — which
is the shape that makes the README's `-v /path/to/project:/work` work
against a normal repo.
2026-08-08 10:38:13 -03:00
Igor Lins e Silva 26a110172e docs(changelog): note the Docker smoke test under 3.7.0 2026-08-08 10:27:36 -03:00
Igor Lins e Silva 01a4a26414 ci(docker): run the image before publishing it
The Docker workflow built both images and never started a container,
and never parsed a Compose file. A green run therefore only meant the
Dockerfile compiled. Two defects that break the very first documented
command shipped past it: `docker-compose.yml` carried a bare
`environment:` key that made Compose reject the file outright (#2188),
and `_embed_texts` handed chromadb `np.float32` scalars so `mine`
aborted on the first drawer (#2187).

Add `scripts/docker-smoke.sh`, which exercises what the README tells
users to run:

  1. `compose config` on docker-compose.yml and the server compose file
  2. entrypoint dispatch for both `cli ...` and bare passthrough
  3. `mine` a mounted directory, asserting a drawer is filed
  4. `search` from a *separate* container, asserting the stored text
     comes back verbatim — this is the assertion that matters, since
     storing user words exactly is the promise the palace makes
  5. a real MCP stdio JSON-RPC handshake: initialize, tools/list, and a
     mempalace_search call whose result must contain the drawer

It asserts on returned content, not just exit codes, and lives in a
script rather than inline YAML so it runs identically on a laptop:
`scripts/docker-smoke.sh <image>`.

The new `smoke` job builds amd64 natively with `load: true` (buildx
cannot load a multi-arch manifest) and reads the publish job's cache
while writing its own scope, so an amd64-only export never lands on top
of the multi-arch one. `build` now needs it, so a failing smoke test
blocks publication rather than being noticed afterwards.

Verified by reintroducing each defect against a real build: the compose
regression fails at step 1, the embedding regression at step 3, and the
current tree passes all five. Failure output is clipped to 500 columns
because a rejected embedding batch otherwise prints a whole 384-dim
vector on one line and buries the message.
2026-08-08 10:26:52 -03:00
Igor Lins e Silva 0ff93caf97 Merge pull request #2188 from MemPalace/fix/compose-null-environment-block
fix(docker): drop the null `environment:` block that invalidated compose
2026-08-08 10:20:36 -03:00
Igor Lins e Silva 0b5c5609d5 Merge pull request #2187 from MemPalace/fix/chroma-embedding-numpy-floats
fix(backends): convert embedding vectors to Python floats before upsert
2026-08-08 10:19:58 -03:00
Igor Lins e Silva d4b439ba31 docs(changelog): note the compose validity fix under 3.7.0 2026-08-08 09:23:28 -03:00
Igor Lins e Silva 6fb5a5844a fix(docker): drop the null environment: block that invalidated compose
`environment:` was present with nothing but comments beneath it, so YAML
parsed it as null and Compose refused the whole file:

    services.mcp.environment must be a mapping

That is every documented Compose command — `docker compose build`,
`docker compose run --rm mcp`, `docker compose down` — failing before
anything starts, on any machine. The README points at this file.

Comment the key out along with its example entries, and say in the file
why it cannot be left bare. The examples switch to mapping syntax so
uncommenting them yields a valid block.

Verified with `docker compose config` on the shipped file (now valid)
and by running the documented flow against a build of this tree: mine a
mounted directory through `docker compose run --rm mcp cli mine`, then
read it back with `... cli search`, which returns the drawer verbatim.
`deploy/docker-compose.server.yml` was checked for the same defect and
is unaffected.
2026-08-08 09:22:54 -03:00
Igor Lins e Silva 0943bf23b3 docs(changelog): note the Chroma embedding-vector fix under 3.7.0 2026-08-08 09:22:16 -03:00
Igor Lins e Silva 547f5658fc fix(backends): convert embedding vectors to Python floats before upsert
Chroma declares `requires_explicit_embeddings`, so every write on the
default backend routes through `EmbeddingCollection`. `_embed_texts`
built its rows with `list(v)`, and `v` is a float32 `np.ndarray` — that
unpacks into `np.float32` *scalars*, which chromadb's
`normalize_embeddings` rejects:

    ValueError: Expected embeddings to be a list of floats or ints, a
    list of lists, a numpy array, or a list of numpy arrays

`mine` aborted on the first drawer, as did every other write against a
default palace. Convert with `.tolist()` (C-speed), keeping a
`float(x)` branch for embedders that already return plain sequences.

The suite could not see this. conftest's autouse
`_stable_embedding_function_for_tests` monkeypatches
`embedding_wrapper._embed_texts` itself for every module outside
`_REAL_EMBEDDING_TEST_MODULES`, so the defective function was never
executed under test. The regression tests therefore go in
`test_embedding.py`, which is exempt from that stub: one asserts the
returned elements are builtin floats, one drives a real Chroma
collection through `EmbeddingCollection.upsert` and reads the document
back. Both fail against the previous line with the production
ValueError.

Verified end to end outside the suite: mining a project and searching
it back returns the drawer verbatim, on the host and in the container
image built from this tree.
2026-08-08 09:21:33 -03:00
ATKabli d64ad8de8d fix: print the full model tag in the longmemeval summary header too
The previous commit replaced llm_model.split('-')[1] on the diary pre-compute
line, but run_benchmark builds the same short name again 35 lines later for
the summary header. That one is dash-guarded so it does not crash, yet it
still guesses at a short name, so a single run printed the full tag on one
line and a mangled fragment on the next: "claude-sonnet-4-6" became "sonnet",
and "llama3.2-vision:11b" became "vision:11b".

No dash-split of a model tag is left anywhere under benchmarks/.
2026-08-08 15:10:24 +03:00
ATKabli 753ecd6855 fix: crash in --llm-rerank output for model names without a dash
llm_model.split('-')[1] raises IndexError for any model tag that contains
no dash (e.g. an Ollama tag like 'qwen3:8b' or an OpenAI-compat route like
'spark/fast'), crashing locomo_bench.py at startup when --llm-rerank is
enabled and longmemeval_bench.py on the diary pre-compute path. Both sites
only build display strings, so print the full model tag instead of
guessing at a short name.

Hit in practice when pointing --llm-backend ollama at a LiteLLM gateway
with provider-prefixed model routes.
2026-08-08 15:10:24 +03:00
Igor Lins e Silva 011e63e5de fix(mcp): resolve chunked diary entries by their entry_id (#2185)
`mempalace_diary_write` returns an `entry_id` for every diary entry, but
for entries large enough to be chunked that id was unusable: get_drawer,
update_drawer and delete_drawer all answered "Drawer not found", and
list_drawers showed the entry as N unrelated chunk rows.

Two metadata conventions never met. The diary chunking path stamped
`parent_entry_id` on each chunk, while the logical-id read paths added in
#1782 query only `parent_drawer_id`. Both keys mean the same thing --
"physical chunk of this logical drawer" -- so chunk groups written by
diary_write were invisible to logical-id resolution. Same bug class as
#1763, which #1782 fixed for `add_drawer` drawers only.

Read paths now resolve either key via `_PARENT_ID_KEYS`:

- `_logical_chunk_group()` matches both with an `$or` (fixes get /
  update / delete). All four backends support `$or`.
- `_collapse_drawer_rows()` groups on either (fixes list_drawers, which
  the `$or` alone does not cover).
- `searcher._result_drawer_id()` resolves either, so a hit on a chunked
  diary entry reports the id that fetches the whole entry rather than
  the single chunk that matched.

New diary writes also stamp `parent_drawer_id` alongside
`parent_entry_id` so the two conventions converge going forward. Because
the read paths still accept the `parent_entry_id`-only shape, palaces
written before this fix are repaired with no data migration.

Diary chunks are written without `source_file`, so neighbor expansion
(#1580) returns early on them and is unaffected by the added key.

Also drops the comment telling callers to iterate `chunk_ids` (it
documented the bug as intended behavior) and a stale claim that search
rejoins chunks via `parent_entry_id` -- no search code read that key.
2026-08-08 09:06:53 -03:00
Igor Lins e Silva e73e75b0d2 Merge pull request #2181 from MemPalace/dependabot/pip/ruff-0.16.1
chore(deps-dev): bump ruff from 0.15.20 to 0.16.1
2026-08-08 08:56:20 -03:00
Igor Lins e Silva 55d6c97512 Merge pull request #2162 from MemPalace/feat/logstream-3.7.0
feat: agent logstream — coordination for a multi-machine agent fleet (RFC 003)
2026-08-08 08:55:54 -03:00
mvalentsevandmesselink d1d904f44b fix(mcp): refuse writes when the served library is no longer installed (#899)
A long-lived MCP server imports mempalace and chromadb once and serves from
those in-memory modules for the life of the process, so an upgrade on disk
mid-session never reaches it and it keeps accepting writes produced by code
the user no longer has installed.

Refuse mutating tools with JSON-RPC -32005 once a watched distribution's
installed version differs from the snapshot taken at import, or once it is
gone entirely. Reads stay available, mempalace_status reports
library_versions, and MEMPALACE_MCP_ALLOW_STALE_LIBRARY=1 opts out.

Both sides of the comparison come from installed metadata rather than a live
module.__version__. A distribution whose metadata cannot be read, or whose
search root will not open, is reported and left uncompared rather than
treated as removed: importlib.metadata suppresses the failure at both of
those levels, so either one would otherwise look exactly like an uninstall
and refuse every write on a healthy install.

The same module also memoizes each search root's listing against that root's
mtime, read in seconds where this fingerprint compares nanoseconds. An
upgrade whose removal and creation both land inside one timestamp tick would
then be answered from the listing taken before it, naming a dist-info that is
already gone; its version reads as empty, the distribution is left
uncompared, and nothing moves that mtime afterwards, so the gate would stay
off for it for the rest of the process. Drop the memo before each reading.

Watch chromadb only when chromadb is the backend serving. It is a hard
dependency rather than an extra, so it is installed even for a palace kept
in Postgres, and watching it there would refuse that user's writes whenever
chromadb alone was upgraded, over a library that writes nothing they own. A
backend that cannot be resolved keeps it watched.

Skip a sys.path entry carrying an embedded NUL. os.stat and os.listdir refuse
it during argument conversion, raising ValueError rather than the OSError
those callers hold; POSIX never gets there because realpath rejects it first,
but Windows resolves it and one such entry would end the whole reading.

The gate sits ahead of the diverged-index refusal added since (-32004, which
is why this one takes -32005). That gate's remedy is `mempalace repair
rebuild-index`, which would run the installed code against a palace this
process is still writing with the superseded one, so the restart instruction
has to be the one that reaches the client; the index probe re-runs per call
and surfaces immediately after a restart. Ordering it this way also skips
that gate's segment probe on a call already refused. Both directions of the
precedence are pinned by tests.

Co-Authored-By: messelink <274674234+messelink@users.noreply.github.com>
2026-08-08 16:52:33 +05:00
Igor Lins e Silva 8bc41e003c ci: bump the ruff pin with the dependency, and guard the drift
The dependabot bump moved pyproject's ruff pin to 0.16.1, but nothing else
followed it, so the repo asked for three different versions at once:

- pyproject.toml said 0.16.1 (the bump)
- uv.lock still resolved 0.15.20 (dependabot did not update it)
- .github/workflows/ci.yml installed 0.15.14 by its own literal pin

The lint job never reads pyproject, so CI kept linting with 0.15.14 and
reported this PR green without 0.16.1 ever running. The ci.yml pin had
already drifted from pyproject before this bump, under a comment saying to
keep them identical.

- ci.yml: pin 0.16.1 to match pyproject.
- uv.lock: regenerated so the locked resolution agrees.
- test_ruff_pins_match: assert ci.yml and pyproject stay equal, so the next
  bump that touches only one of them fails loudly instead of passing blind.
- extend-exclude '*.md': 0.16 began formatting Python inside markdown
  fences, taking the formatter from 199 files to 295 and reflowing
  hand-aligned example code in docs/rfcs/002 and three website pages.
  Excluding docs keeps this a version bump rather than a silent
  documentation reflow, and restores the exact file scope the project has
  always formatted.

Verified with 0.16.1 actually installed: ruff check and ruff format --check
both clean over the same 199 files, full suite green (3669 passed).
2026-08-07 09:27:28 -03:00
Igor Lins e Silva aa3eca5c1f Merge remote-tracking branch 'origin/develop' into fix-ruff-2181 2026-08-07 09:23:42 -03:00
Igor Lins e Silva 759b1273d3 Merge pull request #2149 from mvalentsev/perf/2104-embeddinggemma-size-grouping
perf(embedding): group documents by size before sub-batching (#2104)
2026-08-07 09:23:10 -03:00
Igor Lins e Silva e0715032d3 Merge pull request #2166 from mvalentsev/fix/2160-chatgpt-export-array
fix(normalize): parse ChatGPT export arrays, not just single conversations (#2160)
2026-08-07 09:23:04 -03:00
Igor Lins e Silva 87f21542ab Merge pull request #2145 from mvalentsev/fix/repair-legacy-dry-run
fix(repair): honor --dry-run in the default mode (#2144)
2026-08-07 09:22:57 -03:00
Igor Lins e Silva b757aa7475 Merge pull request #2158 from Evgen197310/fix/hnsw-diverged-write-gate
fix(mcp): refuse vector writes into a diverged HNSW index instead of hanging forever
2026-08-07 09:22:50 -03:00
Igor Lins e Silva b62c5f9cdd fix(logstream): read and write artifact content as bytes, never text mode
Python's text layer applies universal-newline translation: CRLF becomes LF
on read, and on Windows LF expands back to CRLF on write. Every CLI path
that carried artifact and event content went through it, so a patch piped
in from a Windows agent was stored as different bytes than the ones git
produced — under a sha256 of the mangled content, which then failed to
match on the machine that applied it. For a store whose contract is
verbatim bytes addressed by digest, that is silent corruption.

It also disarmed the CRLF warning added for kind=patch: put_artifact looks
for a carriage return that the read path had already stripped, so the one
check meant to catch unappliable diffs could never fire on the platform
that produces them.

- _read_stdin_exact() / _write_stdout_exact(): go through sys.stdin.buffer
  and sys.stdout.buffer, falling back to the text layer when a replaced
  stream has no .buffer.
- --file / --body-file: read_bytes().decode() instead of read_text().
- artifact get --out: write_bytes() instead of write_text().

Verified through real pipes: a 63-byte CRLF patch round-trips at 63 bytes
with an unchanged sha256 via stdin, --out, and stdout, and the CRLF warning
now fires. Regression coverage in TestVerbatimNewlines, confirmed failing
against the old behavior first.

Config reads (peers.json, replica.json, server registry, token file) are
left on read_text: JSON is newline-insensitive and none of them carry user
content.

Reported by Copilot on the pull request.
2026-08-07 08:21:57 -03:00
dependabot[bot] 3991bef49f chore(deps-dev): bump ruff from 0.15.20 to 0.16.1
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.20 to 0.16.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.20...0.16.1)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 07:54:12 +00:00
Miky 2f72c91fd6 fix(codex): make marketplace plugin installable 2026-08-07 10:00:23 +07:00
Grace Gettert 141e16fb2b fix(mine): harden source adapter dispatch 2026-08-06 16:33:30 +00:00
mvalentsev 495a12bff5 fix(repair): honor --dry-run in the default mode (#2144)
The default (legacy) repair path ignored --dry-run and ran the real
rebuild: it deleted any existing <palace>.backup, copied the live palace
over it, re-filed the drawers collection through a staged temp copy, then
rebuilt FTS5 and VACUUMed. #2095 and #2133 fixed this for
--mode from-sqlite only.

The preview now returns before ChromaBackend() is constructed. Opening a
chromadb client is itself a write to chroma.sqlite3, so a preview that
reached one could not be inert; the row count comes from
sqlite_drawer_count instead, the read-only SQLite ground truth
check_extraction_safety already trusts. Staying off the chromadb layer
also keeps a dry run clear of the layer repair is separately reported to
segfault in on a large palace (#2113).

resolve_repair_preflight_errors() decides what a dry run does about the
FTS5 autoheal. The autoheal is a write, so a preview must not run it, but
skipping it routed an isolated inverted-index error into the abort banner
and exit 1 - telling the operator to run offline sqlite3 .recover on a
palace a real run heals by itself (#1596). The dry run now classifies the
errors with the same _errors_are_isolated_fts5 predicate the real path
gates on and continues; broader corruption still aborts in both modes.
It is worded as an attempt rather than a promise, because the real heal
still gives up when another process holds the mine lock, when the rebuild
raises, or when quick_check is still dirty afterwards.

The plan describes the real run in execution order. It names the
live-collection delete the rebuild performs, since "re-file via a staged
temp copy" alone reads as additive; it warns when an existing backup
would be deleted; and it reports a no-op instead of a rebuild when the
collection holds no rows. It states the #1208 truncation guard that can
abort the run, and reports that guard as disabled when
--confirm-truncation-ok is set, because check_extraction_safety returns
immediately then and the promised abort would not happen. An unreadable
count fails closed with a non-zero exit, for parity with the from-sqlite
preview.
2026-08-06 16:48:59 +05:00
mvalentsev 6093340754 fix(normalize): parse ChatGPT export arrays, not just single conversations (#2160)
A real ChatGPT conversations.json is a top-level array of conversation
objects. _try_chatgpt_json requires a single dict with a "mapping" key, and
no other parser in the chain claims the array either, so the export fell
through to the plain-text path: mine --mode convos reported success while
filing serialized JSON sliced at arbitrary offsets.

Unwrap the array and normalize each conversation separately, mirroring
_try_claude_ai_json_split, so a freshly downloaded export sitting next to the
previous one files only the conversations that are actually new.

The ChatGPT parser is now reached for every element of every top-level JSON
array under the mined tree, so type-check its nested shapes: ten of the
twelve malformed payloads now covered by tests raised AttributeError,
KeyError or TypeError, which would abort a whole mine run since convo_miner
catches only OSError and ValueError.
2026-08-06 16:48:56 +05:00
mvalentsev 5bc539d4a7 test(convo-miner): pin subagent skip against explicit single-file targets
The default `subagents/` skip is directory pruning, so it cannot reach a
caller who names one transcript directly: that path feeds a single
synthetic scan entry with no directories to prune. The split is
deliberate -- `--include-subagents` governs what a directory walk sweeps
up, while naming a path stays an explicit request -- but the single-file
scan and this filter were written independently and nothing exercised
them together, so the boundary was unpinned.
2026-08-06 16:33:16 +05:00
mvalentsev 121a4552ac perf(embedding): group documents by size before sub-batching (#2104)
The tokenizer pads every row of a sub-batch to the longest sequence in it,
and attention costs batch x heads x length^2 per layer, so arrival order
decided what an embeddinggemma run cost: one long verbatim message dragged
its whole sub-batch up to its own length.

Sort by UTF-8 byte length before the split and scatter each row back to its
input index. An input that fits one sub-batch is left alone, since every row
pads to the same width either way.

Over 43,157 sweep drawers from 160 transcripts, padded token slots drop
39.7% and the quadratic attention term 45.0%. Wall clock on 52 messages with
one model instance: 1169.70 s to 490.83 s, control baseline 1286.23 s. Row
values move by at most one float32 ULP (1.2e-07, cosine 0.99999992), which
is reduction-order rounding, not a change of meaning.

Only palaces configured for embeddinggemma are affected; the default MiniLM
embedder pads to a fixed width.
2026-08-06 16:33:10 +05:00
Igor Lins e Silva 123d01a16e feat(3.7.0): agent logstream — scope the slice to coordination only
Narrows the 3.7.0 slice to the feature users actually get: RFC 003 agent
coordination (events, artifacts, patch handoffs) plus the multi-master
logstream sync that makes it work across machines.

Removed, deferred with the rest of RFC 004:
- replica_sync.py / vector_cache.py and the `mempalace replica` CLI
- the /snapshot/* hub endpoints they backed
- website/concepts/replicated-palace.md

Memory read replicas were a leaf on the dependency graph (nothing in the
logstream path imports them), and shipping them half-done meant documenting
a mesh whose memory does not actually converge. Dropping them lets the docs
say one true thing instead of two hedged ones: coordination syncs, memory
stays local, point every agent at one hub if you want shared recall.

Fixes a bug found by running it: _start_peer_sync_thread() read peers.json
once at startup and returned early when absent, so a hub started before
peers.json was written never synced — silently, forever. That is the order
the guide tells users to follow. Membership is now re-read every round.

Verified on two live hubs: delegation loop end-to-end, verbatim patch
round-trip by sha256, bidirectional sync, CLI sync alongside a live hub,
and automatic convergence 15s after writing peers.json with no restart.
2026-08-06 00:25:49 -03:00
Igor Lins e Silva f78fe31c92 docs: scope the mesh docs to what 3.7.0 actually ships
The website pages arrived from the full RFC 004 branch and documented the
memory op-log, `mempalace oplog`, and automatic bidirectional memory
convergence. None of that is in this slice: the CLI has logstream, artifact,
and replica, and memory moves by one-way `replica pull`.

- cli.md: drop the `mempalace oplog` section (the command does not exist);
  note that full convergence is staged.
- replicated-palace.md: split the sync table into shipping (coordination,
  multi-master) vs next (memory ops, bidirectional); say plainly that two
  machines capturing independently do not merge yet.
- shared-brain.md: mark step 3 a pull you re-run, not a converging loop.
- RFC 004: record which steps landed.
2026-08-05 23:59:58 -03:00
Igor Lins e Silva 8a019919f4 docs: restore develop's README/mcp-tools content lost in the slice resolution
The rebase resolution had taken the branch version of both files wholesale,
dropping develop's backend table, version badge, mempalace_kg_supersede
section, and the checkpoint added_by row. Rebuilt as develop's content plus
only the RFC 003 logstream additions; tool count is 43 (36 + 7 logstream).
2026-08-05 23:54:56 -03:00
Igor Lins e Silva 2b56ea2757 style: ruff-format drift in tests/conftest.py after slice rebase 2026-08-05 23:50:54 -03:00
Igor Lins e Silva 0e79797025 fix: stabilize Chroma embeddings on Windows
(cherry picked from commit 2f7d4d5274042915d4c792f2b2558775a4cc87e3)
2026-08-05 23:50:31 -03:00
Igor Lins e Silva 3d9c1560a7 docs: clarify logstream wait and tunnel semantics
(cherry picked from commit 8bbe722a4852e31830e86b7d8b2afb1d9d212d63)
2026-08-05 23:50:31 -03:00
Igor Lins e Silva dd19c19b4c docs: clarify replication auth boundaries
(cherry picked from commit 36032a2fd1c15d17bacc6e4de6180e258fe15f57)
2026-08-05 23:50:31 -03:00
Igor Lins e Silva 83281b08c9 fix: widen minted replica ids
(cherry picked from commit 84ef3ee1533e329ca7277950fc03398807f5339a)
2026-08-05 23:50:31 -03:00
Igor Lins e Silva db8e588121 fix: guard HLC against future clock poisoning
(cherry picked from commit b540b03a5d498fb3045af965ff570e584974b48a)
2026-08-05 23:50:31 -03:00
Igor Lins e Silva b545eb30af docs(rfc): RFC 005 — agent identity & routing (host:agent:project)
Identity becomes the triple host:agent:project — the granularity at which an
actor actually shares filesystem, local config, and knowledge. Two sessions in
the same project on the same box are the SAME actor with the SAME identity;
session/PID is event metadata, never identity. Fixes the live collision where
two windows-claude sessions interleaved inboxes, claims, and diary under one
flat name.

Two decisions resolved (both live in the backend/hub):
- Matcher: NO change. Route the tuple as an opaque string on RFC 003's exact +
  '*' broadcast; colons already pass _sanitize_routing. Hierarchical/glob
  routing is deferred — it's a real index/correctness surface and no concrete
  need has landed. This ships as a convention + one renderer change, zero risk
  to the routing hot path.
- Order: host:agent:project (host-first), matching how mac-*/windows-*/blade-*
  names already read; migration is a pure suffix append, not a rename.

Claim safety under a shared identity needs no lease server: natural mutex
(don't claim what your identity already claimed) plus RFC 004 R3's earliest-HLC
tiebreak for the residual race. The durable fix is one place — the renderer of
the mempalace-shared-brain block emits the tuple, so every box re-derives on
next sync instead of being hand-edited. Deferred behind the RFC 004 write-flip;
touches no storage or merge semantics.

(cherry picked from commit b043c29fd6a65ea32f92252b8afab1b62bc95bf5)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva e12a8dacbf feat(logstream): mempalace_event_list preview mode — scan the stream cheaply
Event bodies are stored verbatim and fleet status updates run to several
KB, so listing many events full-body is a large payload — agents hit it
catching up on the stream. New preview=true truncates each body to a
200-char excerpt (marking body_truncated + body_length) while keeping
every routing/metadata field, so an agent can scan and then re-fetch
only the events it cares about in full via a targeted since_event_id.
Default (full verbatim) is unchanged; the verbatim-on-demand guarantee
is intact.

(cherry picked from commit dcf43984b84baea49838b2c665d62e572d259615)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva bc0c59171e fix(estate): mempalace_mesh_peers survives the palace integrity gate
The estate is observability — logstream + sync state + peers.json, no
FTS5 dependency; the profile's drawer count already degrades
gracefully. A damaged palace is exactly when mesh visibility matters
most. Caught live on the third replica: its FTS5 index went malformed
and the integrity gate refused the estate tool along with the palace
tools.

(cherry picked from commit c694a8401cb2efd0ca482344503956e4b4c5bad3)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 00050b3589 feat(estate): node profiles + mempalace_mesh_peers — the estate stops guessing (RFC 004 A.1)
Every hub now advertises a self-derived node profile: roles
(replica/agents/compute), resolved accelerator + embedder, live drawer
count, hardware string, advertised_at. Every field is derivation from
what the daemon observes about itself — never configuration, so mesh
dashboards render what each machine reported, not what a UI guessed.

Profiles ride the existing sync surfaces: /sync/version_vector gains
additive profile/profiles fields, each round's stats carry them, and
carriers relay profiles for origins they only know transitively —
/sync/peers now serves per-peer profiles plus origin_profiles keyed by
replica id. Unreachable peers keep their last advertised profile.

New MCP tool mempalace_mesh_peers returns exactly the /sync/peers
payload from the same producer function, so the desktop app consumes
the estate through the existing bridge — one integration, one token
path, and the tool and endpoint can never drift. Tokens are never in
the payload, asserted in tests.

(cherry picked from commit f31d0904167f5463285ae78de335bcea304ed71a)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 32b96cd7f0 docs(website): existing palaces join the mesh additively — the common upgrade path
Called out explicitly after proving it live: a pre-mesh palace joining
the replicated palace keeps everything (nothing re-mined, nothing lost),
serves its history to peers via one embed-cache + pull, and its drawers
are first-class mesh citizens. This is the path most existing users will
take, so the guarantee belongs in the guide, not in an RFC appendix.

(cherry picked from commit 1023368eed16f16e29544ba44f562872d26df101)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva a11f2671fe docs(website): the replicated palace — mesh documentation catches up with the code
The docs ended at the single-hub logstream era; the mesh has since gone
from RFC to production. This brings the site current:

- NEW concepts/replicated-palace.md: one logical palace across N
  machines — the availability invariant, the three layers, ops/HLC/
  version vectors, gossip convergence, snapshot bootstrap + peers.json,
  distributed embedding (sync the facts, derive the senses), the
  /sync/peers estate surface, and the trust story (bearer tokens today,
  mesh identity next). Status-honest: the memory op-log is documented
  AS the dual-write shadow it currently is, with oplog verify named as
  the cutover gate.
- reference/cli.md: mempalace replica (pull/embed-cache), mempalace
  oplog (status/sync/fold/verify), logstream sync — with the
  operational rules (hub-stopped pulls, quiescent origins,
  MEMPALACE_SYNC_HTTP_TIMEOUT for big bootstraps).
- guide/shared-brain.md: new 'From hub to mesh' section — the
  three-step join recipe and why the mesh removes the hub as a single
  point of failure; See-also links updated.
- Sidebar: Replicated Palace under Concepts.

Example hostnames throughout; vitepress build clean.

(cherry picked from commit 63c2c3c20fcd350b8aca6b3249dd1a08ba5ecc22)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva c311f4b14e feat(transport): the RFC 004 transport seam — MeshGuard integration step 1
Layer 2 promised it would see peers only through a transport interface;
until now that seam existed in the RFC but not in the code. This lands
it: mempalace/transport.py defines the full contract (self_id / peers /
request now; open_stream / on_presence_change / on_inbound documented
and explicitly deferred to the MeshGuard binding with loud
NotImplementedError), and today's shipping behavior — tailnet HTTPS with
peers.json bearer tokens — becomes its first implementation,
HttpsBearerTransport, moved wholesale below the seam (load_peers,
PEERS_FILENAME, the wire primitive, the timeout knob).

get_transport(palace_path) is the single swap point: MEMPALACE_TRANSPORT
defaults to https; 'meshguard' is reserved and fails loudly until the
binding ships — a user who asked for mesh-identity auth must never
silently run on the bearer-token model it replaces. sync_all now
iterates the transport's membership snapshot (injectable for tests and
for the binding).

Compat surface preserved exactly: logsync re-exports load_peers /
PEERS_FILENAME / _peer_get, and SyncPeerError IS TransportError (same
class), so every existing importer and except-clause keeps working.

Context: three-replica mesh went live tonight on bearer tokens; joining
the third node took ~10 manual credential operations for 2 of 3 edges —
the N-squared out-of-band token relay this seam exists to retire.
MeshGuard trust-path sweep (RFC 004 pre-integration gate a) running in
parallel; the FFI binding (gate b) builds on this seam next.

tests: seam contract, https impl wiring, factory swap point incl.
reserved-name loudness, compat surface. Full suite: 3496 passed.

(cherry picked from commit 45b0aa335375af2e7c93eb9b4b805bcc6877b219)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva fc17b15278 feat(sync): /sync/peers — the estate endpoint the mesh UI was missing
PalaceMind's mesh view had no data source: the hub exposed version
vectors and op ranges but nothing that says which replicas exist, what
they're called, whether they were reachable last round, or how far
behind they are. A third replica joining the mesh was invisible to the
UI even though every version vector already carried its origin.

GET /sync/peers (bearer, lock-free like the rest of /sync) now serves
the RFC 004 estate data from this replica's point of view:
- self: replica_id, hostname, local version vector
- peers: configured peers.json entries (name + url — the token is
  NEVER included) merged with last-round sync state recorded by the
  anti-entropy loop: peer replica_id, reachable, last_success_at /
  last_error(_at), last pulled counts, and the peer's remote version
  vector (drift = remote vector vs self vector, computed by consumers)
- unnamed_origins: origins present in the local log but not configured
  as peers — replicas known only transitively through a carrier
- sync_interval_s so consumers can pace their polling

sync_with_peer now returns remote_version_vector; the sync loop folds
each round's outcome into _PEER_SYNC_STATE (whole-entry replacement; an
error round preserves the last known good state so the UI can show
"unreachable since X, last seen at vector V").

tests: estate payload over real HTTP incl. token-privacy assertion and
a transitively-known origin; error-round state preservation.
Full suite: 3484 passed.

(cherry picked from commit ffb8f81ef41b2af32747f58f42b4f354b56d5f0b)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva a3d8826ac7 fix(sync): env-tunable peer HTTP timeout (MEMPALACE_SYNC_HTTP_TIMEOUT)
The hardcoded 30s per-request timeout failed in production during the
third-replica bootstrap: a deep-offset snapshot page (Chroma get(offset=N)
scans N rows) behind the mac's tailscale-serve TLS proxy exceeded 30s at
offset 27500 of a 31k-drawer origin. The pull is resumable so nothing was
lost, but bootstrap pulls need a higher ceiling. Default unchanged at 30s;
malformed values degrade to the default. Longer-term fix (tracked): id-
cursor snapshot pagination so page cost stops growing with offset.

(cherry picked from commit 30715f438eb0427cb6783f2a55e0330f20652b8b)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 9eb8c22c40 feat(replica): distributed embedding — vectors computed at origin, folded insert-only
RFC 004 distributed derivation, first brick (asked by Igor: 'could the
mesh become the user's personal GPU cluster?'). A vector is a pure
function of (content, embedder identity); with a pinned fleet identity it
becomes a portable fact computable on whichever machine has the
horsepower.

vector_cache.py: sidecar vector_cache.sqlite3 keyed (drawer_id,
embedder), packed float32; build_cache() bulk-embeds local documents —
read-only on the content store (quiescence-safe), idempotent and
resumable. CLI: mempalace replica embed-cache [--model] [--batch]
[--all].

Serving: /snapshot/drawers?vectors=<model> joins the cache and ships
base64 float32 per item; manifest advertises per-model cache counts.

Fold: mempalace replica pull --with-vectors verifies the served identity
against the LOCAL embedder identity (loud refusal — a foreign-model
vector poisons the index silently), upserts with explicit embeddings,
falls back to local embedding per missing item; stats report
vectors_used vs locally_embedded.

Six new tests incl. the strict one: a fully-vectored fold must invoke
zero local embedding. Full suite 3450 green. Immediate use: the 156k
Windows merge — origin embeds on the 9950X3D under 'minilm', the mac
folds insert-only, hub window drops from ~an hour to minutes.

(cherry picked from commit a134f50cef950affb4888e9fed36489e433ec824)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 7a61b65c7e fix(replica): thread pull_kg through the peers.json pull path
--no-kg was only honored with an explicit --peer; pull_from_peers now
accepts and forwards it. Caught during the first production reverse pull
(Windows pilot batch, 2,235 drawers folded into the live mac palace in
31.7s including local embedding).

(cherry picked from commit a609e32fc60ca6f63101a2782c91124c1df61d0d)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 5e6580d0c2 fix(replica): authored-only snapshots — bidirectional pull pairs without echo
Raised by windows-claude before its conversation-mining run (option C on
correlation windows_conversation_mining_20260702): with snapshots serving
the full collection, a reverse pull would re-import the puller's own
drawers re-stamped as peer-origin — an echo loop corrupting provenance and
delete-reconciliation scoping. Snapshots now serve only what a replica
AUTHORS (drawers without a replica_origin stamp), making each machine the
origin of its own history; pull pairs converge with no echo. Pages expose
next_offset (raw scan cursor) since filtered pages can be sparse; the pull
engine follows it.

Also adds pull_kg=False / CLI --no-kg: KG rows carry no origin stamp yet,
so a reverse pull could overwrite newer local KG state with the peer's
stale replicated copy — disabled for reverse pulls until step 2a gives KG
facts op provenance. New test: two-origin bidirectional pull, no echo,
clean provenance both sides. Full suite 3444 green.

(cherry picked from commit cafe7fba34868bd86280f8d0c2efbc18a3f50ae0)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva a06dbabdd4 docs(rfc): record step-2 rollout decisions (Igor, 2026-07-02)
Revisions kept forever (no GC path; search surfaces heads only); v4
migration staged on a copy before live-with-backup; step 2 ships as 2a
(drawers+KG ops) then 2b (registry/hallways); mac origin dual-writes
during a shadow period before cutover; remote write-flip (step 3) gated
on the local-capture promotion validating end-to-end.

(cherry picked from commit 34a264e12bdc3ba6f40877972e9435e3f912ee6d)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 5381035513 docs(rfc): RFC 004 — local-capture promotion + v4 dedup as step-2 commitments
Driven by a real customer: the Windows machine holds un-mined projects and
conversations. Capture must happen where the data lives (mining reads local
disk), so step-1 replicas may mine locally now — unstamped drawers are
untouchable by reconciliation — and step 2 commits to a one-time promotion
pass turning them into drawer.add ops under the replica's identity.
Content-pure v4 ids double as the cross-machine dedup: identical content
mined on two origins collapses to one drawer at merge.

(cherry picked from commit d18b2a03519a1cfbe9e10bbf12c902c307195c15)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 0b226029bc feat(replica): RFC 004 step 1 — memory read replicas (snapshot pull + local fold)
Recall becomes local on every machine. The hub serves the palace's FACTS
over GET /snapshot/manifest|drawers|ids|kg (bearer, paged): verbatim drawer
documents + metadata and raw KG rows — never the vector index, which each
replica derives with its own embedder (RFC 004 layer 3: sync the facts,
derive the senses).

replica_sync.py folds pulls idempotently: drawer upserts by id with ONE
additive provenance key (replica_origin = origin replica id), which scopes
delete reconciliation strictly to copies from that origin — locally-authored
drawers are untouchable. KG rows fold INSERT OR REPLACE by id, so
invalidations converge on re-pull; replication never deletes KG rows.
KnowledgeGraph gains dump_rows/apply_row for rowid-paged replication.

CLI: mempalace replica pull [--peer URL --token T] [--no-reconcile]
[--json]; defaults to peers.json. Step-1 boundary is explicit: writes still
belong to the origin (multi-writer memory is step 3); freshness is re-pull
until the step-2 op-log gives precise tails.

10 new tests over the production HTTP server with the sqlite_exact backend:
endpoint pagination, full fold + local search of pulled facts, idempotent
re-pull, reconcile that deletes origin copies but spares local drawers, KG
invalidation convergence, CLI. Full suite 3443 green.

(cherry picked from commit ebd772b00f73c35e571df2c2caebc6177853bb4a)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva f7c174e5d4 feat(logsync): RFC 004 step 0 — logstream multi-master replication
The pilot of the replicated palace: per-origin append-only op sync between
peer replicas, pull-based anti-entropy, no coordinator, no new deps.

Core: replica identity minted per palace (replica.json, upgradeable to the
mesh identity later via alias); hybrid logical clock (fixed-width sortable
stamps, restart-seeded from the log, absorbs remote instants); events gain
origin_replica/origin_seq/hlc — additive to the viewer compat surface, with
seq remaining the LOCAL arrival cursor per the RFC. Pre-replication logs
migrate in place: existing rows backfill as authored by this replica with
their original order preserved.

Sync: version vectors diff into per-origin pull ranges; artifacts fold
before the events that reference them (never a dangling id) with sha256
verified on receive; every apply is idempotent, so crash mid-round just
re-pulls the tail. Hub serves GET /sync/version_vector|ops|artifact
(bearer-authenticated, lock-free) and runs an optional background
anti-entropy loop when peers.json exists (MEMPALACE_SYNC_INTERVAL, default
15s). CLI: mempalace logstream sync [--peer URL --token T] [--json].

22 new tests: HLC ordering/regression/observe, identity mint, migration
backfill, idempotent apply + hash rejection + verbatim copy, bidirectional
two-replica convergence, partition with duplicate claims resolving
deterministically by HLC (R3), endpoints + CLI over real HTTP. Full suite
3433 green.

(cherry picked from commit 340b529e7c6a087adab190023e72230c8311f878)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva d5d62253ae docs(rfc): RFC 004 — transport, security, and PalaceMind sections; draft complete
Transport/lifecycle sections (§5), transport-side security (§10.1, §10.3),
and the PalaceMind appendix authored by windows-claude and delivered as a
patch artifact over the logstream (correlation
rfc004_replicated_palace_position, artifact art_20260702T081251_76aef8fb111a,
sha256 verified, applied at ab6f362). §10.2 at-rest encryption drafted by
mac-claude on apply: per-replica data keys that never traverse the mesh,
two-tier design (FDE baseline + SQLCipher-style page encryption over the
canonical stores, OS-keystore wrapped), replica-local rotation, R7
composition with the revocation ceremony.

The seam contract (§5.1) verifies against the Layer 2 design: request/stream
map to anti-entropy pull + SSE push-notify, and ReplicaId doubles as
origin_replica so provenance and authentication are one fact. RFC 004 is now
fully drafted and awaiting Igor's review.

(cherry picked from commit 9f3333998673fb196664325396c104036f771d1f)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva dd97116417 docs(rfc): RFC 004 — the replicated palace (skeleton + storage sections)
One logical palace per human, fully replicated per machine: agents always
local, services converge via provenance-stamped ops over an encrypted
mesh. Judged against offline as the default posture (R0-R8).

Storage sections drafted (mac-claude ownership per the logstream debate on
correlation rfc004_replicated_palace_position): canonical op-log with HLC +
per-origin version vectors, complete merge-semantics table, v4 content-pure
id migration (verified mutable-state inventory with file refs), backends
demoted to fold-and-index consumers, provenance/source-bound maintenance,
4-step sequencing starting with logstream multi-master. Transport (§5),
transport-side security (§10), and the PalaceMind appendix are reserved
stubs for windows-claude, delivered as patch artifacts to keep a single
committer per repo.

(cherry picked from commit ab6f362af7a2ce947c2c37c11910fa8aeb6b983d)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva d973e4405c feat(logstream): phase 5 — SSE stream + lock-free hub dispatch (RFC 003)
GET /logstream/stream on the hub HTTP transport: bearer-authenticated
Server-Sent Events with the event_list filter set, since_event_id /
Last-Event-ID replay-then-tail resume (live tail only without a cursor,
via new Logstream.latest_event_id), ~15s heartbeat comments, and a
bounded client pool (MEMPALACE_SSE_MAX_CLIENTS, default 8; 503 +
Retry-After on cap). Frames carry the exact event_list JSON envelope —
the compat surface promised to the PalaceMind desktop viewer.

Also fixes a latent hub-starvation hazard the first SSE consumer would
have tripped: do_POST serialized every request under the global HTTP
lock, so one agent's five-minute event_wait long-poll blocked the whole
fleet. Logstream tools (own WAL db, no Chroma/KG state) now dispatch
lock-free via _http_dispatch; everything else keeps the global lock.

Requested by claude-fable-5-windows for the PalaceMind live viewer
(correlation palacemind_desktop_logstream_viewer). Full suite: 3411
passed.

(cherry picked from commit a7c720507974acc2f02957ac24b7045503ad74da)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 5ab0a517b2 test(hub-forward): make registry/proxy tests Windows-portable
Patch authored by windows-codex via logstream (task_win_hubforward_fix,
artifact art_20260702T044935_18f200c38ece, sha verified). Root causes
were test portability assumptions, not product bugs: the 0o600 mode
assertion is POSIX-only (Windows chmod reports 0o666 — now guarded), and
the _disown_record helper stamped PID 1 as a 'live foreign process',
which is dead on Windows, so read_live_serverinfo correctly rejected the
record and the proxy fell back local. Now uses the live parent PID with
liveness asserts. Verified 30/30 on Windows 11 (by author) and 30/30 on
macOS (this apply).

(cherry picked from commit 4006113dd6aa89448d898146b21ba01bbf367f80)
2026-08-05 23:50:19 -03:00
Igor Lins e Silva 3599ba7f4f docs(guide): shared-brain — fold in Windows remote-agent lessons
From windows-codex's full-suite verification reply over the logstream:
debug hub connections with curl before blaming agent config, client tool
caches masquerade as hub problems, the event body is the work order, and
cross-platform worker gotchas (CRLF, quoting, platform-guarded tests).

(cherry picked from commit c843d396cc915aa90f5392fd08cbfa1b91483cac)
2026-08-05 23:50:18 -03:00
Igor Lins e Silva cc62e770b1 docs(guide): Shared Brain — running one palace for a whole agent fleet
Written by a multi-agent council (three drafts from tutorial/architecture/
operations angles, three judges on accuracy/fit/usefulness, one synthesis;
tutorial angle won) and finished with lived input from the fleet itself:
Hermes contributed the desktop-assistant role guidance and Windows Codex
the inbox/watcher etiquette, both delivered over the logstream this page
documents. Covers hub topology, local stdio auto-proxy, remote access,
agent identity, the canonical system-prompt snippet, a full worked
delegation loop with hash verification, hard rules, fleet roles, and
operations (statusz, read-only observers, upgrades via hub restart).

(cherry picked from commit 4e914729ff9eb3bc8dacb81de55de2c5a8982205)
2026-08-05 23:50:18 -03:00
Igor Lins e Silva 5325e0b9a3 feat(logstream): event_wait accepts limit, matching event_list filters
Reported by windows-codex through the logstream itself: wait rejected
'limit' with -32602 while list accepts it, though the filter sets are
otherwise identical. The core already threaded limit through; expose it
on the MCP schema and the CLI (wait --limit).

(cherry picked from commit bc18ccaac6ec709dbb0821f65c3a8e39f68733d1)
2026-08-05 23:50:18 -03:00
Igor Lins e Silva a50677d0e7 feat(logstream): warn at store time on unappliable kind=patch artifacts
Found in the first RFC 003 dogfood: windows-codex stored a patch without
its final trailing newline and git apply rejected the diff as corrupt on
the receiving side. Content stays verbatim — put_artifact/submit_patch
now return an advisory 'warnings' list (missing trailing newline, CRLF
line endings) so the producer can fix the handoff while it still owns
the diff. CLI 'artifact put' mirrors warnings to stderr.

(cherry picked from commit 7c0c9e1362cf20fabc72d61669fe2ea6125f4e3a)
2026-08-05 23:50:18 -03:00
Igor Lins e Silva a9f19a2f7b docs(rfc): mark RFC 003 implemented — first patch delivered via logstream
Patch authored by windows-codex and handed off through the coordination
layer itself: correlation_id task_logstream_e2e_dogfood, patch artifact
art_20260702T041319_cdf92e302efe (sha256 a17012d6...9f64c4, verified),
test log artifact art_20260702T041254_c46525e2d550 (72 passed on
Windows 11, Python 3.12.11). Applied on mac, re-verified (72 passed),
acked applied. Trailing-newline nit in the submitted diff repaired on
apply (producer stripped the final newline when storing the artifact).

(cherry picked from commit 38f3d35804155840cd5512411e9823fb38dc7e95)
2026-08-05 23:50:18 -03:00
Igor Lins e Silva 77c8998174 docs(logstream): concept page, tool/CLI references, coordination protocol (RFC 003 phase 4)
- website/concepts/agent-logstream.md: event/artifact model, delegation
  loop, coordination-vs-memory table; added to the sidebar.
- website/reference/mcp-tools.md: schemas for the seven logstream tools;
  tool count 35 -> 42 (README updated to match).
- website/reference/cli.md: mempalace logstream / mempalace artifact.
- website/guide/remote-server.md: hub doubles as agent coordination bus.
- integrations/shared/coordination-protocol.md: canonical shared-brain
  protocol (identity, delegation loop, hard rules) with a copy-paste
  system-prompt snippet; linked from recall-protocol.md and the
  mempalace-recall skill so recall and coordination never blur.

(cherry picked from commit 1ff312511aef39abbe4638bdc23301266f87dbe6)
2026-08-05 23:50:18 -03:00
Igor Lins e Silva 62df5fee0e feat(cli): logstream and artifact commands (RFC 003 phase 3)
mempalace logstream append/list/wait/ack and mempalace artifact put/get.
Operates directly on the palace logstream.sqlite3 (WAL; safe alongside a
running hub — no Chroma state involved). --json everywhere for scripting;
wait exits 2 on timeout; artifact get prints exact content on stdout so
'mempalace artifact get ID | git apply' works; put reads stdin by default.

(cherry picked from commit 54ca9600e57770fa1e9b46565816fa6ceed8221d)
2026-08-05 23:49:40 -03:00
Igor Lins e Silva 4ab27169c6 feat(logstream): RFC 003 agent coordination logstream, phases 1-2
Durable append-only event + artifact layer in logstream.sqlite3 inside
the active palace dir, so agents can delegate work, wait for replies,
and exchange exact patches through the shared hub.

Phase 1 (mempalace/logstream.py): events, artifacts, event_artifacts
per the RFC schema; WAL + per-instance lock, no Chroma dependency;
append/list/wait/ack/put_artifact/get_artifact/submit_patch; explicit
size limits (body 256 KiB, artifact 4 MiB, metadata 64 KiB); rowid-backed
exclusive since_event_id cursor exposed as seq; inclusive
since_created_at; to_agent filters match '*' broadcasts; artifact_ids
validated in-transaction so listed events never dangle.

Phase 2 (mcp_server.py): seven MCP tools. Mutating four join
_MUTATING_TOOLS (hidden + refused in --read-only). All seven are exempt
from the Chroma SQLite integrity gate; the mutating four are exempt from
the peer-writer lease via _PEER_WRITER_EXEMPT_TOOLS (logstream owns its
own WAL db, and a running mine must not block coordination).
mempalace_reconnect drains cached Logstream handles like the KG cache.

(cherry picked from commit cdf72a994133242da862b11b21033a4546c5e146)
2026-08-05 23:49:40 -03:00
Igor Lins e Silva 3bed756b6a feat(serve): add authenticated /statusz operational endpoint
Machine-readable JSON server state on the MCP HTTP transport: version,
uptime, request counters by status, SQLite integrity, writer mode, and
recently observed clients (peer, user-agent, last path). Follows the
bearer-token policy since it exposes operational metadata; /healthz
remains the unauthenticated liveness probe.

(cherry picked from commit 0fb07f2645c0c853337419175f499daab8063888)
2026-08-05 23:49:23 -03:00
Igor Lins e Silva 3eee0d7e49 feat(serve): allowlist fronting-proxy hostnames on loopback binds
The documented "terminate TLS at a proxy" pattern was unusable with a
loopback bind: proxies like `tailscale serve` and nginx preserve the
public name in the Host header, and the loopback bind's DNS-rebinding
pin rejects anything but loopback literals — so the safest deployment
(hub on 127.0.0.1, tailnet-only HTTPS in front, nothing on the LAN)
got 403s on every request.

MEMPALACE_MCP_EXTRA_ALLOWED_HOSTS (comma-separated host or host:port,
matched exactly after lowercasing; bare hostnames also match with the
bound port) extends the pin for exactly that operator. The default pin
is unchanged and rebinding protection stays on.

Verified live behind `tailscale serve --bg --https=443`: tailnet HTTPS
with bearer token 200, no token 401, forged Host 403, LAN interface
connection-refused.

Documented the tailnet recipe in the remote-server guide.

(cherry picked from commit 2668053ee50b8cd70044d357501dafaa9a9671cc)
2026-08-05 23:49:23 -03:00
Igor Lins e Silva c16332f68f feat(mcp): stdio servers proxy to a live palace hub
With a hub owning the palace, a stdio mempalace-mcp spawned by an agent
harness could only ever be a read-only peer (writer lease #1818) — and
harnesses that hardcode stdio in plugin manifests (Claude Code plugins,
desktop apps) cannot be pointed at HTTP per-install. Racing stdio
writers are also exactly what corrupted a real palace's FTS5 index
(repaired 2026-07-01 via the isolated in-place rebuild).

Now the stdio loop checks the server registry per request: when a live
hub serves this palace, the request is forwarded verbatim over HTTP
with the local bearer token; the response streams back over stdio. The
stdio process holds no Chroma handles, so any number of harnesses can
spawn it against a shared palace safely.

Semantics:
- Per-request discovery: a hub that starts, moves ports, or dies
  mid-session is picked up without restarting the stdio process.
- No hub → local handling, exactly as before (and the resilience
  fallback if the hub is briefly down).
- Unreachable hub: read requests fall back locally; mutating tool
  calls are NOT replayed (the hub may still be executing them) and
  surface as a JSON-RPC error with a hint.
- The hub never proxies to itself (pid guard); MEMPALACE_HUB_FORWARD=0
  disables proxying, same kill switch as the CLI mine forwarder.

Verified live: `mempalace-mcp` under a running launchd hub logs "Live
palace hub detected ... proxying stdio requests" and serves initialize
+ search through the hub against the real palace.

3318 tests pass (7 new proxy tests), ruff clean.

(cherry picked from commit 83cd097595b205f148b474f78fc511fbede5f9b8)
2026-08-05 23:48:43 -03:00
Igor Lins e Silva 67a5fbe795 feat(serve): route CLI/hook mines through a live palace hub
A long-lived HTTP hub (mempalace serve) holds the MCP writer lease for
its whole lifetime (#1818), so every short-lived writer — including the
save hooks, which spawn `mempalace mine` — was refused with
LockHeldByOtherProcess while a hub was up. On a hub machine that meant
automatic transcript capture silently stopped: the exact forgetting
MemPalace exists to prevent.

Fix: the hub is discoverable, and mines are handed to it.

- mempalace/server_registry.py (new): the HTTP transport records
  {pid, host, port, scheme, read_only} as serverinfo.json next to the
  per-palace bearer token under ~/.mempalace/server/<key>/; records are
  trusted only while the pid is alive, wildcard binds are dialed via
  loopback, and cleanup is pid-guarded so an old hub's atexit cannot
  delete a newer hub's record. cli._server_token_path delegates here so
  token and serverinfo share one directory convention.
- mcp_server._serve_http: writes the record after bind, clears it on
  shutdown (finally + atexit).
- cli.cmd_mine: when a live non-read-only hub serves the target palace,
  forward the mine as an HTTP MCP mempalace_mine call (bearer token from
  the 0600 token file when present). Only hub-expressible mines forward;
  flags the tool has no parameters for (--kg-extract, --no-gitignore,
  --include-ignored, --max-chunks-per-file, --redetect-origin, explicit
  --backend) keep the direct path. Fallback to direct happens only
  before the hub accepts the job — after that, failures exit non-zero
  instead of re-mining (no double-ingest). MEMPALACE_HUB_FORWARD=0
  disables forwarding.

Verified live: with a serve hub holding the writer lease, the previously
refused `mempalace mine --mode convos` now prints "forwarding mine to
palace hub http://127.0.0.1:8972" and files drawers through the hub.

3311 tests pass (23 new in tests/test_hub_forward.py), ruff clean.

(cherry picked from commit 010b1927a074c3896fcca54d60eab9cac5e66431)
2026-08-05 23:47:58 -03:00
Joshua Fontany e64aa92f0d test(chroma): assert the HNSW defaults rather than a threshold arithmetic
test_hnsw_write_defaults_bound_index_rewrites divided a hypothetical
corpus size by the configured sync_threshold and read the quotient as a
count of full on-disk index rewrites. It measured no writes, no
compactions, no persistence calls and no rewritten bytes, so it asserted
arithmetic on a constant rather than a property of the implementation.
The metadata tests already cover the defaults, and a write-amplification
regression wants I/O measurement under the benchmark suite.

Two neighbouring docstrings carried the same reasoning in prose and now
state what holds: the values are chromadb's own documented defaults, so a
collection this backend creates indexes on the same terms as one chromadb
creates itself, and sync_threshold=2 reads as a value chromadb's own
parameter validation rejects.

The threshold relationship now reads batch_size <= sync_threshold, which
is what chromadb permits.

_hnsw_creation_metadata(options) reads hnsw_space directly, so
get_collection's local copy of that resolution had no reader; removing it
leaves the option's path to a created collection unchanged.

tests/test_backends.py: 91 passed. Backend/palace selection: 593 passed,
16 skipped.
2026-08-05 10:27:05 -07:00
raman325 449d6d86e5 ci: retrigger flaky windows hermes integration test 2026-08-05 12:35:41 -04:00
Grace Gettert b6fad9ed3a fix(mine): safely gate source adapter dry runs and incremental protocol 2026-08-05 16:06:00 +00:00
raman325 2f000ad4b4 Merge branch 'develop' into feat/hermes-provider-core
* develop: (45 commits)
  ci: retry transient Chroma reader initialization failure
  fix(encoding): recover undefined CP1252 continuation bytes
  fix(encoding): make repair conservative and reversible
  fix(windows): add legacy encoding repair tool
  feat(searcher): wire i18n stop words into BM25 tokenizer (#973)
  test: expect dry_run=False on rebuild-index alias call
  fix(repair): honor --dry-run for repair --mode from-sqlite
  fix(hooks): ingest only the active transcript
  fix(embedding): remap unsupported EmbeddingGemma token IDs
  fix(convos): honor mined state during dry runs
  fix: harden release polish for bot findings and search errors
  chore(release): 3.7.0
  fix: reopen immutable readers and clear identity on promote
  fix: address backend ownership edge cases
  fix: serialize SQLite writes before palace lease
  fix: take mine-lock before archive in repair --mode from-sqlite
  fix(chroma): adopt chromadb's own HNSW write defaults
  fix: retry transient MCP ownership failures
  fix: address MCP ownership review feedback
  fix: address single-writer review feedback
  ...

# Conflicts:
#	mempalace/convo_miner.py
#	tests/test_convo_miner.py
2026-08-05 11:37:54 -04:00
Evgen197310andClaude Opus 5 d80a1f43d1 fix(mcp): refuse vector writes into a diverged HNSW index instead of hanging
The #1222 capacity probe guards reads only: search and check_duplicate fall
back to BM25 when the flushed HNSW segment lags sqlite. Writes went straight
to chromadb — and an upsert into that index does not fail, it blocks inside
chromadb's Rust upsert with no timeout of its own, for the life of the
process, while this server holds the dispatch lock, the palace mine lock and
the writer lease. Peer sessions drop to read-only behind a server whose
handshake still answers, and nothing on the server side says why: the only
trace of a 25-minute outage was the client's own "tool still running" ticks,
and mempalace_status could not answer because the stuck call held the
dispatch lock. This is the #1845 / #1908 symptom.

Measured on a 3.7.0 palace whose flushed segment held 803 of 820 embeddings
(py-spy: MainThread parked in chromadb/api/rust.py:_upsert, 64 idle
tokio-rt-workers, no fcntl lock on chroma.sqlite3 — an internal wait, not
lock contention): on copies of that palace, three of five freshly generated
vectors blocked forever, the other two committed in 0.03s, and each vector
reproduced its own verdict on retry. A write's fate depended on where its
embedding landed in the damaged graph. After
`mempalace repair rebuild-index`, all five committed in ~0.2s.

Two guardrails, both Tier 1 of #1963:

- _mcp_diverged_index_refusal: dispatch refuses the vector-write tools while
  the probe reports divergence, and puts the rebuild command in the error
  payload. Narrower than _MUTATING_TOOLS on purpose — the knowledge-graph
  and tunnel/hallway tools keep their own state and never reach the segment,
  so they must not even pay for the probe. Refusing is also the honest answer
  for the write that does *not* hang: chromadb acknowledges it into sqlite
  and the metadata segment, then leaves it out of HNSW, so the drawer reports
  success while being invisible to vector search. In the same incident 16
  such drawers came out of one checkpoint that returned "completed
  successfully in 2s". The probe is pure sqlite + pickle, so the gate costs
  no chromadb interaction on the path it protects, and it re-runs per call so
  a repair in another process un-gates this server without a restart.

- a write-stall watchdog: MEMPALACE_MCP_WRITE_STALL_WARN_SECS (default 60,
  0 disables) warns on stderr — where the MCP host records it — from a
  thread the stuck call is not blocking. MEMPALACE_MCP_WRITE_STALL_EXIT_SECS
  (default 0, opt-in) lets an operator turn the wedge into a restartable
  failure: a server stuck inside chromadb never recovers, and exiting is what
  releases the palace locks its peers are queued behind. Exit code 75
  (EX_TEMPFAIL) so it does not read as an orderly shutdown.

Refs #1845, #1908, #1963

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:03:39 +03:00
Igor Lins e Silva 1d113e46a2 Merge pull request #2148 from fatkobra/fix/1055-repair-legacy-encoding
fix(windows): safely repair legacy mojibake with backups
2026-08-03 20:48:52 -03:00
Igor Lins e Silva 047d7333df ci: retry transient Chroma reader initialization failure 2026-08-03 20:25:07 -03:00
Sandro da Silva 67dcaf3164 fix(encoding): recover undefined CP1252 continuation bytes 2026-08-03 20:05:40 +00:00
Sandro da Silva 44977537e5 fix(encoding): make repair conservative and reversible 2026-08-03 15:47:59 +00:00
mvalentsev a3350ec567 fix(convo-miner): tighten subagent filter per pre-undraft review
- Case-insensitive directory match (d.lower() == 'subagents') so
  the filter still kicks in if Claude Code or a plugin ever emits
  'Subagents/' on case-preserving filesystems (Windows, macOS APFS).
- Drop defensive getattr in cmd_mine: argparse always defines the
  attribute since --include-subagents is unconditionally registered.
  Direct args.include_subagents access matches every neighbouring
  field and fails loudly if the registration is ever removed.
- Soften CLI help and docstring: drop the 80:1 ratio (reporter-
  specific) and the in-code (#1217) reference. Add explicit
  default=False on the argparse flag for symmetry with --extract.
- Add 2 negative tests: 'mysubagents'/'subagentsbackup' must still
  be mined (regression guard against substring-match), and
  'Subagents/' must be skipped (case-insensitive coverage).
2026-08-03 15:23:45 +05:00
mvalentsev 5f425ba417 fix(convo-miner): skip Claude Code subagent transcripts by default (#1217)
scan_convos() now prunes any directory named 'subagents' during os.walk.
Claude Code records Explore/Plan/Grep subagent transcripts in
<session-uuid>/subagents/agent-*.jsonl and on a typical workspace these
outweigh main session files ~80:1, dominating mining time and producing
near-zero additional signal (the parent session already summarizes them).

Adds a --include-subagents opt-in flag for users who want full history.
The shared SKIP_DIRS set in palace.py is left untouched, so project
mining (miner.scan_project) still descends into legitimate user-created
subagents/ directories in code projects.
2026-08-03 15:23:44 +05:00
Sandro da Silva c56f8b30bb fix(windows): add legacy encoding repair tool 2026-08-03 09:56:02 +00:00
Igor Lins e Silva 511ead9a65 Merge pull request #977 from mvalentsev/feat/searcher-i18n-stopwords
feat(searcher): wire i18n stop words into BM25 tokenizer (#973)
2026-08-03 01:11:55 -03:00
Igor Lins e Silva 3c46c8dc37 Merge pull request #2140 from MemPalace/fix/2137-onto-develop
fix(hooks): ingest only the active transcript (#2137)
2026-08-03 01:05:21 -03:00
Igor Lins e Silva 5e1437e96d merge: resolve #2137 onto develop (keep dry-run + single-file tests) 2026-08-03 00:54:03 -03:00
Igor Lins e Silva efa1820289 Merge pull request #2136 from fatkobra/fix/embeddinggemma-image-soft-token
fix(embedding): remap unsupported EmbeddingGemma token IDs
2026-08-03 00:52:56 -03:00
Igor Lins e Silva a4fc26eed3 Merge pull request #2134 from fatkobra/fix/2118-convo-dry-run-mined-state
fix(convos): honor mined state during dry runs
2026-08-03 00:52:51 -03:00
Igor Lins e Silva 69dc7fbb78 Merge pull request #2138 from MemPalace/fix/2133-from-sqlite-dry-run
fix(repair): honor --dry-run for repair --mode from-sqlite (#2133)
2026-08-02 18:47:55 -03:00
mvalentsev a5bf4aa9ff feat(searcher): wire i18n stop words into BM25 tokenizer (#973) 2026-08-03 02:38:46 +05:00
Igor Lins e Silva 98647f2e71 test: expect dry_run=False on rebuild-index alias call 2026-08-02 18:37:53 -03:00
Igor Lins e Silva d1ff13856a fix(repair): honor --dry-run for repair --mode from-sqlite
from-sqlite ignored --dry-run and performed the real archive+rebuild
(#2095, #2133). Preview after source validation, skip the destructive
confirm, never take the mine-lock or rename the palace, and fail closed
when SQLite row counts are unreadable instead of inventing zeros.
2026-08-02 18:31:44 -03:00
Sandro da Silva d52d5120de fix(hooks): ingest only the active transcript 2026-08-02 19:21:35 +00:00
Sandro da Silva bbcb86df75 fix(embedding): remap unsupported EmbeddingGemma token IDs 2026-08-02 18:19:31 +00:00
Sandro da Silva bd94573d04 fix(sweep): collapse repeated message UUIDs before upsert 2026-08-02 17:48:22 +00:00
Sandro da Silva 5cebe6608a fix(convos): honor mined state during dry runs 2026-08-02 17:12:25 +00:00
Igor Lins e Silva a337cafeaf Merge pull request #2130 from MemPalace/fix/release-3.7.0-polish
fix: release 3.7.0 polish — WAL reopen, search errors, hash stamp
2026-08-02 07:52:25 -03:00
Igor Lins e Silva ee03d65a52 fix: harden release polish for bot findings and search errors
Address actionable #2129 bot feedback and the Windows closet KeyError:

- Reopen immutable sqlite_exact readers only when both WAL sidecars exist
  (partial pair keeps the clean snapshot instead of failing the reconnect).
- Retry get_collection without options when plugin backends reject the kwarg.
- Stamp multi-conversation content_hash only on chunk 0 to avoid O(N²) meta.
- Always include results: [] on search error envelopes so callers never KeyError.
- Clearer hybrid-search assertions in the closet isolation test.
2026-08-02 07:43:14 -03:00
Igor Lins e Silva df273c010a Merge pull request #2128 from MemPalace/chore/release-3.7.0
chore(release): 3.7.0
2026-08-02 07:14:25 -03:00
Igor Lins e Silva e1fc71a9b9 chore(release): 3.7.0
Bump package, plugins, lock, and README badge to 3.7.0. Promote the
post-3.6.0 integrity spine into CHANGELOG: single-writer ownership,
HNSW write defaults and preflight, safer repair/re-mine, MCP/daemon
lifecycle hardening, entity ReDoS guard, and hook write-routing.

Also ruff-format two test files that drifted during conflict merges.

Local validation: ruff check/format clean; 3497 passed, 31 skipped.
2026-08-02 05:34:29 -03:00
Igor Lins e Silva f2e9fef357 Merge pull request #2127 from MemPalace/fix/2065-onto-develop
fix(entity): defuse entity-candidate ReDoS on long ASCII runs (#2065)
2026-08-02 05:27:08 -03:00
Igor Lins e Silva 6ec20305a4 Merge pull request #2126 from MemPalace/fix/2103-onto-develop
fix(mcp): refuse config and ack writes in read-only mode (#2103)
2026-08-02 05:27:04 -03:00
Igor Lins e Silva 4009e75b5d merge: resolve #2065 onto develop (union palace test imports) 2026-08-02 05:26:36 -03:00
Igor Lins e Silva 217de20d04 merge: resolve #2103 onto develop (keep lease + RO gate tests) 2026-08-02 05:26:36 -03:00
Igor Lins e Silva a929e17499 Merge pull request #2029 from mvalentsev/fix/2014-daemon-lock-contention-retry
fix(daemon): defer jobs refused the palace lock, don't fail them (#2014)
2026-08-02 05:26:03 -03:00
Igor Lins e Silva aa47be1c5e Merge pull request #2072 from xcarbo/contrib/stdin-eof-self-exit
fix(mcp): self-exit on stdin EOF/broken pipe so orphaned stdio sessions release locks
2026-08-02 05:25:57 -03:00
Igor Lins e Silva 2accb56492 Merge pull request #2125 from MemPalace/fix/2089-onto-develop
fix(convo_miner): stop sweeper drawers being purged (#2089)
2026-08-02 05:25:40 -03:00
Igor Lins e Silva c57b1cdf9d Merge pull request #2090 from fatkobra/fix/2080-round-trippable-drawer-id
fix(search): return round-trippable drawer IDs
2026-08-02 05:25:35 -03:00
Igor Lins e Silva 31bc2f8ae6 Merge pull request #2050 from rohanprichard/fix/issue-2044-dedup-convo-miner-drawers
fix: prevent duplicate drawers when re-mining LLM conversations
2026-08-02 05:25:29 -03:00
Igor Lins e Silva af0c7665e0 Merge branch 'pr-2089' into fix/2089-onto-develop 2026-08-02 05:25:13 -03:00
Igor Lins e Silva 25e562afe2 Merge pull request #2086 from KeilerHirsch/fix/repair-preserve-temp-on-swap-fail
fix(repair): preserve verified temp copy on failed rebuild swap; fail loud on truncated pagination
2026-08-02 05:24:32 -03:00
Igor Lins e Silva cf2ebce7ef Merge pull request #2087 from KeilerHirsch/fix/repair-sparse-embedding-metadata
fix(repair): stop dropping drawers with zero embedding_metadata rows
2026-08-02 05:24:27 -03:00
Igor Lins e Silva 07148142ae Merge pull request #2093 from KeilerHirsch/fix/preflight-count-divergence
fix: preflight HNSW divergence before every col.count() call site the audit found
2026-08-02 05:24:02 -03:00
Igor Lins e Silva 55d23f10b7 Merge pull request #2107 from amorphous-dreams/fix/2106-hnsw-guard-corrupts-what-it-protects
fix(chroma): adopt chromadb's own HNSW write defaults
2026-08-02 05:23:55 -03:00
Igor Lins e Silva 3c80445eb2 Merge pull request #2109 from amorphous-dreams/fix/2108-repair-from-sqlite-mine-lock
fix(repair): take the mine-lock before archiving in repair --mode from-sqlite
2026-08-02 05:23:50 -03:00
Igor Lins e Silva cbb6e7f9e7 Merge pull request #2079 from MemPalace/codex/fix-local-single-writer
fix(storage): enforce local single-writer ownership
2026-08-02 05:22:10 -03:00
Igor Lins e Silva 9b0a88e136 fix: reopen immutable readers and clear identity on promote
Address the last two Codex review comments on #2079:

- Reopen sqlite_exact immutable read-only connections when WAL/SHM
  sidecars appear after a clean-database open so recall sees a later
  writer's commits instead of a frozen pre-writer snapshot.
- Clear per-palace embedder-identity validation when MCP discards
  storage handles during ownership promotion, so an empty read-only
  open cannot skip recording the active model on the first write.
2026-08-02 00:58:26 -03:00
Igor Lins e Silva 524c980a17 fix: address backend ownership edge cases 2026-08-01 20:23:53 -03:00
Igor Lins e Silva 8a112a88ef fix: serialize SQLite writes before palace lease 2026-08-01 19:56:31 -03:00
Joshua FontanyandClaude Opus 4.8 7b495035d3 fix: take mine-lock before archive in repair --mode from-sqlite
rebuild_from_sqlite renamed the existing palace aside
(palace.pre-rebuild-<ts>) and only hit the single-writer
mine_palace_lock later, when the first chromadb upsert ran. A palace
held by a live MCP server / daemon was therefore stranded: archived,
no rebuilt replacement, a partial dest left behind.

Acquire mine_palace_lock(dest_palace) BEFORE the archive/rename so
contention raises MineAlreadyRunning while the palace is untouched
(no archive, no partial). The lock wraps the whole rebuild as one
writer; the inner per-batch acquires pass through re-entrantly.

The body is split into _rebuild_from_sqlite_locked so the lock wraps
every destructive step.

Behaviour: held lock during repair from-sqlite — before: palace
archived + partial dest stranded; after: clean MineAlreadyRunning,
palace untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 23:39:42 -07:00
Joshua Fontany 72bbb0ab50 fix(chroma): adopt chromadb's own HNSW write defaults
`_HNSW_BLOAT_GUARD` pinned `hnsw:batch_size=2` / `hnsw:sync_threshold=2` on every
collection this backend creates, to answer #1579. Three findings on chromadb 1.5.9
(PersistentClient, Rust bindings, single writer, zero concurrency) retire it.

**2 sits outside chroma's own declared valid range.** `hnsw_params.py:21-22`
validates both knobs as `isinstance(p, int) and p > 2`.

**The cost is write amplification, measured.** Bytes written (`/proc/self/io`) for
one mine of N records, 64-dim, `num_threads=1`, identical corpus and seed:

    N          2/2        100/1000    ratio
    10,000      47.1 MB    28.1 MB    1.67x
    20,000     127.4 MB    60.0 MB    2.12x
    40,000     390.3 MB   134.0 MB    2.91x

Two runs at N=20,000 reproduced within 0.1%. `sync_threshold` dominates — 3/3
measured identical to 2/2, and 1000/1000 identical to 100/1000, so `batch_size`
barely registers. The ratio grows with collection size, so the largest palaces pay
the most.

**It buys nothing.** A 5-record collection at 100/1000 — far below the threshold,
so no persist ever fires — reads back whole from a fresh process: `count()` and the
vector query both answer. On that artifact `link_lists.bin` is 0 bytes with no
`index_metadata.pickle`, which is #1579's trigger shape exactly, and
`quarantine_stale_hnsw()` creates no drift directory.

The knobs move into `_hnsw_creation_metadata()`, which both `create_collection`
paths now call, so per-collection tuning has one home.

Legacy `metadata=` is kept deliberately. Measured on 1.5.9: creating with
`configuration=` leaves `collection.metadata` as `None` and the sqlite
`collection_metadata` table empty, which blinds `_read_sync_threshold` and
`ChromaCollection.distance_metric` — both read that table. (`collection.configuration`
does read back under both paths; migrating those two readers to it is the
prerequisite for ever moving the writer, and belongs in its own change.)

Not claimed: that a small `sync_threshold` is a known chroma failure mode. No such
report was found in chroma's issues or docs, and chroma's own guidance runs the
other way. The Python persist path that #1579 and chroma#6975 describe does not
execute under the Rust bindings — `hnswlib` is not a dependency of this line.
2026-07-31 18:01:20 -07:00
Igor Lins e Silva b95d14fb3f fix: retry transient MCP ownership failures 2026-07-31 05:12:56 -03:00
Igor Lins e Silva 52d5128d09 fix: address MCP ownership review feedback 2026-07-30 23:18:58 -03:00
Igor Lins e Silva c6e87831bb fix: address single-writer review feedback 2026-07-30 22:25:05 -03:00
KeilerHirsch 9de876d849 fix(repair): raise on double-failure pagination and skip global count for filtered listings
Addresses fatkobra's CHANGES_REQUESTED review on this PR. The prior
truncation-detection fix in _paginate_ids left two loud-failure gaps:

1. When BOTH the offset get() and its no-offset fallback failed, the
   `except Exception: break` returned the partial (or empty) ids
   collected so far as if the listing were complete. On a mid-run
   failure that hands scan_palace/rebuild a truncated palace; on the
   first page it returns [] and scan_palace prints "Nothing to scan."
   for a palace it never read. Now raises RuntimeError (chained from
   the underlying error) instead of breaking.

2. The count()-based truncation disambiguation used the collection-wide
   col.count(), but _paginate_ids accepts a where= filter (scan_palace
   passes {"wing": only_wing}). A global count greater than the
   collected rows does not prove a *filtered* result is truncated -- the
   extra rows may belong to other wings. count() takes no where=
   argument, so the raise is now gated on `where is None`; filtered
   completeness is treated as unknown rather than false-positive-raised.

Adds three regression tests covering the after-progress double failure,
the first-page double failure, and the exactly-page-size filtered case.
2026-07-29 20:22:07 +02:00
mvalentsev bd439ae54f fix(mcp): refuse config and ack writes in read-only mode (#2101)
Read-only gated on _MUTATING_TOOLS, which is the palace-write set the
peer-writer lease consults to decide which calls need the palace mine
lock. Two tools change state without touching the palace, so they are
correctly absent from that set and were served on a --read-only server
anyway: mempalace_hook_settings rewrites ~/.mempalace/config.json via
MempalaceConfig.set_hook_setting, and mempalace_memories_filed_away
unlinks ~/.mempalace/hook_state/last_checkpoint on both branches.

Add _READ_ONLY_REFUSED_TOOLS and point the dispatch gate and the
tools/list filter at it. _MUTATING_TOOLS and the peer-writer path are
unchanged: adding the two names there instead would put a config-only
tool under the palace lease, so a server that lost the lease to a peer
would answer -32001 for a call the lease has no say over.

mempalace_reconnect stays reachable on purpose and the comment records
why, since clearing ChromaBackend._quarantined_paths lets the reopen
rename a segment directory. The two --read-only help strings and the
matching row in the remote-server guide said "the mutating tools",
which now names the narrower set.
2026-07-29 19:32:37 +05:00
KeilerHirsch 867151b211 test(encoding): assert lowercase skip_name + strengthen raw-utf8 read coverage
Third test now genuinely fails pre-fix (raw UTF-8 skip_name read), fixing the reviewer-flagged passes-either-way case. skip_names are lowercased on load, so assert on .lower().
2026-07-29 08:54:08 +02:00
KeilerHirsch 845f59419e fix(encoding): pin encoding=utf-8 on dialect.py + config.py text opens
dialect.py: all 11 text-mode open() calls (6 read, 5 write) omitted encoding=, so on a non-UTF-8-locale process (e.g. German Windows / cp1252) UTF-8-written JSON and AAAK text is decoded via the OS codepage, corrupting umlauts. config.py: 4 text opens lacked encoding= (config.json + people_map read paths, two writes); the other json.dump write paths already pinned UTF-8.

Audit findings #51 (dialect.py:360) and #84 (config.py:377). Regression: tests/test_encoding_hardening.py forces cp1252 default open and asserts umlaut round-trips through from_config / config.json read / raw-UTF-8 skip_name.
2026-07-29 08:51:00 +02:00
KeilerHirsch 13196cd056 fix(dedup): guard dedup_palace's own count() print, not just get_source_groups
Independent review of the preceding commit found that dedup_palace's own
'Drawers: N' count() print runs a few lines before it calls
get_source_groups -- #92's palace_path guard on get_source_groups only
covered that function's internal count(), leaving this earlier, separate
call site in the same function fully exposed to the same #1222
SIGSEGV/panic class. Same fix pattern: preflight hnsw_capacity_status
before this print and abort the whole dedup_palace run on divergence.

show_stats has no equivalent exposure -- it goes straight to
get_source_groups with no separate count() print.
2026-07-28 17:34:49 +02:00
KeilerHirsch 8b5e372951 fix: preflight HNSW divergence before every col.count() call site the audit found
hnsw_capacity_status() (chroma.py) exists precisely to preflight the
#1222 SIGSEGV/pyo3-panic class before anything touches the HNSW
segment, but repo-wide it was wired into only 4 call sites while raw
count()/collection.count() is called at 20+ others -- a bare
except Exception around count() cannot catch a native crash, since
the process dies regardless of any Python try/except. This wires the
existing, already-tested probe into the 7 remaining call sites the
audit identified as CRITICAL:

- #89 palace.py::_enforce_embedder_identity -- the universal
  get_collection() chokepoint every tool passes through, previously
  guarded only by except Exception. Highest leverage: skips this
  bookkeeping-only check on divergence instead of risking count().
- #90 migrate.py::migrate -- routes straight to the same
  SQLite-extraction fallback the except branch already used, instead
  of ever reaching col.count() when diverged.
- #91 repair.py::scan_palace / prune_corrupt -- both abort with the
  existing from-sqlite recovery guidance instead of opening the
  collection.
- #10 repair.py::rebuild_index -- preflights divergence alongside its
  existing sqlite-integrity and poisoned-max-seq-id preflights, before
  opening the collection.
- #13 repair.py::rebuild_index never rebuilt or reported on the
  closets collection -- now warns when closets is still diverged
  after a drawers-only rebuild, pointing at --mode from-sqlite instead
  of letting 'Repair complete' stand unqualified.
- #92 dedup.py::get_source_groups -- takes an optional palace_path
  (threaded from both callers) to preflight before count(); omitted by
  existing tests, which keep their pre-existing behavior.
- #93 miner.py::status -- preflights before the ChromaDB-client
  fallback path (used when the direct sqlite read is unavailable).

7 new regression tests, each confirmed failing against the pre-fix
code (via git stash of the source files only) and passing after the
fix. One existing dedup.py test updated for the new palace_path kwarg
in its call-signature assertion. Full suite: 3154 passed, 1 unrelated
pre-existing flake (test_mcp_server.py peer-writer-lock module-global
state leaking across test files in full-suite ordering -- this diff
never touches mcp_server.py).
2026-07-28 17:18:02 +02:00
KeilerHirsch 0eb137e419 style: ruff format after import wrap (CI lint) 2026-07-28 16:47:17 +02:00
Sandro da Silva a7c3827368 fix(search): return round-trippable drawer IDs 2026-07-28 14:37:35 +00:00
KeilerHirsch 3a97996b30 fix(convo_miner): stop sweeper drawers being purged, abort on failed purge
#104 (CRITICAL, data-loss): the sweeper writes drawers with no
extract_mode at all (ingest_mode="sweep"). _metadata_matches_extract_mode's
legacy-compat rule -- "no extract_mode means treat as a legacy exchange
row" -- couldn't tell that apart from a genuine pre-schema convo_miner
row, so mempalace mine --mode convos (default extract=exchange) swept
every sweeper drawer for a shared transcript into its purge scope and
deleted them on the very next re-mine. The legacy-compat fallback now
only applies when the drawer is otherwise convo_miner's own (no
ingest_mode at all, or convo_miner's own "convos" tag) -- a drawer
positively identified as another producer's (sweep, or any other
foreign ingest_mode) never matches, using the same ingest_mode
discriminator sync.py already relies on for its own registry-row check.

#105 (MEDIUM, silent-failure): convo_miner's own instance of the
purge-failure swallow already fixed for miner.py at #23 -- a failed
purge in _file_chunks_locked was logged at debug level and mining
proceeded anyway, silently producing duplicate/stale drawers under
mixed schema versions. Now aborts (returns skipped=True, leaving the
old drawers' stored mtime untouched so the next mine retries) and
prints a visible warning.

428 tests pass across test_palace.py/test_convo_miner*.py/test_miner.py/
test_sweeper.py/test_hallways.py/test_format_miner.py/test_repair.py, no
regressions.
2026-07-28 16:24:25 +02:00
KeilerHirsch 5eddedf691 fix(repair): stop dropping drawers with zero embedding_metadata rows
extract_via_sqlite drove its extraction with an INNER JOIN starting at
embedding_metadata. Any embedding with zero rows there (a sparse
historical write with no chroma:document and no other key -- the same
condition _extract_drawers already sanitizes for the collection-layer
rebuild path, see #1458) never appeared in the join result and was
silently dropped by mempalace repair --mode from-sqlite, with no count
mismatch and no warning.

Drive the query from embeddings (LEFT JOIN embedding_metadata) instead,
defaulting to an empty metadata dict when a row has none. Order by
e.id rather than em.id since em.id is NULL for unmatched rows.

Regression test seeds a real chromadb collection, then strips one
drawer's metadata rows directly via SQLite (current chromadb validates
against empty metadata on write, so this only reproduces on rows
already sitting in an older palace) and asserts the drawer still
comes back through the SQLite bypass.
2026-07-28 16:21:10 +02:00
KeilerHirsch a17dea19d7 fix(cli): promote verified temp copy in cmd_repair's own recovery path too
cmd_repair() has a separate, inlined implementation of the same
live-swap-failure recovery that _rebuild_collection_via_temp's shared
fix (preserve the verified temp copy) already covers for rebuild_index().
Before this, cmd_repair's own except-block still did shutil.rmtree()
on the whole palace directory (destroying that same temp copy) and
restored a pre-repair full-directory backup -- printing recovery
guidance that pointed at data the same code path had just deleted.
Now both callers promote from the verified temp copy consistently.
2026-07-28 12:23:34 +02:00
KeilerHirsch d4905184fe style: ruff format after comment cleanup (CI lint) 2026-07-28 12:08:11 +02:00
KeilerHirsch edcd065068 docs: drop internal audit-ID references from comments/docstrings
Bare (#8)/#12: markers were internal finding IDs from a local audit
report, not GitHub references -- but they collide with real closed
issues in this repo (#8, #12) and would misdirect reviewers. Replaced
with plain prose.
2026-07-28 12:00:43 +02:00
KeilerHirsch 8a79aa5122 fix: preserve verified temp copy on failed rebuild swap; fail loud on truncated pagination
Two independent bugs from the 2026-07-28 full-repo audit:

1. _rebuild_collection_via_temp deleted its own verified temp collection
   in the except-handler even when the live collection had already been
   replaced (live_replaced=True) -- at that point the temp copy is the
   ONLY intact data left. Now preserved, and the error message points the
   operator at it.

   rebuild_index's own recovery path used to 'restore' by copying back a
   pre-rebuild chroma.sqlite3 file backup and claim the palace was back
   to pre-repair state -- but the live collection's on-disk HNSW segment
   directories were already destroyed by the delete, so the restored
   sqlite3 referenced segment UUIDs that no longer existed. Replaced with
   a new _promote_temp_collection() that recovers by re-extracting from
   the verified temp copy and re-uploading into a fresh live collection,
   with an honest fallback message if that promotion also fails.

2. _paginate_ids silently returned a truncated ID list when offset-based
   pagination failed and the no-offset fallback got structurally stuck
   at the first page (1000 results). Callers (scan_palace, rebuild) treat
   the result as the complete palace, so this could act on incomplete
   data with no warning. Now cross-checks against the collection's own
   count() to disambiguate a genuinely-complete page-sized collection
   from a truncated larger one, and raises instead of silently truncating
   when it can't tell.

Both fixes verified against an independent adversarial review pass
(mutation-tested: confirmed the new tests catch a wrong-source-collection
read and a swapped ids/documents payload in the recovery path, which the
original test set did not).

Deliberately out of scope: none of the rebuild paths hold mine_palace_lock
for their full duration, so a concurrent writer can still interleave with
a rebuild. That's a separate, larger architectural gap (queue-and-replay
vs. block-on-write), tracked for its own follow-up.
2026-07-28 11:46:46 +02:00
Igor Lins e Silva 2d8fc9e66d fix(storage): enforce local single-writer ownership 2026-07-26 17:42:28 -03:00
xcarboandClaude Fable 5 b11995d81e fix(mcp): self-exit on stdin EOF/broken pipe so orphaned stdio sessions release locks
A stdio mcp_server orphaned by a dropped SSH session used to swallow
pipe errors in the generic exception handler and sleep forever on the
dead channel, holding the mine_palace flock (2026-07-10 write-path
outage, PID 23392). The loop now logs and shuts down on stdin EOF,
OSError from stdin reads (EIO/EBADF from orphaned ptys), and
BrokenPipeError/OSError on stdout writes. Shutdown returns from the
loop for a normal interpreter exit 0 — no os._exit — so held flocks
release via process teardown; a broken stdout is re-pointed at devnull
first so the shutdown flush cannot turn the exit into status 120.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpEdb9SNWF42BpupHWBhC3
2026-07-24 11:49:11 +04:00
Grace Gettert 2df0e12a44 feat(mine): dispatch explicit source adapters
Requested-by: Grace Gettert <grace.gettert@carpe.io>
2026-07-23 16:57:47 +00:00
934a4419ef fix(entity): defuse entity-candidate ReDoS on long ASCII runs (#2063)
The English entity-candidate pattern in i18n/en.json backtracks
catastrophically on a long unbroken run of printable ASCII (base64,
minified JS, hashes, data URIs), pinning `mempalace mine` on a single
~5000-char window for hours. Collapse such runs to a space before
single-word candidate matching, in both consumers that apply the pattern
(palace._candidate_entity_words and entity_detector.extract_candidates).

Scoped to ASCII ([!-~]) so non-ASCII scripts stay untouched — a CJK
paragraph is one unbroken run with no ASCII whitespace, and zh-CN/zh-TW
have no multi-word fallback, so a blanket collapse would erase their
detection. Threshold 24 sits above the 20-char cap of the simple-name
pattern, so no real name is dropped.

Fixes #2063.

Co-authored-by: Ryan Wei <9876551+RyanWei@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:56:37 +05:00
mvalentsevandmjvmsteixeira 8d2de4a2a4 fix(daemon): defer jobs refused the palace lock, don't fail them (#2014)
A daemon job refused the palace write lock was marked terminal `failed` and
never retried. `mine_palace_lock` guards the palace write itself, so a refusal
means no drawer was filed, but the queue recorded it exactly like a crash
mid-execution, whose outcome is unknown. The work was dropped: it only ran
again if a hook happened to re-submit equivalent work, and a job kind no hook
re-emits was lost outright.

Refusals now defer. The job goes back to `queued` with `started_at` cleared,
the claim's attempt increment undone, and the reason recorded. The update is
scoped to the claim that was refused (started_at match), so a defer racing a
recovery re-claim cannot re-queue work it does not own. `claim_next` clears
the recorded reason so it never outlives the claim it describes. Every other
failure stays terminal: a crashed job's outcome is unknown, and blindly
re-running a non-idempotent kind would re-file verbatim content, which is what
MAX_ATTEMPTS guards.

The worker cools a refused job off in memory and moves on rather than sleeping
in line. It is the only worker and the holder keeps the lock until its write
finishes, which can be a long mine, so blocking would stall every unrelated job
behind a lock that has nothing to do with them, and a job merely queued behind
the refused one would never be claimed. `claim_next(exclude=...)` skips a
cooling job, so the oldest-first ordering cannot hand the same refused job back
forever while newer work waits. The filter runs in Python rather than an
`id NOT IN (?, ?, ...)` list, which would bind one host parameter per cooling
job against a cap that defaults to 999 before SQLite 3.32. The cooldown
lives in the worker, not the schema, which has no migrations; a restart just
retries at once, costing one refusal, never work.

`tool_diary_write` swallowed `MineAlreadyRunning` in its bare `except
Exception`, so the refusal reached the daemon with no `error_class` and
`diary_write` would still have been dead-lettered. It now uses a typed handler
ahead of the bare `Exception`, the way `tool_mine` and `tool_sync` already do.

Deferral makes a refused job non-terminal, and `DaemonClient.wait` only returns
on a terminal state, so callers that wait on purpose would have waited for a
state a parked job cannot reach: a foreground `mine --daemon` for the one-hour
default, and the `hooks_cli` pre-compaction mine and Stop-hook diary paths for
their whole timeout on every fire, each then reporting a failure that never
happened. `wait(stop_on_lock_deferral=True)` hands the parked job back instead.
The CLI echoes the global `--palace` back into the command it suggests, so the
suggestion does not silently list the default palace's queue instead of the
one the job is parked in. A job that is genuinely running is still waited out.

Co-Authored-By: mjvmsteixeira <185609735+mjvmsteixeira@users.noreply.github.com>
2026-07-22 11:34:34 +05:00
Igor Lins e Silva aa89bd8227 Merge pull request #2051 from mvalentsev/perf/1471-hnsw-probe-cache
perf(mcp): cache the HNSW capacity probe, invalidated by file signature (#1471)
2026-07-22 00:57:36 -03:00
Igor Lins e Silva f852ef98fa Merge pull request #2030 from fatkobra/feat/1963-hook-write-routing
feat(hooks): apply shared daemon write-routing policy
2026-07-22 00:51:43 -03:00
Igor Lins e Silva b55124e9d8 Merge pull request #2055 from mvalentsev/fix/2054-chunk-text-line-count
perf(miner): O(N) incremental line-number tally in chunk_text (#2054)
2026-07-22 00:40:30 -03:00
Igor Lins e Silva 39d151bd28 Merge pull request #2058 from mvalentsev/fix/2056-chunk-text-windowing-loop
fix(miner): reject chunk_overlap above half the chunk size to stop chunk_text hang (#2056)
2026-07-22 00:40:19 -03:00
mvalentsev bfd1e486fe fix(miner): reject chunk_overlap above half the chunk size to stop chunk_text hang (#2056)
chunk_text's windowing loop (start = end - chunk_overlap) stops advancing
when chunk_overlap exceeds chunk_size // 2 on short-line content, looping
forever at 100% CPU. A paragraph or line boundary pull only moves end past
start + chunk_size // 2, so a pulled chunk spans more than half the chunk
size; the step then advances only while chunk_overlap <= chunk_size // 2.

Tighten the existing guard from chunk_overlap >= chunk_size to
chunk_overlap > chunk_size // 2 in both chunk_text (raise, with the numeric
bound in the message) and MempalaceConfig._validated_chunk_config (repair to
min(DEFAULT_CHUNK_OVERLAP, chunk_size // 2)). The windowing loop is
unchanged, so every already-valid config produces byte-identical chunks; 50%
overlap (chunk_overlap == chunk_size // 2) stays valid.

Fixes #2056.
2026-07-22 00:33:32 +05:00
mvalentsev 2b5dc6e8cb perf(miner): O(N) incremental line-number tally in chunk_text (#2054)
chunk_text recomputed line_start/line_end with a full-prefix
content.count("\n", 0, pos) per chunk (O(N*K) overall), so large files
ground for days. Keep the emitted values byte-identical but tally
newlines incrementally over the newly-scanned span (O(N) total), with a
from-scratch fallback that keeps every value exact.
2026-07-21 20:56:46 +05:00
mvalentsev 93bdeeeec0 perf(mcp): cache the HNSW capacity probe, invalidated by file signature (#1471)
hnsw_capacity_status() runs on every search, duplicate check and status
call (MCP and CLI). Each call cost a COUNT(*) over the embeddings table
and a full unpickle of the segment metadata, both scaling with palace
size. Cache the verdict per (palace_path, collection_name) and reuse it
while an (inode, mtime_ns, size) signature over chroma.sqlite3, its -wal
sidecar and index_metadata.pickle is unchanged, so an external write
invalidates it at once rather than after a fixed window. The #1222
divergence guard stays fresh: a mid-probe write or a locked read is
returned but never cached, and the signature brackets the whole probe so
a pickle rewrite during it cannot be pinned as fresh.
2026-07-20 22:14:45 +05:00
rohan richard 54397a5cc0 fix: dedup content hashes per conversation and per wing
normalize() joined every conversation in a Claude.ai privacy export
bundle into one string before it was hashed, so re-exporting the bundle
with one new conversation added changed the whole-file hash and the
existing conversations were re-mined as duplicates. Split normalization
into normalize_conversations() so each conversation can be hashed and
deduped on its own.

Also scope the content-hash map by wing — mining the same transcript
into a second wing is a deliberate re-file, not a duplicate, and was
leaving that wing with only the registry sentinel.
2026-07-20 22:35:21 +05:30
rohan richard 283dae45e3 fix: prevent duplicate drawers when re-mining LLM conversations
Repeated exports from Claude/ChatGPT land the same conversation under a
new filename each run (timestamped bundle, regenerated slug, etc.), so
the existing source_file/mtime skip never recognized it as already
mined and re-filed a duplicate set of drawers. Now content is hashed
after normalization and checked against previously filed hashes, so
the same conversation under a new path is skipped instead of
duplicated.
2026-07-20 17:30:21 +05:30
Igor Lins e Silva 8ab251c452 Merge pull request #2020 from MemPalace/develop
Release v3.6.0 — promote develop to main
2026-07-17 07:39:41 -03:00
Pim Messelink 811d6c9940 feat(pgvector): facet_counts per BaseCollection #1868 contract
Implements the pgvector server-side implementation of BaseCollection.facet_counts(field, where, limit), completing the pgvector fast path for the aggregation contract merged in #1868 and specified in #1835.
2026-07-17 10:11:22 +00:00
Sandro da Silva 3791ad17ea feat(hooks): apply daemon write-routing policy 2026-07-17 08:23:36 +00:00
Igor Lins e Silva 5cd961eef9 Merge pull request #2027 from fatkobra/feat/1963-write-routing-policy
feat(routing): add shared daemon write-routing policy
2026-07-16 19:08:03 -03:00
Igor Lins e Silva 67c90c39d5 Merge pull request #2034 from mvalentsev/fix/2023-checkpoint-added-by
fix(mcp): preserve agent attribution in mempalace_checkpoint (#2023)
2026-07-16 19:07:41 -03:00
mvalentsev de74cad911 test(mcp): close chroma clients in checkpoint attribution test
The new real-Chroma checkpoint test dropped its create-time client with a
bare del, leaving the per-path SharedSystemClient's SQLite/HNSW handles
open on Windows (#1128). Close both clients so the temp palace is released,
matching the conftest fixture's close-not-del pattern.
2026-07-16 18:08:32 +05:00
mvalentsevandepinethrone c76208507e fix(mcp): preserve agent attribution in mempalace_checkpoint (#2023)
mempalace_checkpoint hard-coded added_by="checkpoint" for every drawer,
dropping the filing agent's identity even though it arrives in the same
call via diary.agent_name. Add an optional top-level added_by parameter
and resolve attribution as explicit > diary agent_name > "checkpoint";
blank/whitespace/non-string values defer to the next source. The value
is declared in the tool schema so tools/call admits it on both stdio and
HTTP transports.

Fixes #2023

Co-Authored-By: epinethrone <172391900+epinethrone@users.noreply.github.com>
2026-07-16 17:27:50 +05:00
raman325andClaude Fable 5 fc3beb71ab fix(hermes): mirror default-target memory writes into the knowledge graph
Hermes' memory tool defaults to target="memory" (the agent's own
notes); filtering on_memory_write to target == "user" silently dropped
the majority of writes. Mirror both targets under distinct subjects —
user→asserted for user facts, hermes→noted for agent notes — so
kg_query("user") never surfaces environment quirks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:08:58 -04:00
raman325andClaude Fable 5 bb1d502ddc fix(hermes): resolve palace, collection, and KG against one config chain
The provider filed and searched self._palace_path while the mcp_server
passthrough tools resolved mempalace's global config — a custom Hermes
palace_path searched one palace while drawer CRUD, duplicate checks,
and tunnels wrote another. Publish the resolved palace to
MEMPALACE_PALACE_PATH (mcp_server's own --palace mechanism) with
ownership tracking so a stale bridge never outranks edited config and
a user-set env var is never touched. The KG tools become native
handlers: mcp_server resolves its KG from DEFAULT_KG_PATH unless its
own CLI flag was given, which no env bridge can influence.
collection_name and the unset-palace default now defer to
MempalaceConfig — the same single chain the MCP server itself uses.
Also document that hermes backup does not cover ~/.mempalace (no ABC
hook exists for contributing external paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:08:58 -04:00
raman325andClaude Fable 5 2d6580b1f1 fix(hermes): make sync_turn the sole filing path
on_session_end and on_pre_compress blind-re-filed the raw message list,
duplicating every turn sync_turn had already stored — filed_at is hashed
into the drawer id, so upserts cannot collapse the copies. Drop the
re-filing (and the pre-compress hint that over-promised persistence);
on_session_end keeps only the wake-up cache refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:08:58 -04:00
raman325andClaude Fable 5 37bc50d98a fix(hermes): address PR #1915 review findings
- _scan_metadatas: fetch cap+1 and compare len > cap, so a collection
  holding exactly STATUS_SCAN_LIMIT rows is no longer reported as
  truncated (the view is complete). Callers still get at most cap rows.
- status/list_wings/list_rooms: tolerate None metadata entries from
  legacy palaces / raw writers instead of failing the tool call.
- _match_wing_by_keywords: skip non-string keywords so a hand-edited
  wing_config.json can't break live turn filing.
- file_conversation_exchange: extra_metadata can no longer overwrite
  canonical keys (matches the documented append-only contract), and
  wing/room are validated with sanitize_name — invalid names fall back
  to wing_general / conversations rather than dropping the turn, per
  the verbatim-first mandate.
- Fix two stale docstrings left from the pre-split layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:08:58 -04:00
raman325andClaude Fable 5 9553ed9940 feat(integrations): Hermes memory provider core
The Hermes provider from feat/hermes-integration, split out per review
on #1684 — provider + tests only; backfill, the hermes install CLI,
and docs follow in a stacked PR.

Changes vs the original branch:
- _file_turn routes through convo_miner.file_conversation_exchange()
  instead of a hand-rolled col.upsert, so live turns carry canonical
  drawer metadata and the ids.py ID recipe.
- The backfill/live wing-routing parity test moves to the backfill PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:08:20 -04:00
raman325andClaude Fable 5 04fdb78180 feat(convo): canonical write path for single verbatim exchanges
Live agent integrations and their backfills need to file one
conversation exchange at a time, but hand-rolling the upsert leaves
drawers without hall / entities / filed_at / extract_mode metadata —
silently invisible to hallway traversal, entity search, and the
since/before date filters.

file_conversation_exchange() builds the same metadata the convo miner
writes, and make_exchange_drawer_id() moves the ID construction into
ids.py per its single-source-of-truth contract (full-content hash, no
prefix collisions; filed_at keeps repeated exchanges distinct).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:08:20 -04:00
Sandro da Silva fa97c93dd7 feat(routing): add shared daemon write-routing policy 2026-07-15 14:38:23 +00:00
Igor Lins e Silva ec8788c2bf Merge pull request #2022 from MemPalace/chore/sync-main-before-3.6.0
chore: sync main hotfixes into develop before 3.6.0
2026-07-15 03:05:55 -03:00
Igor Lins e Silva f62ce2696b test: cover main hotfix regressions 2026-07-14 21:52:32 -03:00
Igor Lins e Silva a613c921c8 docs: include main hotfixes in 3.6.0 notes 2026-07-14 21:32:55 -03:00
Igor Lins e Silva 4bee65c11b Merge remote-tracking branch 'origin/main' into chore/sync-main-before-3.6.0 2026-07-14 21:32:21 -03:00
Igor Lins e Silva 6340d611cc Merge pull request #2019 from MemPalace/chore/release-3.6.0
chore(release): 3.6.0
2026-07-14 21:28:37 -03:00
Igor Lins e Silva 9ecdf83c08 docs: address 3.6.0 release review 2026-07-14 21:18:12 -03:00
Igor Lins e Silva dbfeb82e68 chore(release): 3.6.0 2026-07-14 20:44:15 -03:00
Igor Lins e Silva 68edb110a1 Merge pull request #2018 from MemPalace/agent/fix-explicit-palace-graph-scope
fix: scope derived graph state to explicit palace
2026-07-14 20:28:43 -03:00
Igor Lins e Silva a824be43c8 Merge pull request #2017 from MemPalace/agent/fix-sqlite-recovery-cleanup
fix(repair): require clean SQLite recovery finalization
2026-07-14 20:28:27 -03:00
Igor Lins e Silva 88e454caed Merge pull request #2016 from MemPalace/agent/fix-cli-divergence-preopen
fix(searcher): guard CLI divergence before Chroma open
2026-07-14 20:28:03 -03:00
Igor Lins e Silva 4c8ef8ed43 Merge pull request #2015 from MemPalace/agent/fix-sqlite-integrity-contention
fix(repair): wait out transient SQLite contention
2026-07-14 20:25:55 -03:00
Igor Lins e Silva 287d8f1b4d fix: scope hallway reads and deletion to selected palace 2026-07-14 19:40:29 -03:00
Igor Lins e Silva 0955100a1f fix(repair): preserve strict cleanup exceptions 2026-07-14 19:37:32 -03:00
Igor Lins e Silva 8a87fc66e2 fix(searcher): preserve backend diagnostics before HNSW probe 2026-07-14 19:37:32 -03:00
Igor Lins e Silva f68cb8d682 fix: scope derived graph state to explicit palace 2026-07-14 19:22:06 -03:00
Igor Lins e SilvaandAaron Zendejas acd62442be fix(repair): require clean SQLite recovery finalization
Co-authored-by: Aaron Zendejas <phileokairos@gmail.com>
2026-07-14 19:12:56 -03:00
Igor Lins e SilvaandTom Boucher 2c6cecf1fe fix(searcher): guard CLI divergence before Chroma open
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-07-14 19:10:02 -03:00
Igor Lins e Silva 7267de2b32 fix(repair): wait out transient SQLite contention 2026-07-14 19:07:36 -03:00
Igor Lins e Silva 32e545eaaa Merge pull request #2004 from mvalentsev/fix/2003-http-client-disconnect
fix(mcp): don't log a traceback when an HTTP client disconnects mid-response (#2003)
2026-07-14 18:49:46 -03:00
Igor Lins e Silva 28aa2c49b0 Merge pull request #1928 from jrzmurray/fix/automine-fts5-autoheal
mine: auto-heal isolated FTS5 corruption instead of forcing manual repair
2026-07-14 18:47:51 -03:00
Igor Lins e Silva 88e839bce5 Merge pull request #1987 from KeilerHirsch/fix/mcp-async-startup-preflight-dev
fix(mcp): answer initialize immediately — run startup preflight in a background thread
2026-07-14 18:44:48 -03:00
Igor Lins e Silva 0b04b79869 Merge pull request #2010 from KeilerHirsch/fix/convo-skip-tool-results
fix(convo_miner): don't mine Claude Code tool-results/ dirs — raw tool dumps flood the palace
2026-07-14 18:42:26 -03:00
Igor Lins e Silva 3da735ec6b Merge pull request #1971 from mvalentsev/fix/palace-lock-holder-set-async-safety
fix(palace): pair the mine_palace_lock holder-set update with its release (#1970)
2026-07-14 18:41:43 -03:00
KeilerHirsch 5fdce3923e style: ruff format test_convo_miner_unit.py (CI lint) 2026-07-14 16:43:05 +02:00
mvalentsev 3c0ecb5b3e fix(mcp): don't log a traceback when an HTTP client disconnects mid-response
On the HTTP transport, a client that hangs up mid-response makes the send
path raise an unhandled BrokenPipeError / ConnectionResetError, so the
default socketserver handle_error logs a full traceback for a routine
disconnect. Override handle_error on the HTTP server to log the disconnect
(ConnectionError, plus ssl.SSLEOFError over TLS) at DEBUG and drop it, while
every other exception still reaches the default handler with its traceback.
The wider ssl.SSLError tree is left uncaught, so genuine TLS handshake/cert
failures still surface.

Addresses the disconnect half of #2003.
2026-07-14 13:59:49 +05:00
KeilerHirsch 7f07b0d4fa fix(convo_miner): scan_convos no longer descends into Claude Code tool-results dirs
Claude Code pages large tool outputs to <session>/tool-results/*.txt
inside ~/.claude/projects/<slug>/. These are raw machine dumps (command
output, MCP results) referenced from the transcript JSONL — not
conversations. The convo scanner picked them up as plain .txt, so every
transcript ingest stored megabytes of tool output as memories: on one
field palace, 12,825 drawers traced back to tool-results files, with a
single paged file producing 3,611 drawers.

Adds CONVO_SKIP_DIRS = SKIP_DIRS | {'tool-results'} used only by
scan_convos — project-mining semantics are unchanged, and regular nested
conversation dirs still scan (covered by a companion test).
2026-07-14 10:13:10 +02:00
mvalentsev 4b98dd796d fix(palace): pair the mine_palace_lock holder-set update with its release
_mark_held(palace_key) ran before the try: whose finally runs
_mark_released(). An async exception (SIGINT/KeyboardInterrupt) landing
after _mark_held() and before the try: skips _mark_released(), stranding
the key in the process-wide _palace_lock_keys set while the outer finally
frees the flock. The in-memory hold then outlives the OS lock: a later
re-entrant acquire passes through and writes without the flock while
another process can acquire it, i.e. two writers into one palace.

Move _mark_held() inside the try so it pairs with _mark_released() on
every exit. Add a regression test that injects the interrupt in the
window and asserts the holder set is not stranded.
2026-07-11 15:21:41 +05:00
KeilerHirsch 99464572f4 style: ruff format test file 2026-07-10 17:04:33 +02:00
Igor Lins e Silva 5b1c32eee1 Merge pull request #1953 from jrzmurray/rebuild-1213
feat: add exclude_patterns config key to mempalace.yaml (rebase of #1213)
2026-07-10 09:11:10 -03:00
KeilerHirsch e360a3a040 fix(mcp): answer initialize immediately — run startup preflight in a background thread
The stdio loop ran _refresh_sqlite_integrity_status() and
_refresh_vector_disabled_flag() before reading the first request.
PRAGMA quick_check reads every page of chroma.sqlite3, so on multi-GB
palaces the probe alone (measured: 20.3s on a 1.72 GB / 326k-drawer
palace, 40-46s under disk/lock contention) starves the MCP client's
60s connect timeout — even though the initialize response itself never
touches the database. The HTTP transport already starts without the
synchronous probe.

Move both probes to a daemon thread (mcp-startup-preflight). The #1222
intent is preserved: the probe still starts at startup and logs its
warning as soon as it finishes. Consumers that need the verdict
(_ensure_sqlite_integrity_status via the tool-call integrity gate)
serialize on a new _sqlite_integrity_refresh_lock with double-checked
locking, so a tool call arriving mid-probe waits for the in-flight
verdict instead of running a second O(database size) quick_check —
and never proceeds unverified.

Measured on the 1.72 GB palace with the >512 MB startup gate disabled
(MEMPALACE_STARTUP_INTEGRITY_MAX_MB=0, full quick_check in flight):
initialize 1.4s (was 20-46s); first tool call after probe completion
3.4s with sqlite_integrity checked=true ok=true.

Complements c54531a: the oversized-palace skip still applies to the
background probe, but the handshake no longer depends on it.
2026-07-10 14:10:43 +02:00
Igor Lins e Silva d7819d4b85 Merge pull request #1965 from Evgen197310/fix/test-format-mcp-writer-self-heal
style(tests): fix ruff format check left red by #1960
2026-07-10 09:08:41 -03:00
J.R. Murray c7955d3899 chore: retrigger CI (unrelated test-windows flake on prior run) 2026-07-09 18:29:29 -04:00
Igor Lins e Silva 18a9788961 Merge pull request #1899 from zc277584121/milvus-backend-v2
Add Milvus storage backend
2026-07-08 12:27:46 -03:00
Igor Lins e Silva 63188686e5 Merge pull request #1945 from KeilerHirsch/fix/repair-atomic-archive-rename
fix(repair): use os.rename for in-place archive, not shutil.move
2026-07-08 07:25:51 -03:00
Evgen197310andClaude Opus 4.8 09aa4034a7 style(tests): satisfy ruff format check for peer-writer self-heal test
PR #1960 merged with a red `lint` job: `ruff format --check .` wanted to
collapse the multi-line `MineAlreadyRunning(...)` raise in the new
`test_peer_writer_readonly_self_heals_after_peer_exits` onto one line
(it fits the line-length limit). All six real test jobs passed; only the
formatter check failed, which left `develop` red on lint.

Reformat that one statement so `ruff format --check .` is clean again.
No logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 11:00:03 +03:00
Igor Lins e Silva a20d769b8c Merge pull request #1960 from Evgen197310/fix/mcp-writer-readonly-self-heal
fix(mcp): self-heal writer lease instead of latching read-only for life
2026-07-08 00:33:35 -03:00
Igor Lins e Silva b0b028170a Merge pull request #1957 from jrzmurray/pr-mutable-transcripts
fix(convo_miner): treat transcripts as mutable, not immutable
2026-07-08 00:33:21 -03:00
Igor Lins e Silva c0ae2abaff Merge pull request #1946 from mvalentsev/fix/status-integrity-non-chroma
fix(mcp): mark sqlite_integrity not-applicable on non-chroma backends (#1931)
2026-07-08 00:32:56 -03:00
Evgen197310andClaude Opus 4.8 9e871b45b5 fix(mcp): self-heal writer lease instead of latching read-only for life
The #1818 peer-writer guard latched _MCP_WRITER_READ_ONLY=True on the first
MineAlreadyRunning and short-circuited every subsequent acquisition attempt,
so a server that came up read-only (a peer held the per-palace flock at
startup) stayed read-only for its entire process lifetime — even long after
the peer exited and the OS released the flock. In the common case of several
overlapping Claude sessions (one server per session, all on the same palace),
whichever session started second was stranded: mutating tools kept refusing
with -32001 and the only remedy was killing/restarting that server.

_mcp_peer_writer_refusal already calls _acquire_mcp_writer_lock() on every
mutating tool, so the retry hook existed — the sticky latch just suppressed it.
Drop the read-only short-circuit: when read-only we now re-attempt the
non-blocking flock each call and transparently promote to writer once the peer
is gone. Race-safe — fcntl LOCK_NB is kernel-arbitrated, so two servers can
never both win. The genuinely-broken-lock path (_MCP_WRITER_LOCK_FAILED) is
still cached, since retrying a broken lock mechanism can't help.

Adds test_peer_writer_readonly_self_heals_after_peer_exits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:41:17 +03:00
J.R. Murray 9b432699ce fix(convo_miner): treat transcripts as mutable, not immutable
Conversation transcripts were assumed immutable once mined: the bulk
skip-check (prefetch_mined_set) only tracked "have we seen this
source_file before at the current normalize_version", with no mtime
comparison at all. That's wrong for how Claude Code sessions actually
work -- a session keeps appending to its own JSONL file while active,
and /compact or /clear can rewrite one in place. Once a session file
was mined, any content appended after that point would silently never
get mined, with no error or warning -- the file just looked
"already filed" forever.

palace.py:
- prefetch_mined_set() now returns dict[source_file, stored_mtime]
  instead of a bare set[source_file]. `if src in mined_set` still works
  identically (dict `in` checks keys), so this is a source-compatible
  change for that access pattern; a caller that wants staleness
  detection reads mined_set[src] and compares against the file's
  current mtime itself. None means no mtime was ever stored (or
  getmtime failed when the drawer was written) and must be treated as
  stale, not "unknown, assume unchanged".
- Removed bulk_check_mined(): it already existed for exactly this
  purpose (bulk mtime prefetch) but had zero callers anywhere in the
  codebase and was missing the normalize_version/extract_mode filtering
  prefetch_mined_set has -- folded its intent into prefetch_mined_set
  instead of maintaining two subtly-different, overlapping bulk scans
  over the same underlying data.
- file_already_mined()'s docstring corrected: it previously claimed
  "transcripts are assumed immutable" for convo mining. That's no
  longer true; corrected to describe the actual current split (convo
  miner's bulk skip-check uses prefetch_mined_set's stored mtimes; this
  function's check_mtime=True path is now only its per-file,
  lock-held race-condition recheck).

convo_miner.py:
- New _is_unchanged_since_last_mine() helper (extracted to keep
  _mine_convos_impl under the repo's cyclomatic-complexity gate):
  false whenever the file isn't in the prefetched map, its stored mtime
  is None, getmtime fails, or the mtimes don't match -- true only when
  genuinely unchanged.
- _file_chunks_locked's metadata now stamps source_mtime on every real
  drawer (mirroring miner.py's existing pattern), and its in-lock
  recheck now passes check_mtime=True.
- _register_file's 0-chunk sentinel also stamps source_mtime, so a
  short file that later grows past MIN_CHUNK_SIZE is detected as
  changed instead of being skipped forever by the sentinel.

One-time cost worth flagging: no existing convo drawer has source_mtime
stored (this field never existed for convo mining before now), so the
first `mempalace mine --mode convos` after this ships will see every
already-mined file as stale and fully re-mine it. Not a bug --
_file_chunks_locked's existing purge-before-insert means no
duplication results -- just a real, one-time cost across a large
corpus.

tests/test_convo_miner.py: 7 new tests -- grown-file re-mine picks up
new content, unchanged file still skipped (the mtime check must not
regress the existing optimization), grown-file re-mine purges stale
drawers rather than accumulating duplicates (checked via unique content
markers, not raw counts -- ChromaDB collections can carry unrelated
bookkeeping rows), prefetch_mined_set's returned mtime matches the real
file, None handling for a drawer with no stored mtime, a legacy
drawer (no source_mtime field, simulating pre-this-change data) is
correctly re-mined rather than skipped forever, and the sentinel path
stamps source_mtime too.

Full suite: 3327 passed, 20 skipped, 0 failed. ruff check / ruff
format -- clean.
2026-07-07 16:41:22 -04:00
J.R. Murray 3f49e15545 refactor(miner): extract exclude_patterns prescan filter into a helper
Rebasing Lochness's exclude_patterns work (#1213) onto current develop
pushed _mine_impl's cyclomatic complexity to 26, tripping the repo's
max-complexity=25 ruff gate (clean on develop before this rebase).
Extracted the pre-scanned-file-list filtering branch into
_apply_exclude_patterns_to_prescanned_files -- same behavior, no test
changes needed, complexity back under the gate.
2026-07-07 15:29:42 -04:00
Lochness b70f06a69e feat: add exclude_patterns config key to mempalace.yaml
Allow projects to specify .gitignore-style patterns that the miner should
skip, without relying on .gitignore for mining control.

A new optional exclude_patterns list in mempalace.yaml is parsed by the
existing GitignoreMatcher class via a new from_patterns() classmethod —
same syntax, same semantics as .gitignore, no new dependency.

  exclude_patterns:
    - '*.md'
    - '*.yaml'
    - 'docs/'        # dir-only: prunes entire tree without descending
    - 'dist/'
    - 'coverage/'

Key behaviour:
- Patterns follow .gitignore rules: anchoring (/pattern), dir-only
- dirs[:] pruning via GitignoreMatcher.matches(..., is_dir=True) so
  excluded subtrees are never walked
- Checked after .gitignore filtering; force_include (--include-ignored)
  bypasses exclude_patterns
- Pre-scanned files lists (init double-scan optimisation) are filtered too
- Backwards compatible: omitting exclude_patterns changes nothing

Changes:
- GitignoreMatcher.from_patterns(): new classmethod, same rule parser as
  from_dir(), reads from a list instead of a file on disk
- scan_project(): builds one exclude_matcher before os.walk; used for
  both dirs[:] pruning and per-file filtering
- _mine_impl(): applies the exclude matcher to pre-scanned files lists
  when provided by the caller
- tests/test_miner.py: three new tests
    test_scan_project_exclude_patterns_skips_matching_files
    test_scan_project_exclude_patterns_prunes_entire_directory
    test_scan_project_exclude_patterns_include_ignored_bypasses_exclusion
2026-07-07 15:29:30 -04:00
J.R. Murray da32e562f7 fix(mine): address review feedback on FTS5 auto-heal (#1928)
Two review comments on this PR, both addressed:

- gemini-code-assist flagged that maybe_autoheal_fts5_index's default
  progress=print goes straight to stdout. _validate_palace_fts5_after_mine
  runs inside the MCP server process too (mcp_server.tool_mine ->
  miner.mine), where stdout is the JSON-RPC transport -- a stray print()
  there would corrupt the protocol stream and crash the connection. Pass
  progress=logger.info instead; palace.py already has the module logger.

- nikkunikku corroborated the fix from a real 1.4GB production palace (278
  repeated abort-loop iterations before the fix) and pointed out a real
  test gap: the fixture-based auto-heal tests fabricate real FTS5
  corruption via direct shadow-table writes, which some SQLite builds
  refuse (existing pytest.skip paths in test_miner_fts5_validation.py,
  related to #1925) -- so on those builds the auto-heal wiring in
  _validate_palace_fts5_after_mine is never actually exercised. Added
  their suggested build-independent tests, adapted to this file's fixture
  helpers: test_validator_suppresses_raise_when_autoheal_clears and
  test_validator_still_raises_when_autoheal_cannot_clear, stubbing
  mempalace.repair.sqlite_integrity_errors/maybe_autoheal_fts5_index
  directly instead of fabricating corruption.

Added a third test, test_validator_passes_logger_progress_not_print_to_autoheal,
covering the specific progress= wiring: the two tests above mock
maybe_autoheal_fts5_index entirely and discard its kwargs, so neither would
have caught the progress=print regression this commit actually fixes. The
new test captures the real kwargs and asserts progress is a bound method of
palace.py's own logger (not print), without pinning to logger.info
specifically -- severity level is a verbosity choice, not a correctness
requirement, so the assertion shouldn't fail on a reasonable future change
to e.g. logger.debug. Verified both directions: fails against the
pre-fix `print` default (and shows the leaked stdout line to prove it),
passes at .info and at .debug alike.

Full suite: 3221 passed, 20 skipped (unchanged skip count). ruff
check/format clean.
2026-07-07 13:23:59 -04:00
mvalentsevandZoz92 1d38ae8784 fix(mcp): mark sqlite_integrity not-applicable on non-chroma backends (#1931)
mempalace_status reported a passing SQLite integrity check on non-chroma
backends (checked/ok true, sqlite_path pointing at a chroma.sqlite3 that does
not exist) even though _refresh_sqlite_integrity_status short-circuits the
check there. _sqlite_integrity_payload now reports the check as not-applicable
(checked false, ok null, reason) for non-chroma backends, keeping the chroma
payload shape and error surfacing unchanged.

Co-Authored-By: Zoz92 <66385795+Zoz92@users.noreply.github.com>
2026-07-07 13:52:44 +05:00
KeilerHirsch 07182d5adf fix(repair): use os.rename for in-place archive, not shutil.move
shutil.move's fallback for a failed os.rename is copytree + rmtree. On
Windows, when any file inside the palace is held open by another
process (a live MCP server, a running mine, another harness), the
rename fails and shutil.move falls back to deleting the live palace
file-by-file via rmtree -- which itself then fails partway through on
the first locked file, leaving the palace partially gutted next to a
partial (or empty) archive copy.

Reproduced live twice (Windows 11, 2026-07-05 and 2026-07-06): running
`mempalace repair --mode from-sqlite --yes --archive-existing` while
an MCP server / detached mine held palace/*/data_level0.bin open threw
mid-rmtree in both cases. The palace itself survived only because the
specific locked files could not be unlinked -- a different lock
pattern (e.g. a lock on a file rmtree reaches first) would have lost
data with no way back.

os.rename is atomic on both platforms it matters on (POSIX rename(2),
Windows MoveFileEx) -- it either fully succeeds or fails without
touching anything. Catch the failure and abort cleanly with actionable
guidance instead of a raw traceback.
2026-07-07 09:38:03 +02:00
Igor Lins e Silva c00e8dcc16 fix: keep status from taking writer lease (#1934) 2026-07-06 18:16:11 -03:00
Igor Lins e Silva b459293e6f fix: keep status from taking writer lease 2026-07-06 09:26:16 -03:00
Grace Gettert 9815f0ae23 fix: half-open as-of interval for KG supersession
Fixes #1913.\n\nVerified locally on Windows with focused knowledge graph/MCP KG tests plus ruff check and ruff format --check.
2026-07-06 09:23:46 -03:00
J.R. Murray 4e20ad3e8c fix: sanitize embedded NUL bytes before they reach ChromaDB
Fixes #1927.\n\nVerified locally on Windows with targeted NUL/surrogate/miner/FTS5 tests plus ruff check and ruff format --check.
2026-07-06 09:23:35 -03:00
Igor Lins e Silva 65bedc2b33 Merge pull request #1911 from trek-e/fix/startup-integrity-nonblocking
fix: skip startup SQLite integrity check on oversized palace (unblocks MCP handshake)
2026-07-06 09:07:38 -03:00
Igor Lins e Silva f820b73160 Merge pull request #1898 from messelink/fix/embedding-wrapper-facet-counts-forwarder
fix(backends): forward facet_counts + get_all_metadata in EmbeddingCollection
2026-07-06 08:19:15 -03:00
Igor Lins e Silva aff1ee8255 Merge pull request #924 from mvalentsev/fix/mine-log-oversized-skips
fix(mine): log warning when files exceed MAX_FILE_SIZE (#923)
2026-07-06 08:18:55 -03:00
Igor Lins e Silva 39bec19956 Merge pull request #1930 from yusefhaddad/fix/read-only-missing-mutating-tools
fix: read-only mode misses checkpoint and delete_by_source in _MUTATING_TOOLS
2026-07-06 08:18:21 -03:00
J.R. Murray f9db9b726f fix(repair): recognize newer SQLite FTS5 corruption message wording
_errors_are_isolated_fts5 gated auto-heal on one specific message shape:

    malformed inverted index for FTS5 table

SQLite >= ~3.5x (confirmed on 3.53.2 / Python 3.13.7) reports the same
isolated-FTS5 condition with different wording instead:

    fts5: corruption found reading blob N from table "embedding_fulltext_search"

The narrow regex never matched this phrasing, so maybe_autoheal_fts5_index
silently declined to heal on any machine running a recent-enough SQLite,
falling straight through to the hard-abort path -- the exact condition
the whole auto-heal feature (#1926/#1928) exists to avoid. Widened the
pattern to match either wording.

Caught by running this repo's own test suite on this machine:
test_repair.py's two auto-heal tests were failing (not, as assumed
earlier, pre-existing/unrelated flakiness -- that assumption was never
actually verified). Traced to this exact classification gap.

Fixing this correctly also exposed that four tests in
test_miner_fts5_validation.py had been passing for the wrong reason: they
manufacture the exact "reporter-shaped" isolated-FTS5 corruption (#1926's
actual bug shape) and asserted mine() must raise MineValidationError for
it -- true only because the classifier bug prevented auto-heal from ever
engaging. With the classifier fixed, that corruption is now correctly
auto-healed and mine() succeeds instead, so those tests' expectations
were stale, not their fixtures being invalid:

- test_helper_raises_on_fts5_segment_corruption -> renamed
  test_helper_auto_heals_fts5_segment_corruption; asserts no raise + a
  clean post-heal quick_check, instead of expecting a raise.
- test_full_chain_raises_through_mine_impl and
  test_mine_impl_does_not_print_partial_summary_on_validation_error: their
  real purpose is exception-passthrough / banner-suppression when the
  validator DOES raise, not proving any particular corruption triggers it.
  Switched from real file corruption to a monkeypatched raise. (Tried
  swapping to _page_mangle's non-isolated corruption first -- that made
  ChromaDB's own Rust bindings panic just opening the file for the
  re-mine's get_collection() call, a native crash rather than a catchable
  Python exception, before the validator ever ran. Different failure mode
  than what these tests are about, and not reliable to depend on.)
- test_mine_formats_full_chain_raises_when_fts5_corrupt: same fix, mirrors
  the miner-path change for the extract path.
- Added test_full_chain_auto_heals_isolated_fts5_corruption and
  test_mine_formats_full_chain_auto_heals_isolated_fts5_corruption as
  companions, proving the full mine()/mine_formats() chain -- not just
  the standalone validator -- actually auto-heals and succeeds end-to-end
  for the isolated case now that it's correctly classified.
- test_errors_are_isolated_fts5_classification: added the new message
  wording as an explicit regression fixture (pinned literally, not
  dependent on whatever this machine's SQLite happens to emit).

Full suite: 3302 passed, 20 skipped, 0 failed -- first fully clean run
this session. ruff check / ruff format -- clean.
2026-07-05 17:35:01 -04:00
Yusef HaddadandClaude Fable 5 578e5775b4 fix: include checkpoint and delete_by_source in _MUTATING_TOOLS
mempalace_checkpoint and mempalace_delete_by_source (added in 3.5.0) were
missing from _MUTATING_TOOLS, so a server started with --read-only /
MEMPALACE_MCP_READ_ONLY=1 still allowed writing drawers and bulk-deleting
by source. The same gap affected the peer-writer lock gate, which uses
the same frozenset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 00:42:11 -06:00
J.R. MurrayandClaude Sonnet 4.6 adc0ef876f fix: auto-heal isolated FTS5 corruption in mine, not just repair
mempalace mine aborts with the "ABORT: SQLite-layer corruption detected"
banner on an isolated FTS5 inverted-index corruption -- the specific case
maybe_autoheal_fts5_index already exists to fix in place. That helper is
wired into cmd_repair's preflight, but not into mine's own post-mine
validation (palace._validate_palace_fts5_after_mine), so mine forces a
manual `mempalace repair` for a corruption class that's already safely
self-healable.

This wires the same auto-heal call into _validate_palace_fts5_after_mine,
before it raises MineValidationError. maybe_autoheal_fts5_index returns
the *remaining* errors after the heal attempt, so MineValidationError
still raises whenever the corruption isn't the isolated, fully-healable
case -- this only changes behavior when the heal has verifiably and
fully cleared the corruption.

Verified against a real-world repro (mining a real Claude Code project
directory deterministically triggered this corruption after all files
filed successfully, zero concurrency, single uninterrupted process):
mempalace repair --yes confirmed the auto-heal path clears it before
proceeding to a full rebuild. With this patch, mine self-heals the same
case directly -- no abort, Files processed: <n>, Done, PRAGMA quick_check
clean afterward.

Test suite: 3214 passed, 20 skipped -- no new failures. Two pre-existing
unrelated failures in test_repair.py (a SQLite-version-dependent FTS5
corruption message-wording mismatch, tracked separately) reproduce
identically on unmodified develop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 01:26:57 -04:00
Tom BoucherandClaude Opus 4.8 c54531a2a2 fix: skip startup SQLite integrity check on oversized palace
The MCP server ran PRAGMA quick_check on the full chroma.sqlite3 during
startup, before answering the initialize handshake. quick_check is
O(database size); on multi-GB palaces it exceeds the MCP client's ~30s
connection timeout, so the server never finishes starting and the client
drops the connection (observed >2min on a 4.6GB palace).

Skip the startup probe when chroma.sqlite3 exceeds
MEMPALACE_STARTUP_INTEGRITY_MAX_MB (default 512MB; 0 disables). The gate
lives in _refresh_sqlite_integrity_status, the single choke point for the
startup calls and every lazy consumer. `mempalace repair` preflight still
runs the full quick_check via repair.sqlite_integrity_errors, so
SQLite-layer corruption is still caught on the destructive path.

Refs #1818.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jq495N7e2D4wY2Mp2AQvg7
2026-07-02 12:34:56 -04:00
Cheney Zhang 0104d86c53 fix: address Milvus backend review feedback
Signed-off-by: Cheney Zhang <chen.zhang@zilliz.com>
2026-07-01 07:10:39 +00:00
Cheney Zhang db6c3d6552 fix: refine Milvus backend consistency
Signed-off-by: Cheney Zhang <chen.zhang@zilliz.com>
2026-07-01 06:52:28 +00:00
Cheney Zhang bddbb5f7a5 fix: enable native Milvus Lite lexical search
Signed-off-by: Cheney Zhang <chen.zhang@zilliz.com>
2026-07-01 06:51:35 +00:00
Cheney Zhang 2c81e22e28 fix: use native Milvus lexical search
Signed-off-by: Cheney Zhang <chen.zhang@zilliz.com>
2026-07-01 06:51:35 +00:00
Cheney Zhang 9b395fe9d5 feat: add Milvus backend
Signed-off-by: Cheney Zhang <chen.zhang@zilliz.com>
2026-07-01 06:50:27 +00:00
Igor Lins e Silva da5a48caf5 Merge pull request #1719 from raman325/feat/openclaw-tool-catchup
docs(openclaw): catch up SKILL.md with 8 newer MCP tools
2026-06-30 15:39:55 -03:00
Igor Lins e Silva e096701333 docs(openclaw): document full MCP tool surface 2026-06-30 15:23:21 -03:00
Igor Lins e Silva e84221c6b7 Merge pull request #1901 from messelink/feat/latex-extensions
feat: add LaTeX (.tex, .bib) to readable and prose extensions
2026-06-30 14:38:46 -03:00
Igor Lins e Silva 0549a90df3 Merge pull request #1905 from MemPalace/docs/fix-backend-table-layout
fix(docs): wide-table layout — stop clipping, fit backend table
2026-06-30 14:12:56 -03:00
Igor Lins e Silva 281aaf489d fix(docs): make backend comparison table fit the content column
Browser-validated the table layout across desktop (1280) and mobile (375):

- Denser doc-table cell padding (8px 16px -> 8px 12px) so comparison tables
  fit the content column instead of needing a horizontal scrollbar.
- `overflow-wrap: break-word` on table-cell code so only genuinely long
  values (e.g. a Postgres DSN) wrap, while short identifiers like
  `palace_path` keep natural column sizing and stay on one line.
- Drop the redundant 'Configure with' column from the storage-backends table
  (each backend's connection variables are documented in full in its own
  subsection right below) and shorten 'Local (exact cosine)' -> 'Local
  (exact)'. The comparison table is now five columns and fits cleanly.

Verified no clipping and no page-level horizontal overflow on the
configuration, remote-server, reference (cli/mcp-tools/python-api),
claude-code, and knowledge-graph pages; wide tables scroll within their own
container on mobile.
2026-06-30 08:06:43 -03:00
Igor Lins e Silva 176c8b820b fix(docs): stop wide tables from clipping; slim backend table
The custom theme set `.vp-doc table { overflow: hidden }` to clip its
rounded corners, which also overrode VitePress's default `overflow-x: auto`
— so any table wider than the content column was clipped with no way to
scroll to the hidden columns (visible on the storage-backends table). Switch
to `overflow-x: auto` so wide tables scroll, keeping the rounded corners.

Also shorten the storage-backends table's two capability headers
(Namespace isolation -> Namespaces, Lexical search -> Lexical) so the table
fits the content column without needing the scrollbar.
2026-06-30 07:51:45 -03:00
Igor Lins e Silva 3b8d2652f4 Merge pull request #1904 from MemPalace/docs/storage-backend-config
docs(config): add storage backends configuration reference
2026-06-30 07:39:31 -03:00
Igor Lins e Silva e4924e8e94 docs(config): clarify backend selection vs configuration in table
Rename the table's 'Select with' column to 'Configure with' and list each
backend's primary connection knob, since a connection variable (e.g.
MEMPALACE_QDRANT_URL) configures a backend but does not select it — selection
is uniform via --backend / MEMPALACE_BACKEND, covered in the prose below the
table. Also state the concrete MEMPALACE_QDRANT_TIMEOUT default (10.0s).
2026-06-30 07:32:37 -03:00
Igor Lins e Silva 1577346158 docs(config): add storage backends configuration reference
Establish guide/configuration.md as the canonical home for per-backend
connection settings, with a compatibility table and connection-variable
reference for the chroma, sqlite_exact, qdrant, and pgvector backends.

remote-server.md already links Postgres + pgvector to /guide/configuration,
but the page had no backend section; this populates that target. New backends
add one table row plus a connection subsection, keeping README's compatibility
table in sync rather than accreting a prose paragraph per backend.
2026-06-30 07:12:55 -03:00
Pim MesselinkandClaude Opus 4.7 6cec591c17 feat: add LaTeX (.tex, .bib) to readable and prose extensions
LaTeX source files and BibTeX bibliographies are prose-rich content that
benefits from both palace mining and entity detection. Adds the two
extensions to the two extension lists most relevant to them, each with a
matching test.

- ``mempalace/miner.py:READABLE_EXTENSIONS`` — ``.tex`` / ``.bib`` join the
  mining allowlist (parallel to the Swift/Kotlin PR #1368 and the PHP
  ecosystem PR #1819).

- ``mempalace/entity_detector.py:PROSE_EXTENSIONS`` — ``.tex`` / ``.bib``
  also join the *preferred* entity-detection bucket alongside ``.md`` /
  ``.rst`` / ``.csv``, NOT the broader code-file fallback. The reason
  ``PROSE_EXTENSIONS`` exists separately is documented in-code:
  programming-language files have lots of capitalized identifiers (class
  names, function names) that produce false-positive person matches.
  LaTeX/BibTeX don't have that problem — they're typesetting languages
  for prose documents. ``.bib`` in particular is almost entirely author
  names, one of the highest real-entity densities of any file type the
  detector scans.

Tests follow the patterns established by the prior extension PRs:
``tests/test_miner.py::test_scan_project_includes_latex_files`` mirrors
the Swift/Kotlin scan tests, and
``tests/test_entity_detector.py::test_scan_for_detection_includes_latex_prose``
mirrors ``test_scan_for_detection_finds_prose``. The existing
``test_prose_extensions`` was extended to assert the two new entries.

Full env-cleared suite: 3216 passed, 20 skipped. ``ruff check .`` and
``ruff format --check .`` both clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA
2026-06-29 07:25:50 +00:00
Igor Lins e Silva afd0428823 feat(serve): turnkey secure remote MCP server (#1877) (#1900)
* feat(serve): turnkey secure remote MCP server (#1877)

Add `mempalace serve`: a secure-by-default wrapper over the HTTP MCP
transport so a team can stand up a shared central palace with one
command.

Server capabilities (mempalace/mcp_server.py):
- Native TLS via --tls-cert/--tls-key (env MEMPALACE_MCP_TLS_CERT/_KEY):
  wraps the socket in a TLS 1.2+ context, validated before bind. Token
  is still required on a non-loopback bind (TLS != auth).
- Read-only mode via --read-only (env MEMPALACE_MCP_READ_ONLY): the 24
  mutating tools are hidden from tools/list and refused at dispatch
  (-32003), enforced before arg handling — not merely hidden.

Turnkey command (mempalace/cli.py):
- Auto-generates a strong bearer token for non-loopback binds, stored
  0600 under ~/.mempalace/server/ and printed once; reused across
  restarts. Token rides in the child env, never argv, so it can't leak
  via ps.
- Prints a ready-to-paste client config (scheme reflects TLS), then
  foreground-execs the real server so Docker/systemd own the lifecycle.

Deployment (deploy/):
- docker-compose.server.yml wires the server + Qdrant with a /healthz
  healthcheck and persistent volumes.
- server.env.example documents the env surface.
- mempalace-server.service is a hardened systemd unit template.

Tests: TLS handshake (openssl-gated), read-only enforcement, token
autogen/0600/reuse, token-not-in-argv, secure-by-default gates.

Docs: remote-server guide now leads with `mempalace serve` plus Compose
and systemd subsections.

* test(serve): fix Windows — don't patch os.name; gate 0600 asserts to POSIX

Patching os.name to 'posix' broke Path.home() on Windows (pathlib mixed
POSIX home resolution with Windows drive parsing). Capture both exec
branches (os.execve + subprocess.run) instead, and guard the POSIX
permission-bit assertions behind os.name == 'posix' (Windows files
report 0o666).
2026-06-29 03:28:06 -03:00
Pim MesselinkandClaude Opus 4.7 cea780eb79 fix(backends): annotate EmbeddingCollection.facet_counts return type
Per Gemini review (PR #1898 comment r3489013681): the forwarder lacked the
``-> dict[str, int]`` return annotation that ``BaseCollection.facet_counts``
and the sibling ``get_all_metadata`` forwarder both carry. One-line
consistency fix, no behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA
2026-06-29 02:37:11 +00:00
Pim MesselinkandClaude Opus 4.7 2211c974ff fix(backends): forward facet_counts + get_all_metadata in EmbeddingCollection
Both methods are concrete on ``BaseCollection`` (``facet_counts`` raises
``UnsupportedCapabilityError``; ``get_all_metadata`` pages through
``self.get(include=["metadatas"])``). Python MRO resolves them on
``EmbeddingCollection`` before ``__getattr__`` ever fires, so without an
explicit forwarder the wrapper silently runs the base default instead of
delegating to the wrapped backend's optimized implementation. The pattern
matches the existing explicit forwarders for ``distance_metric``,
``lexical_search``, and the embedder-identity trio — all added to fix the
same shadow.

What this means in production for the three backends that get wrapped
(``EmbeddingCollection`` only applies to ``requires_explicit_embeddings``
backends — qdrant, pgvector, sqlite_exact; chroma is unwrapped and
unaffected):

- **facet_counts shadow (#1868 regression)**: every ``mempalace_status``,
  ``list_wings``, ``list_rooms``, ``get_taxonomy`` call routes through
  the gated ``col.facet_counts(...)`` path. The capability check passes
  (``supports_metadata_facets`` is on the backend), but the call hits the
  wrapper's MRO-resolved ``BaseCollection.facet_counts`` and raises
  ``UnsupportedCapabilityError``. ``mcp_server``'s broad ``except`` swallows
  it, logs ``WARN Failed to fetch metadata facets, falling back to client-
  side loop: backend does not support facet_counts``, and counts via the
  O(n) Python loop — the exact behavior #1868 was designed to eliminate.

- **get_all_metadata shadow (#1796 / #1892 regression)**: the BaseCollection
  default pages through ``self.get(include=["metadatas"])`` — fine for
  Chroma's SQL OFFSET cursor, but on wrapped backends (qdrant, pgvector)
  the inner's overridden ``get_all_metadata`` is unreachable. For pgvector
  specifically, this means #1892's ``with_document=False`` fast path is
  never taken even though it's implemented — every metadata-only fetch
  transfers the full document column over the wire. On a 13k-drawer remote
  pgvector palace over WAN that's ~13MB per call, dominating wall time.

Why no test caught it: backend tests (``test_qdrant_backend.py``,
``test_pgvector_backend.py``) call the methods directly on the raw
collection, not through the wrapper. ``test_mcp_server.py`` facet tests
use ``MagicMock()`` for the collection, which synthesizes attributes on
demand and bypasses MRO entirely. Neither path covers the seam where the
bug lives: ``palace.get_collection() -> EmbeddingCollection -> .method()``.

Three tests pin both the fix and the bug class:

- ``test_facet_counts_forwards_to_inner`` — direct integration through the
  wrapper, asserts the inner's recorded call matches.
- ``test_get_all_metadata_forwards_to_inner`` — same shape, plus a sentinel
  ``get()`` on the inner so a missing forwarder would route to the base
  default and pick up the wrong data (observable failure, not silent).
- ``test_wrapper_forwards_all_concrete_basecollection_methods`` — meta-test
  that enumerates every concrete public method on ``BaseCollection`` via
  ``inspect.getmembers`` and asserts each one is explicitly defined on
  ``EmbeddingCollection``. Catches the bug *class*: any future
  ``BaseCollection`` method with a concrete default body becomes a CI
  failure the moment it's added without a wrapper forwarder, with a message
  pointing straight at the file to edit.

Full env-cleared suite: 3205 passed, 20 skipped. ``ruff check`` and
``ruff format --check`` both clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA
2026-06-29 02:22:10 +00:00
Igor Lins e Silva 8ec284db5d docs(guide): add Remote / Team Server deployment guide (#1877) (#1897)
Documents running MemPalace as a central memory service for a team:
HTTP MCP transport (--transport http with bearer-token auth), a
networked backend (Qdrant via REST, no extra dep; or pgvector), and
optional GPU embedding. Covers the security model (non-loopback token
requirement, Host/Origin DNS-rebinding guard, TLS-in-front), client
connection, and operating notes. Adds the page to the guide sidebar.

Addresses #1877.
2026-06-28 20:10:09 -03:00
5dcc46ba7e feat(graph): auto-populate the associative graph from mined sessions (#1895)
* feat(graph): auto-populate the associative graph from mined sessions

Conversation mining never set the `entities` drawer metadata that hallways
consume, so mined sessions produced an empty associative graph (and starved
the entity-navigation / tunnel-recommendation features built on top of it).

Add a no-LLM structural entity extractor and wire it into the convos mine:

- entities: structural-only extractor (author-quoted code spans, URLs, file
  paths, qualified identifiers, CamelCase / snake_case symbols). No wordlists,
  no NLP models, precision-biased so prose doesn't pollute the graph.
- convo_miner: set `entities` per chunk, and compute hallways after a convos
  mine (mirroring the project-file path). Hallways run before the FTS5
  validation, which opens a direct sqlite connection that can invalidate the
  live Chroma collection handle on some Chroma builds.
- cli: `mempalace hallways` lists the associative graph (CLI parity with the
  list_hallways MCP tool).
- tests: extractor precision/ranking, entities metadata at mine time, CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(graph): address review — semicolon safety, leading-underscore snake, negative limit

- entities `_clean`: strip `;` out of tokens so a URL query string or backtick
  span can't split the `;`-joined entities metadata field
- entities `_SNAKE`: optional leading/trailing `_?` so `_extract_authored_at`
  and similar are matched in plain text (previously only caught via backticks)
- cli `hallways`: clamp `--limit` with max(0, ...) so a negative value shows
  nothing instead of slicing from the end
- tests for all three

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
2026-06-28 19:44:11 -03:00
178105cbd5 feat: optimize metadata counting using Qdrant server-side facets (#1868)
* feat: add metadata facet support for qdrant

* added benchmark

* updated benchmark

* chore: remove tracking for local scratch benchmark

* feat: add metadata facet support for qdrant -clean

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update mempalace/backends/qdrant.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update mempalace/backends/qdrant.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update tests/test_qdrant_backend.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update tests/test_qdrant_backend.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update tests/test_qdrant_backend.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update tests/test_mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* /fix always working tool_status() fallback fixed

* /fix fallback added to tool_list_rooms

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update tests/test_qdrant_backend.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* /fix rebuilt the room populating logic

* /add added temporary files for atomic transactions

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update tests/test_mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* /fix ai slop

* /fix added default facet limit

* Update tests/test_qdrant_backend.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* /fix added max workers pool

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update mempalace/mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update mempalace/backends/qdrant.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* /fix added clear()

* Update tests/test_mcp_server.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix(qdrant): validate facet filter before existence check; fix taxonomy test

- facet_counts now validates the where filter and rejects local-only
  filters before the _remote_exists() short-circuit, so an unsupported
  filter raises UnsupportedCapabilityError even on an unmaterialized
  collection (matches get()/lexical_search() ordering).
- test_tool_get_taxonomy_uses_metadata_facets compared concurrent room
  facet calls via set(), but a call() with a dict kwarg is unhashable;
  compare order-independently via membership instead.

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
2026-06-28 19:43:28 -03:00
Mikhail Valentsev 833b6ab4d3 feat(mcp): add since/before date filter to list_drawers (#1128) (#1891)
* feat(mcp): add since/before date filter to list_drawers (#1128)

mempalace_list_drawers previously filtered only by wing/room. This adds
optional since/before ISO date bounds on filed_at: since is inclusive,
before is exclusive.

The filter runs in Python after the rows are fetched. ChromaDB 1.5.7
rejects string operands for $gte/$lt and filed_at is stored as an ISO
string, so a server-side where comparison is not available; the tool
already collapses and paginates the full result set in Python.

Drawers whose filed_at is missing or unparseable are excluded while a
bound is active, and inverted bounds (since >= before) return a clear
error.

* test: close chromadb clients between tests to fix Windows handle leak (#1128)

chromadb 1.5.7 caches one System per palace path and only frees the
SQLite/HNSW file handles on client.close(); the collection fixture and
the per-test MCP cache reset only dereferenced the client, so handles
leaked across the session. Harmless on POSIX (rmtree unlinks open files),
but on Windows the handles stay locked and accumulate until an HNSW
segment write in a later test's setup fails, which surfaced here as
TestDeleteBySource::test_commit_purges_matching_closets asserting 0 == 2.

Close the client in the collection fixture and in _reset_mcp_cache so the
handles are released between tests.

* test: release backend chromadb clients between tests (#1128)

palace.get_collection() caches one PersistentClient per palace_path on the
process-wide backend singleton and never closes it; sweep, repair and several
CLI tests reach the store through it. chromadb frees the rust-side SQLite/HNSW
file handles only on client.close(), so the handles leak across the whole
session: a 30-palace probe shows ~200 open file descriptors into the palace
tree, dropping to 0 once the clients are closed.

On POSIX the open handles are harmless (rmtree unlinks open files), but on
Windows they stay locked and accumulate until a later test's HNSW segment
write fails ("Failed to apply logs to the hnsw segment writer"), e.g.
test_sweeper.py::TestSweeperTandem::test_sweep_recovers_untaken_message_at_cursor_timestamp.

Drain the cached clients in the autouse _reset_mcp_cache teardown via
close_palace(), which closes each PersistentClient (releasing its handles)
without marking the backend closed so it stays reusable. Complements the
collection-fixture and _client_cache close() added earlier.
2026-06-28 18:24:58 -03:00
faa8643d9f fix(palace): process-wide mine_palace_lock re-entrancy so the HTTP transport can write (#1859)
* fix(palace): process-wide mine_palace_lock re-entrancy for threaded HTTP transport

The MCP HTTP transport (ThreadingHTTPServer) acquires the long-lived
writer-lease on one thread (_acquire_mcp_writer_lock) but dispatches each
write request on a different worker thread. The lock re-entrancy guard was
thread-local, so write handlers (add_drawer/update_drawer) failed to see the
process-held lease, re-acquired the flock, and self-conflicted with
"palace ... is held by PID <self>". Reads worked (no lock); writes over the
HTTP transport were impossible.

Make the re-entrancy record process-wide (pid-tagged, guarded by a
threading.Lock) so a write from any thread of the process that already holds
the lease passes through. Safe: flock is per-process and HTTP writes are
serialized by _HTTP_REQUEST_LOCK. Preserves fork-safety, same-thread nesting
(miner.mine -> ChromaCollection.upsert), and cross-process protection
(MineAlreadyRunning still raised between processes).

Add cross-thread same-process regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(palace): reset lock guard on fork to avoid inherited-locked deadlock

Address review (PR #1859): `_palace_lock_guard` is a threading.Lock, so a child
forked while another thread held it would inherit it locked (the holder thread
is gone in the child) and deadlock on the next acquire. Register an
os.register_at_fork(after_in_child=...) handler that replaces the guard with a
fresh unlocked lock and clears state; the child must reacquire the flock anyway.
Guarded by hasattr(os, "register_at_fork") for Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
2026-06-28 18:24:10 -03:00
cff43adb63 feat(convo): preserve authored timestamp from transcripts (#1890)
* feat(convo): preserve authored timestamp from transcripts

Conversation drawers only carried `filed_at` (ingest time), so a bulk
re-mine collapsed every drawer to a single instant and the chronological
signal was lost — even though each Claude Code / Codex JSONL line already
carries an ISO-8601 `timestamp`. The recency-window fallback and any
date-aware consumer then saw ingest order, not when content was written.

- convo_miner: derive `authored_at` (per-file max line `timestamp`) and
  store it as drawer metadata; falls back to `filed_at` when absent
- searcher: surface `authored_at` in search results, and break exact
  hybrid-score ties toward the more recently authored drawer (ISO strings
  sort chronologically; missing dates sort oldest) — benchmark-neutral as
  it only reorders exact ties
- tests: cover `_extract_authored_at` (latest wins, skips/tolerates lines
  without timestamps, non-jsonl/missing -> None) and the tie-break

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(search): surface authored_at in CLI + backfill for existing data

Completes the authored_at work so the field is visible end-to-end and
existing palaces can adopt it without re-mining.

- layers: CLI `search` output shows an `authored:` date line per result
  (peer of the existing date; markdown drawers fall back to filed_at)
- scripts/backfill_authored_at.py: in-place migration that stamps
  authored_at on convos drawers from their source transcripts — metadata
  only (no re-embedding), idempotent, dry-run by default
- docs/authored-at.md: documents created_at (ingest) vs authored_at
  (written) and both backfill paths (in-place / drop-and-recreate)
- tests: backfill integration tests over an ephemeral ChromaDB collection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(search): address review — non-string timestamp guard + top-level authored_at tiebreak

Two correctness fixes from the PR review:

- _extract_authored_at: only compare when the parsed `timestamp` is a str.
  A non-string timestamp on a malformed/foreign JSONL line previously raised
  TypeError outside the try and could crash the mine.
- _hybrid_rank: the tie-break read `authored_at` only from nested `metadata`,
  but the search_memories path (MCP / Claude Code) carries it at the top level
  of each hit — so the tie-break silently no-op'd there. Read both shapes.
- tests: non-string timestamp cases, and a top-level-shape tie-break test
  (which fails before this fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: apply ruff format to authored_at changes

CI ruff format --check flagged 4 files; ruff check already passed.
Formatting only — no behavior change.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
2026-06-28 18:13:06 -03:00
Pim MesselinkandClaude Opus 4.7 fd3b4e8ebb fix(pgvector): skip document column for metadata-only fetches (#1840 follow-up) (#1892)
* fix(pgvector): skip document column for metadata-only fetches (#1840 follow-up)

Closes the explicit "separate follow-up to keep this low-risk" callout
in PR #1840's description.

For remote pgvector deployments (TLS over WAN), `mempalace_status` and
every other metadata-only consumer was transferring the full `document`
column over the wire even when nothing read it. A single scroll over a
177K-drawer palace on a 175 ms-RTT link moved ~150 MB of document text
plus ~50 MB of metadata; this PR drops that to ~50 MB.

scroll_rows / _scroll gain `with_document: bool = True`. When False,
SELECT projects NULL::text instead of the document column. Positional
_row parser unchanged (record[1] stays the document slot, just receives
NULL). Existing callers default to True and see byte-for-byte identical
behavior.

PgVectorCollection.get_all_metadata override: where=None path goes
single-scroll with with_document=False. Filtered path falls back to base
to keep _matches_where running on array/object metadata values (same
correctness contract as #1840's filtered-path decision).

Tests:
- Update _FakePgVectorClient.scroll_rows to accept with_document; mirror
  the NULL-becomes-empty-string semantics when False
- Update 5 existing scroll_calls assertions to include with_document=True
  (unchanged intent)
- test_pgvector_get_all_metadata_skips_document_column: assert exactly
  one scroll call with with_document=False
- test_pgvector_get_all_metadata_filtered_falls_back_to_base: assert
  filtered path preserves with_document=True

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA

* fix(pgvector): extend with_document=False fast path to filtered get_all_metadata

Per gemini-code-assist review feedback on #1892: _matches_where only reads
metadata, so the where=None vs where=set conditional fall-back was unnecessary.
The filtered path can use the same single-scroll with_document=False fast path
and apply the post-filter locally on metadata dicts — extending the wire-byte
win to every get_all_metadata caller, not just unfiltered ones.

Mirrors the pushdown + local _matches_where pattern already used by _rows
in the same file: pushdown when _requires_local_filter is False, post-filter
in Python otherwise. Same correctness contract as #1840's filtered get path.

Renames test_pgvector_get_all_metadata_filtered_falls_back_to_base to
test_pgvector_get_all_metadata_filtered_uses_fast_path and asserts the new
behavior (with_document=False + pushdown forwards the equality filter to SQL).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-28 18:02:29 -03:00
Pim MesselinkandClaude Opus 4.7 cd7a8658a2 fix(backends): require SQLite magic header for chroma + sqlite_exact detect() (#1893) (#1896)
* fix(chroma): require SQLite magic header for ChromaBackend.detect() (#1893)

Closes #1893.

ChromaBackend.detect() was returning True for a 0-byte chroma.sqlite3 file
because the check was just os.path.isfile(...). On a palace that has any
other backend marker alongside a stale 0-byte chroma.sqlite3,
resolve_backend_name then raises BackendMismatchError and the palace becomes
unopenable until the user manually rm's the empty file.

The 0-byte file appears as a side effect of any sqlite3.connect() on a
missing path — Python creates the file immediately but writes the SQLite
header only on the first statement. So any code path that touches the
chroma.sqlite3 path with bare sqlite3.connect(), including chromadb's own
PersistentClient lazy-init (see the comment at backends/chroma.py:2052),
can leave a 0-byte artifact behind.

Fix: detect() now reads the first 16 bytes and compares to the SQLite
magic prefix b"SQLite format 3\x00" instead of relying on file presence
alone. One extra open() + 16-byte read; detect() isn't a hot path.

Properties:
- Rejects 0-byte files (the symptom #1893 is about).
- Rejects non-SQLite garbage at the canonical path (partial writes, etc.).
- Doesn't false-negative on real chroma palaces: any chroma palace whose
  PersistentClient has done any work has the magic header on disk
  (verified — CREATE TABLE is enough to land the header).
- Doesn't couple detect() to chroma's specific schema; the magic header
  is stable across chromadb releases.

Test sweep: many test files used (chroma.sqlite3).touch() or
.write_bytes(b"") as a "fake palace" shortcut, exploiting the loose
isfile() check (one such site even had the comment "# pass the isfile
guard"). After this change, those stand-ins no longer register as chroma
palaces. Introduced tests/_chroma_palace_helper.py::make_minimal_chroma_sqlite
following the existing _backend_conformance.py precedent, and updated 15
call sites across 8 test files to use it. The existing
test_chroma_detect_matches_palace_with_chroma_sqlite (which encoded the
buggy semantics with write_bytes(b"")) is renamed to
test_chroma_detect_matches_palace_with_sqlite_header and now writes a
real SQLite database via the helper. Added two new tests for the
rejection paths (empty file, non-SQLite garbage).

Full env-cleared suite: 3137 passed, 20 skipped, 0 failed. ruff check
and ruff format --check both clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA

* fix(sqlite_exact): require SQLite magic header for SQLiteExactBackend.detect()

Per gemini-code-assist review on #1892 PR #1896: SQLiteExactBackend has the
same os.path.isfile() detection pattern as ChromaBackend did, with the same
0-byte-file vulnerability. Mirrors the chroma fix for repo-wide consistency.

- SQLiteExactBackend.detect() now does the same 16-byte SQLite magic-prefix
  check as ChromaBackend.detect().
- _chroma_palace_helper.py: factored its body into a private
  _write_minimal_sqlite_file() and gained a sibling
  make_minimal_sqlite_exact_sqlite() for the sqlite_exact filename. No churn
  to any existing chroma call sites.
- test_sqlite_exact_backend.py:426 (the one site that wrote b"" for
  sqlite_exact.sqlite3) updated to use the new helper.
- Three new tests in test_sqlite_exact_backend.py mirror the chroma trio:
  matches with valid header, rejects empty file, rejects non-SQLite garbage.

Full env-cleared suite: 3140 passed, 20 skipped, 0 failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-28 18:01:59 -03:00
dependabot[bot] d8b63a9521 chore(deps-dev): bump ruff from 0.15.18 to 0.15.20 (#1883)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.18 to 0.15.20.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.18...0.15.20)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 01:33:30 -03:00
dependabot[bot] d6cb868f75 chore(deps): bump docker/setup-buildx-action from 3 to 4 (#1881)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 01:33:23 -03:00
dependabot[bot] b864766b12 chore(deps): bump docker/setup-qemu-action from 3 to 4 (#1880)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 01:33:15 -03:00
dependabot[bot] 1bc5c508d5 chore(deps): bump actions/checkout from 6 to 7 (#1882)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-28 01:32:32 -03:00
5cbc37ee9b fix(cli): force UTF-8 when reading/writing .gitignore in init (#1648)
On Windows, Path.read_text() and open(path, 'a') use locale encoding
(GBK on Chinese-locale systems) before PEP 686 / Python 3.15. A valid
UTF-8 .gitignore with non-ASCII comments crashes
_ensure_mempalace_files_gitignored() with UnicodeDecodeError, which
aborts 'mempalace init' on Windows for any user whose .gitignore
contains non-ASCII text.

Force encoding='utf-8' on both read and append, with errors='replace'
on read as a defensive fallback for legacy mixed-encoding files.

Co-authored-by: ALaDingAhmad <16530935@qq.com>
Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
2026-06-28 01:31:19 -03:00
bacb935bb3 fix(layers): order L1 wake-up by recency so it surfaces the latest moments (#1630)
L1's generate() scored drawers by importance/emotional_weight/weight, and
the docstring promised "prefer high importance, recent filing". But no
ingest path (miner, convo_miner, diary, add_drawer) writes any of those
fields, so the sort collapsed to insertion order (oldest first) and
recency was never consulted. A scoped `wake-up --wing X` therefore
surfaced the *oldest* moments: the opposite of useful.

Add filed_at (present on every drawer, ISO-8601, lexically chronological)
as the secondary sort key. Importance stays primary for the day a scoring
pass populates it; filed_at is the effective signal today, making the
"recent filing" half of the promise true with data already present.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
2026-06-28 01:19:32 -03:00
Mikhail Valentsev c17d1aaf13 fix(mcp): stop clobbering host app root logger at import (#1860) (#1885)
* fix(mcp): stop clobbering host app root logger at import (#1860)

_init_logging() ran at import and called logging.basicConfig(force=True),
resetting the root logger's level, format, and handlers unconditionally. An
app that configured logging before importing mempalace.mcp_server lost its
setup: a host on DEBUG dropped to INFO, custom formatters and handlers were
replaced.

force=True existed (#1495) only to keep MEMPALACE_LOG_FILE working when root
already had handlers. This keeps that contract without the reset: configure
root only when it is unconfigured (standalone); otherwise attach a
mempalace-filtered file handler additively and leave the host's config alone.

Adds _MempalaceLogFilter so the file captures every mempalace logger (the
dotted mempalace.* family plus the flat mempalace_* names) and nothing else.

* fix(mcp): survive importlib.reload and pin file log format (#1860)

Addresses review on #1885.

Restore _logging_configured from globals() so the idempotency guard survives
importlib.reload: a reload re-executes the module body, and a plain reset would
let _init_logging() stack a duplicate file handler on root.

Set an explicit "%(message)s" formatter on the file handler so the embedded
path does not depend on logging's default formatter (which already renders the
same, but is now pinned and identical to the standalone path).

Adds a reload regression test and a format-pin assertion.
2026-06-28 01:02:59 -03:00
Igor Lins e Silva 292cf872a5 Merge pull request #1878 from MemPalace/fix/1596-fts5-autoheal
fix(repair): auto-heal isolated FTS5 inverted-index corruption (#1596)
2026-06-28 01:00:53 -03:00
Igor Lins e Silva b0a94bdef2 fix(repair): auto-heal isolated FTS5 inverted-index corruption (#1596)
Concurrent killed-mid-write mines can leave embedding_fulltext_search in a
malformed-inverted-index state that fails PRAGMA quick_check while the
underlying rows stay intact (integrity_check ok). The repair preflight then
hard-aborts before reaching the FTS5 rebuild step, so `mempalace repair`
refuses to run and full-text search stays broken — the exact loop #1596
reports. The MineValidationError banner even promises "repair --yes rebuilds
the FTS5 virtual table automatically," which the preflight abort made false.

Add maybe_autoheal_fts5_index(): when every quick_check error is an isolated
"malformed inverted index for FTS5 table" failure, rebuild the index in place
from the intact embedding_fulltext_search_content table
(INSERT ... VALUES('rebuild')) under mine_palace_lock, then re-run quick_check.
The rebuild touches no drawer rows. Wired into both repair preflights
(rebuild_index and cli cmd_repair). Any non-FTS5 error in the set, a lock held
by a live mine, or a rebuild that does not clear quick_check leaves the errors
unchanged so the caller still aborts with the recovery banner — broader
corruption is never silently rebuilt over.
2026-06-26 03:13:44 -03:00
Igor Lins e Silva 73e74bfb80 Merge pull request #1872 from MemPalace/fix/1716-all-layer-0-quarantine
fix(chroma): stop quarantining valid all-layer-0 HNSW segments (#1716)
2026-06-26 03:06:00 -03:00
Igor Lins e Silva dc433824fd fix(chroma): stop quarantining valid all-layer-0 HNSW segments (#1716)
An empty link_lists.bin is not corruption on its own: hnswlib stores the
layer-0 graph inside data_level0.bin and only writes link_lists.bin for
elements promoted to level > 0. A small/low-fanout index where every
element stays on layer 0 serializes an empty link_lists.bin and loads
fine. Flagging that shape as corrupt produced a self-perpetuating
quarantine loop — repair rebuilt the byte-identical all-layer-0 segment,
the next cold start re-quarantined it, accumulating drift dirs (221 MB in
the reported case) with no ingestion involved.

Use the persist-completion marker as the discriminator instead. ChromaDB
writes index_metadata.pickle last, so an intact pickle envelope proves the
flush finished and the empty link_lists.bin is the legitimate all-layer-0
shape. Only treat an empty link_lists.bin as a partial flush when there is
real payload AND no completion marker (absent or truncated pickle). The
#1457 partial-flush protection (real payload, no/truncated marker) is
preserved; the byte-sniff is factored into _hnsw_metadata_marker_intact
and reused by _segment_appears_healthy.

Also fixes the related single-writer stale-quarantine false positive
(#1564), which shares this all-layer-0 root cause.
2026-06-26 00:27:36 -03:00
Joe Sebastian 0f3f8ec652 fix: expand tilde in palace_path when read from config file\n\nMempalaceConfig.palace_path correctly called os.path.expanduser() for\nenv-var paths but not for paths read from config.json. If config.json\nstores palace_path as '~/.mempalace/palace' (the default written by\ninit), the tilde was returned unexpanded.\n\nDownstream callers such as cli.py cmd_mine did call expanduser when\n--palace was passed explicitly, but fell through to MempalaceConfig()\nwhen no flag was given, inheriting the unexpanded string. Python's\nos.makedirs and chromadb.PersistentClient treat a leading tilde as a\nliteral directory name rather than the home directory, so the palace\nwas silently written to a CWD-relative path such as\nmy_project/~/.mempalace/palace.\n\nThe fix is a single os.path.expanduser() call on line 343 of\nconfig.py, mirroring the existing env-var branch on line 342. Since\nDEFAULT_PALACE_PATH is already expanded at module load (line 197),\nexpanduser on an absolute path is a no-op, so the default case is\nunaffected.\n\nSymptoms: scattered {project}/~/.mempalace/palace directories, palace\nalways appears empty after mine, search returns Collection does not\nexist, launchd-driven nightly mine writes to a different location than\ninteractive mine.\n\nCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>n (#1865) 2026-06-26 00:09:51 -03:00
David FinkelsteinandDavid Finkelstein 5294d3118e fix(hooks): hide conhost window on Windows in _mine_sync non-daemon path (#1863)
The non-daemon synchronous mine fallback in _mine_sync() spawned the
mine subprocess without CREATE_NO_WINDOW, flashing a visible console
window on every PreCompact fire on Windows. The async paths
(_spawn_mine, _desktop_toast) already pass it via _detached_popen_kwargs();
this sync path was missed. getattr(..., 0) is a no-op off-Windows.

Fixes #1862

Co-authored-by: David Finkelstein <david@finkelstein.us>
2026-06-25 23:59:08 -03:00
Ivan AntsimonauandIvan Antsimonau aac947a5c5 Fix/wing slug special chars (#1852)
* fix: sanitize wing slug for project dirs with special characters

Project folders containing characters outside sanitize_name's set
(e.g. a leading '+') leaked into the derived wing name, producing names
like 'wing_+project' that config.sanitize_name rejects, silently
breaking diary auto-save for that project.

Add _safe_wing_slug(): collapse non-word runs to '_', trim, and fall
back to 'sessions' when a name reduces to nothing. Route the three
wing-derivation sites through it.

Tests: unit cases for the helper plus a hypothesis property test
asserting wing_<slug> always passes sanitize_name for any input.

* fix: preserve dots and apostrophes in wing slug for backward compatibility

The first pass collapsed every non-word character (including dot and
apostrophe) to underscore, renaming existing valid wings — e.g. my.app
became wing_my_app — which would orphan diary entries already filed under
the old name. Keep dot and apostrophe (both accepted by sanitize_name),
collapse consecutive dots to avoid the path-traversal rejection, and trim
edge separators.

Add backward-compatibility tests for previously-valid names plus a
double-dot collapse test.

* fix: cap wing slug length to stay within sanitize_name's limit

sanitize_name rejects names over 128 characters, so a very long project
directory name would produce a wing name that fails validation,
re-triggering the silent auto-save break this PR fixes. Truncate the slug
to 120 chars (the wing_ prefix keeps the total under 128).

Widen the hypothesis property test to max_size=300 so it exercises the
length path, and add an explicit truncation test.

Addresses gemini-code-assist review feedback on PR #1852.

---------

Co-authored-by: Ivan Antsimonau <ivan.antsimonau@katim.com>
2026-06-25 23:59:05 -03:00
skblue 86546348e4 fix(cli): add repair rebuild-index alias (#1670) 2026-06-25 23:59:02 -03:00
Eldar Shlomi d20ac0adf1 fix: spawn daemon with CREATE_NO_WINDOW to match hook miner (#1783) (#1857)
daemon.py:_detached_kwargs was the last production spawn site still using DETACHED_PROCESS. Swap it to CREATE_NO_WINDOW, matching the hook miner's _detached_popen_kwargs fixed in #1848 — the dedicated follow-up the review bot asked for. `grep -rn DETACHED_PROCESS mempalace/` now returns zero production hits.

Survivability is unchanged: CREATE_BREAKAWAY_FROM_JOB (escapes the parent Job Object's kill-on-close) plus the daemon never being attached to the launching console carry survive-terminal-close; CREATE_NEW_PROCESS_GROUP (also kept) isolates Ctrl-C/Break. CREATE_NO_WINDOW is ignored when OR'd with DETACHED_PROCESS, so this replaces the flag rather than adding it. The daemon already redirects stdout/stderr to daemon.log and reads no stdin, so it needs no console.

Adds the first tests for _detached_kwargs (posix + windows, cross-platform monkeypatch of the Windows-only flag constants, mirroring the #1848 hooks_cli tests).
2026-06-25 23:51:22 -03:00
Igor Lins e Silva d479d6f245 Merge pull request #1869 from arnoldwender/test/wal-error-paths
test(wal): cover WAL crash-safety, idempotent setup, and redaction edge paths
2026-06-25 23:46:50 -03:00
mvalentsev c4ce4c89d2 fix(mine): route SKIP to stderr and cover stat() OSError arm (#923)
The original commit printed SKIP for oversized files to stdout but the
sibling SKIP for symlinks in the same scan_project / scan_convos already
went to stderr. Align the new line with that convention.

Also adds a SKIP-with-error log for the except OSError arm right below the
size check. Files whose stat() raises (permission denied, racing delete,
broken symlink that survived the earlier is_symlink check) were the same
bug class as the silent oversize drop.

Tests switched from captured.out to .err and tightened to the full
template; new test covers the OSError arm via a selective Path.stat
monkeypatch with a follow_symlinks gate for Python 3.10+.
2026-06-25 22:20:03 +05:00
mvalentsev ca11a455f5 ci: retrigger after #1520 unblocked develop lint 2026-06-25 22:20:03 +05:00
mvalentsev 4700c44163 ci: retry macos chromadb pyo3 flake on test_missing_db_invalidates_cache 2026-06-25 22:20:03 +05:00
mvalentsev a426587d35 style: apply ruff format to print statements 2026-06-25 22:20:03 +05:00
mvalentsev c7428c3b71 fix(mine): log warning when files exceed MAX_FILE_SIZE (#923)
Both miner.py and convo_miner.py silently skip files larger than the
10 MB limit with a bare continue. This is especially painful for
conversation mining where long Claude/ChatGPT exports routinely
exceed 10 MB and vanish with no trace.

Print a SKIP warning per oversized file, matching the existing format
in split_mega_files.py.
2026-06-25 22:20:03 +05:00
Arnold Wender c80f6537ec test(wal): cover crash-safety, idempotent setup, and redaction edge paths
The write-ahead log gained its own module in v3.5.0 but sat at 82% coverage;
the uncovered lines were exactly the failure/guard branches that uphold its
contracts: the cache-hit early return, the restricted-FS chmod/mkdir swallow
paths, and the promise that a WAL write failure is logged and never crashes
the calling tool. Add five tests covering those branches plus the non-string
redaction marker, bringing mempalace/wal.py to 100% and locking the
crash-safety guarantees against regression. Test-only; no production change.
2026-06-24 22:58:08 +02:00
Igor Lins e Silva 6ea60c6043 Merge pull request #1864 from MemPalace/codex/security-bundle-guards
fix: tighten local guards and file handling
2026-06-24 02:16:42 -03:00
Igor Lins e Silva d59ca24647 fix: green up CI for hardened file handling
- ruff format llm_client.py and miner.py (lint job)
- _copy_file_no_follow: close src fd if the dst open fails (no leak), and
  route the rebuild restore through it so backup + restore share one
  no-follow/regular-file path
- update repair tests to assert the unified hardened copy instead of the
  removed shutil.copy2 calls; backup paths are now timestamped
- update normalize large-file test to stub fstat (size is checked on the
  open fd, not via a pre-open os.path.getsize)
2026-06-24 01:12:24 -03:00
Igor Lins e Silva e9746df6fd fix: restore convo miner scan indentation 2026-06-23 23:47:13 -03:00
Igor Lins e Silva 2366582fe3 fix: tighten local guards and file handling 2026-06-23 23:45:03 -03:00
Igor Lins e Silva 2c08bb251d Merge pull request #1855 from MemPalace/develop
Release v3.5.0 — promote develop to main
2026-06-22 22:00:13 -03:00
Igor Lins e Silva 65fa1517a1 Merge pull request #1853 from MemPalace/chore/release-3.5.0
chore(release): 3.5.0
2026-06-22 16:48:16 -03:00
Igor Lins e Silva e8f96dd8b2 chore(release): 3.5.0
Bump version to 3.5.0 across version.py, pyproject.toml, the Claude/Codex
plugin manifests, the README badge, and uv.lock. Refresh the "N MCP tools"
prose from 34 to 35 (delete_by_source #1729 and checkpoint #1851 each added a
tool). Add the 3.5.0 CHANGELOG entry.
2026-06-22 16:39:37 -03:00
Igor Lins e Silva 270f1aede9 Merge pull request #1854 from MemPalace/ci/windows-chromadb-flake-retries
ci(test-windows): retry the transient ChromaDB HNSW compaction flake
2026-06-22 16:38:31 -03:00
Igor Lins e Silva 37194cf543 ci(test-windows): retry the transient ChromaDB HNSW compaction flake
ChromaDB's rust HNSW core intermittently fails compaction on Windows with
"Failed to apply logs to the hnsw segment writer" during add/update — a
long-standing, non-reproducible-on-Linux/macOS flake that hits different tests
(test_migrate_wings, test_closets) across unrelated commits and has been
turning otherwise-green release/CI runs red at random.

Add pytest-rerunfailures and wire `--reruns 2 --only-rerun "Failed to apply
logs to the hnsw segment writer"` into the test-windows job only. The
--only-rerun scope means a real, deterministic failure still fails on the first
run; only this specific transient native-dependency error is retried. The
Linux and macOS jobs deliberately keep zero reruns so genuine regressions
surface there loudly.
2026-06-22 16:01:08 -03:00
Igor Lins e Silva 58ea91b44d Merge pull request #1806 from fatkobra/fix/1801-http-transport-stdio
feat(mcp): add opt-in HTTP transport
2026-06-22 13:05:13 -03:00
Igor Lins e Silva 1095d684ff fix(mcp): harden HTTP transport — DNS-rebinding guard, optional token, real tests
The opt-in HTTP transport reuses the stdio dispatcher and binds loopback by
default, but /mcp was unauthenticated with no protection against a malicious
web page reaching a DNS-rebound localhost server, and its tests reached for
Starlette/uvicorn (not project deps) so they were silently skipped in CI —
the production _serve_http handler had zero coverage.

Hardening:
- Pin the Host header to loopback literals + the bound host on a loopback bind
  (DNS-rebinding defense); relaxed for a deliberately non-loopback bind, which
  is the operator's call and may sit behind a Host-rewriting proxy.
- Reject any browser Origin that isn't a loopback origin (rebinding/SSRF guard);
  non-browser MCP clients omit Origin and are unaffected.
- Optional bearer token via MEMPALACE_MCP_HTTP_TOKEN (constant-time compare);
  required on /mcp, never on /healthz so liveness probes work credential-free.
- Warn loudly when bound to a non-loopback host (palace reachable from network).

Testability:
- Split _build_http_server() out of _serve_http() so tests bind 127.0.0.1:0 and
  drive the real handler over a loopback socket via stdlib http.client.
- Replace the skipped Starlette reimplementation with 12 tests covering dispatch,
  initialize, /healthz, 404, parse-error, the 16 MiB cap, notification 202, and
  the Host/Origin/token rejections — no third-party deps.
2026-06-22 12:40:03 -03:00
fatkobra 32fad4844d fix(lint): resolve 7 ruff errors in test_mcp_http_transport 2026-06-22 12:40:03 -03:00
fatkobra eff887d12f fix(tests): rewrite test_mcp_http_transport for Python 3.9-3.13 + Windows 2026-06-22 12:40:03 -03:00
fatkobra ce00cf0718 fix reviewer: handling JSON-RPC 2026-06-22 12:40:03 -03:00
fatkobra 3e3fcf29fe fix(mcp): move _HTTP_REQUEST_LOCK 2026-06-22 12:40:02 -03:00
fatkobra 569beff342 fix(mcp): move _HTTP_REQUEST_LOCK and _HTTP_MAX_REQUEST_BYTES 2026-06-22 12:40:02 -03:00
fatkobra 3fbab55cab test(mcp): make HTTP transport tests network-free 2026-06-22 12:40:02 -03:00
fatkobra 1a47b78dcb test(mcp): make HTTP transport loopback tests proxy-free 2026-06-22 12:40:02 -03:00
fatkobra 960eaac851 test(mcp): bypass proxies in HTTP transport loopback tests 2026-06-22 12:40:02 -03:00
fatkobra 54e9f46027 test(mcp): avoid subprocess flakiness in HTTP transport tests 2026-06-22 12:40:02 -03:00
fatkobra 2e8ff64e3f test(mcp): keep HTTP transport tests Python 3.9 compatible 2026-06-22 12:40:02 -03:00
fatkobra 94519b91dd fix suggestion of reviewer to avoid a critical race condition and other fixes 2026-06-22 12:40:02 -03:00
fatkobra 2f92774eec feat(mcp): add opt-in HTTP transport 2026-06-22 12:40:02 -03:00
Igor Lins e Silva 9902e3a9a5 Merge pull request #1729 from arnoldwender/feat/mcp-delete-by-source
feat(mcp): add mempalace_delete_by_source bulk-cleanup tool (#1722)
2026-06-22 12:37:59 -03:00
Igor Lins e Silva a91f7e06c7 Merge pull request #1820 from fatkobra/fix/1814-session-end-hook
fix(claude-plugin): run final mine on SessionEnd
2026-06-22 12:37:19 -03:00
Igor Lins e Silva 971e5a75c8 Merge pull request #1851 from undeadindustries/feat/cursor-checkpoint-tool
feat: add mempalace_checkpoint batch save tool
2026-06-22 12:37:09 -03:00
Igor Lins e Silva 7392ab8a25 Merge pull request #1817 from mvalentsev/fix/1815-source-file-filter
feat(search): add an optional source_file filter to mempalace_search (#1815)
2026-06-22 12:36:58 -03:00
Igor Lins e Silva e38af8c2e9 Merge pull request #1823 from fatkobra/fix/1818-mcp-peer-writer
fix(mcp): refuse second writer for same palace
2026-06-22 12:36:33 -03:00
Igor Lins e Silva 70ec8e450f Merge pull request #1832 from fatkobra/fix/1796-qdrant-bulk-metadata-scroll
fix(backends): single-scroll bulk metadata fetch for Qdrant; bump scroll page size (#1796)
2026-06-22 12:36:15 -03:00
Igor Lins e Silva a11cf4b667 Merge pull request #1786 from MemPalace/dependabot/github_actions/docker/metadata-action-6
chore(deps): bump docker/metadata-action from 5 to 6
2026-06-22 12:36:08 -03:00
Igor Lins e Silva a9344937df Merge pull request #1787 from MemPalace/dependabot/github_actions/docker/build-push-action-7
chore(deps): bump docker/build-push-action from 6 to 7
2026-06-22 12:36:01 -03:00
Igor Lins e Silva abd780ee36 Merge pull request #1788 from MemPalace/dependabot/github_actions/docker/login-action-4
chore(deps): bump docker/login-action from 3 to 4
2026-06-22 12:35:54 -03:00
Igor Lins e Silva db90cdf132 Merge pull request #1849 from undeadindustries/fix/1843-recovery-guidance
fix: point diverged-index recovery at from-sqlite, not re-mine (#1843)
2026-06-22 12:35:34 -03:00
Igor Lins e Silva 91e2158213 Merge pull request #1847 from mvalentsev/fix/1843-repair-from-sqlite-guidance
fix(repair): point index-read failures to repair --mode from-sqlite (#1843)
2026-06-22 12:35:26 -03:00
Igor Lins e Silva b4a6ffdf9f Merge pull request #1822 from fatkobra/fix/1816-hnsw-strict-repair-kg
fix(chroma): route stale hnsw divergence to sqlite fallback
2026-06-22 12:35:03 -03:00
Igor Lins e Silva 92ac104e31 Merge pull request #1848 from eldar702/fix/1783-create-no-window
fix: use CREATE_NO_WINDOW so Windows hook miner spawns don't flash a console (#1783)
2026-06-22 12:34:43 -03:00
Igor Lins e Silva e576c98ed2 Merge pull request #1798 from trek-e/fix/fact-checker-runpy-warning
fix(tests): run fact_checker __main__ via subprocess to clear runpy warning
2026-06-22 12:34:39 -03:00
Igor Lins e Silva fff8546672 Merge pull request #1769 from techempower-org/test/pgvector-live-conformance
test(backends): live-substrate conformance module for pgvector
2026-06-22 12:34:35 -03:00
Igor Lins e Silva b8c6bfe504 Merge pull request #1840 from mvalentsev/fix/1830-pgvector-pagination
fix(pgvector): push get(limit, offset) pagination into SQL (#1830)
2026-06-22 12:34:31 -03:00
Igor Lins e Silva 7d311af78e Merge pull request #1842 from mvalentsev/fix/sqlite-exact-pagination
fix(backends): push sqlite_exact get(limit, offset) pagination into SQL (#1841)
2026-06-22 12:34:27 -03:00
Igor Lins e Silva 5ae2315f80 fix(mcp): purge matching closets in delete_by_source (#1722)
delete_by_source removed only the drawers, leaving the matching closets
(the AAAK index layer, keyed independently by source_file) behind as stale
pointers at the now-deleted source. Mirror the closet-purge step used by
sync_palace / purge_file_closets: after the drawer delete, best-effort purge
the closets via push-down delete(where=...) so it survives large palaces and
can never abort an already-committed drawer delete.

Dry run now also reports closet_match_count so the caller sees the full blast
radius; commit reports closets_deleted. Adds tests that seed the closet
collection directly (tool_add_drawer doesn't build closets) and assert the
matching closets are purged on commit and counted on dry run.
2026-06-22 12:24:29 -03:00
undeadindustriesandCursor 352eb67a90 test: update Cursor followup assertion for checkpoint tool
The save-hook followup now drives a single mempalace_checkpoint call, so
test_threshold_emits_followup_message must assert that tool name instead
of the old add_drawer/check_duplicate/diary_write trio. Fixes the
test-macos / test-linux CI failures on this branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 13:54:09 +10:00
undeadindustriesandCursor f3ed6f796e fix: harden tool_checkpoint input validation
Address PR review: guard untrusted MCP client payloads in
mempalace_checkpoint so a single malformed item cannot raise deep in
sanitization and abort the whole batch.

- coerce dedup_threshold to float
- require wing/room/content to be non-empty strings (skip + record error)
- validate the diary object and entry type, recording errors instead of
  silently ignoring a malformed diary

On a genuine dedup-check error we still file the drawer rather than skip:
verbatim recall is the priority and add_drawer's idempotency blocks exact
duplicates. Adds tests for the non-string, dedup-error, and malformed-diary
paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 13:46:22 +10:00
undeadindustriesandCursor 4291fecfdf feat: add mempalace_checkpoint batch save tool
Collapse the Cursor auto-save sequence (check_duplicate Nx + add_drawer
Nx + diary_write 1x) into a single mempalace_checkpoint MCP call so the
host UI renders one tool-call card and keeps its spinner up for the whole
save. The new tool reuses the existing single-item handlers, so semantic
dedup, idempotency, and verbatim guarantees are unchanged.

- mcp_server.py: add tool_checkpoint + register mempalace_checkpoint
- service.py: classify mempalace_checkpoint as a write tool
- cursor save hook: followup now drives one mempalace_checkpoint call
- docs: new mcp-tools.md section, help.md entry, 33 -> 34 tool count sweep
- tests: checkpoint add/dedup/malformed/registry + classify_tool

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 13:41:15 +10:00
undeadindustriesandCursor d7e182a272 docs: add Windows backup alternative to corrupt-index recovery (#1843)
Gemini review on PR #1849: the optional palace backup step used the
Unix-only `cp -a`, which fails on Windows. MemPalace ships on win32, so
add a PowerShell `Copy-Item` alternative alongside the macOS/Linux form
and note that `--archive-existing` already moves the old palace aside.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:57:19 +10:00
undeadindustriesandCursor 65d07045bd fix: point diverged-index recovery at from-sqlite, not re-mine (#1843)
A diverged HNSW index (for example after a failed chromadb compaction)
leaves the drawer rows intact in chroma.sqlite3 but the vector index out
of sync. Re-mining to recover silently drops drawers added through the
MCP server and diary entries, which have no source file.

- repair-status now recommends `mempalace repair --mode from-sqlite
  --archive-existing` when DIVERGED, instead of the generic `mempalace
  repair`, and explains why re-mining loses data.
- The shared recall protocol and the recall skills (Cursor + Claude
  plugin) document the compactor / "Not connected" recovery path:
  stop the server, rebuild from SQLite, verify, restart — never repair
  in-process from the agent.

Complements #1847 (legacy repair error messages); does not duplicate it.
Does not close #1843 — MCP reconnect resilience and honest add_drawer
write signalling remain open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:50:16 +10:00
eldar702 fb8dc1f677 fix: use CREATE_NO_WINDOW so Windows hook miner spawns don't flash a console (#1783)
Fixes #1783
2026-06-21 20:33:55 +03:00
mvalentsevandundeadindustries 44016ad118 fix(repair): point index-read failures to repair --mode from-sqlite (#1843)
When the chromadb compactor cannot apply the WAL into the drawers HNSW
segment (InternalError: Failed to apply logs to the hnsw segment writer),
the legacy repair paths fail on their first Collection.count() read and
advise re-mining from source files. The drawer rows are intact in
chroma.sqlite3, so repair --mode from-sqlite rebuilds them; re-mining
silently drops drawers added via the MCP server and diary entries that
have no source file.

Both legacy read-failure sites (cmd_repair and rebuild_index) now emit
shared guidance pointing at the from-sqlite recovery, worded conditionally
so it also covers a live server or mine still holding the palace open.

Co-Authored-By: undeadindustries <9536461+undeadindustries@users.noreply.github.com>
2026-06-21 22:17:01 +05:00
mvalentsev c203aacf8a ci: re-trigger Windows (flaky closet-boost test) 2026-06-21 18:20:12 +05:00
mvalentsev 4c4c4aba8d fix(mcp): reject non-string source_file with a clean error (#1815)
A JSON number or boolean passed for source_file is not coerced by the
string schema type, so it reached _sanitize_optional_source_file and
raised AttributeError from .strip() rather than a clean validation error.
Add an isinstance guard that raises ValueError, which tool_search returns
as a structured error. Regression test added.
2026-06-21 18:20:12 +05:00
mvalentsevandrendigua2025-gif 7fb7bd3b8d feat(search): add an optional source_file filter to mempalace_search (#1815)
Expose source_file alongside wing/room on mempalace_search. build_where_filter
generalizes to 0/1/2+ clauses and the filter threads through the main vector
path, the index-mismatch fallback, the vector-disabled BM25/SQLite path, and
the union lexical path so it never silently no-ops. Matching is on the exact
full stored value; results now expose source_path (the full path) for round
tripping, since the displayed source_file is a basename. The MCP schema gains
the source_file property and a path-tolerant sanitizer rejects null bytes,
lone surrogates, and overlong values.

Fixes #1815

Co-Authored-By: rendigua2025-gif <253093224+rendigua2025-gif@users.noreply.github.com>
2026-06-21 18:20:12 +05:00
fatkobra 8723d30083 test(mcp_server): add missing _fetch_all_metadata delegation/fallback tests. 2026-06-21 11:29:25 +00:00
fatkobra 2ec48dde56 refactor(qdrant): reuse _rows() in get_all_metadata(); fix sys.modules test pollution
Addresses maintainer review on #1832 (the two non-blocking 🟡 items plus
two 🟢 nits)
2026-06-21 10:57:47 +00:00
mvalentsev 6cc5832176 ci: re-trigger checks (unrelated Windows closet flake) 2026-06-21 05:28:34 +05:00
mvalentsev 386f3c9796 fix(backends): push sqlite_exact get(limit, offset) pagination into SQL
SQLiteExactCollection.get(limit, offset) fetched the whole collection via
_rows() (SELECT ... FROM documents ORDER BY rowid, no LIMIT/OFFSET) and sliced
in Python, so every paginating caller (prefetch_mined_set, status, exporter,
migrate, dedup, sync, ...) re-scanned the entire table per page, making the
sweep O(N^2) in rows materialized.

Push LIMIT/OFFSET into the scan on the unfiltered page (no ids/where/
where_document and non-negative bounds); filtered, id, and negative pages keep
the full-scan plus Python-slice path so the post-filter still runs first. SQLite
requires a LIMIT before OFFSET, so an offset-only page uses LIMIT -1. ORDER BY
rowid keeps pages stable.
2026-06-21 05:11:15 +05:00
Igor Lins e Silva e0da99b2d5 Merge pull request #1839 from MemPalace/fix/graph-stats-sqlite-uri
fix(mcp): route _sqlite_graph_stats through sqlite_read_uri
2026-06-20 20:33:53 -03:00
Igor Lins e Silva 3eacbe0c43 Merge pull request #1834 from mvalentsev/fix/pgvector-surrogate
fix(pgvector): replace lone surrogates so a transcript surrogate no longer aborts the mine (#1833)
2026-06-20 20:11:47 -03:00
mvalentsev a27129a32a fix(pgvector): replace lone surrogates so a transcript surrogate no longer aborts the mine (#1833)
A lone UTF-16 surrogate (U+D800-U+DFFF) in transcript content has no UTF-8
encoding, so pgvector's bulk upsert_rows makes psycopg raise UnicodeEncodeError
and the whole mine aborts, leaving later files unmined.

Apply config.strip_lone_surrogates (-> U+FFFD) to id, document, and the
serialized metadata JSON in upsert_rows. json.dumps(ensure_ascii=False) leaves a
metadata surrogate raw in the string, so one pass over the serialized JSON covers
it; NUL, by contrast, json-escapes and must be stripped before serialization
(see #1829). Replace rather than drop, matching ChromaDB's document handling.

Verified end to end against live Postgres + pgvector: before, a surrogate in
document or metadata aborts the mine; after, it ingests and round-trips as U+FFFD.

Fixes #1833
2026-06-20 20:10:37 -03:00
Igor Lins e Silva 4791999516 Merge pull request #1831 from mvalentsev/fix/1829-pgvector-nul
fix(pgvector): strip NUL bytes so a transcript NUL no longer aborts the mine (#1829)
2026-06-20 20:07:10 -03:00
mvalentsevandhrabbach fa27e41436 fix(pgvector): push get(limit, offset) pagination into SQL (#1830)
PgVectorCollection.get(limit=, offset=) ignored pagination at the SQL
layer: scroll_rows ran SELECT ... WHERE <where> with no LIMIT/OFFSET, so
get() fetched the whole table and sliced in Python. prefetch_mined_set
pages the whole palace on every mine, so mining was O(N^2) in rows
transferred and Python objects built as the palace grows; every other
paginating caller (exporter, migrate, repair, hallways, closet_llm,
miner, palace_graph) paid the same cost.

Push LIMIT/OFFSET into scroll_rows/_scroll with ORDER BY id (the primary
key) for stable offset pagination. get() uses the pushed path only for an
unfiltered page (no ids, no where/where_document, non-negative bounds); a
filtered get keeps the full-scan path because the metadata @> ... pushdown
is broader than the exact _matches_where re-filter for array/object
values, so that re-filter must run before pagination. Full-scroll callers
pass no bound, so their SQL is unchanged.

Co-authored-by: hrabbach <181709360+hrabbach@users.noreply.github.com>
2026-06-21 04:00:32 +05:00
Igor Lins e Silva 31fff1c354 fix(mcp): route _sqlite_graph_stats through sqlite_read_uri
The graph_stats sqlite reader (#1837) and the sqlite_read_uri encoding fix
(#1838) landed in parallel, so _sqlite_graph_stats was the one reader left on
the naive f"file:{db_path}?mode=ro" construction that mis-parses paths with
spaces/special chars. Convert it too — now every read-only sqlite reader
percent-encodes its path. sqlite_read_uri is already imported in mcp_server
from #1838, so this is a call-site-only change.
2026-06-20 19:25:53 -03:00
Igor Lins e Silva fe460c4b8b Merge pull request #1838 from MemPalace/fix/sqlite-ro-uri-encoding
fix: percent-encode sqlite read-only URIs for spaced/special-char paths
2026-06-20 19:24:16 -03:00
Igor Lins e SilvaandCopilot Autofix powered by AI 73772cb707 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-20 19:24:07 -03:00
Igor Lins e Silva 38253b1f5f fix: percent-encode sqlite read-only URIs so spaced/special-char paths open
sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) mis-parses paths
containing spaces or other URI-reserved characters — common in real home
directories (a Windows "First Last" user folder, many macOS paths), and made
worse by Windows backslashes. The database silently fails to open and the
read-only fast paths fall back (or error) on those machines.

Add config.sqlite_read_uri(), which percent-encodes the path via
urllib.request.pathname2url (lazy-imported to keep config import light), and
route every read-only sqlite reader through it:
- mcp_server._tool_status_via_sqlite
- searcher BM25 sqlite fallback
- repair (status / scan / max-seq read paths)
- backends/chroma (5 readers: counts, wing/room tally, id maps, etc.)

All previously used the same naive f-string construction. Surfaced as a
gemini-code-assist review note on #1837.
2026-06-20 18:56:12 -03:00
Igor Lins e Silva 9d5a375017 Merge pull request #1837 from MemPalace/fix/graph-stats-sqlite-fast-path
perf(mcp): sqlite fast path for graph_stats (#1379)
2026-06-20 18:53:56 -03:00
Igor Lins e Silva 73f455c7d0 fix: address PR review feedback on graph_stats sqlite fast path (#1379)
- Soft-fallback on any exception, not just sqlite3.Error, so an unexpected
  schema shape tripping the reconstruction degrades to build_graph() instead
  of raising — matching the sibling sqlite fast paths (Copilot).
- Guard an empty/None _config.palace_path before building db_path (Gemini).
- Test: tripwire _get_collection in addition to graph_stats, directly
  asserting the fast path never opens the chroma client / cold-loads HNSW
  (Copilot).
2026-06-20 18:39:47 -03:00
Igor Lins e Silva 477aa362cd perf(mcp): sqlite fast path for graph_stats to fix large-palace timeouts (#1379)
tool_graph_stats built the whole palace graph via build_graph(), which pages
every metadata row (col.get limit/offset) and cold-loads the HNSW index — the
remaining overview-tool timeout from #1379 (#1836 fixed status / list_wings /
list_rooms / get_taxonomy but deliberately left graph_stats out, as it builds
an in-memory graph rather than a flat tally).

Add _sqlite_graph_stats(): one GROUP BY room, wing, hall over chroma.sqlite3,
reconstructing build_graph's room_data and the same stats (total_rooms,
tunnel_rooms, total_edges, rooms_per_wing, top_tunnels) with the same
per-drawer filter (room present, != "general", wing present) and edge
semantics (C(wings, 2) * halls per multi-wing room). Same _is_chroma_backend()
guard + client-path fallback as the #1748 overview tools.

Test seeds a real chroma palace mirroring the build_graph parity case in
test_palace_graph, with a tripwire on graph_stats proving the fast path runs
and that "general"/wing-less drawers are excluded. Idea adapted from #1381's
_sqlite_graph_stats.
2026-06-20 18:30:43 -03:00
Igor Lins e Silva 2eda1f9130 Merge pull request #1836 from MemPalace/fix/perf-status-and-embedder-threads
perf: sqlite fast-path for overview tools + embedder thread cap (#1748, #1379, #1068)
2026-06-20 18:05:57 -03:00
Igor Lins e Silva 8bdebe1da1 fix: address PR review feedback (preserve "unknown" label; use super().model)
#1748: normalize the sqlite fast path's "?" COALESCE placeholder (and None)
back to "unknown" inside _sqlite_taxonomy, so drawers missing wing/room
metadata keep the client path's output contract — no observable API change
for MCP clients on legacy/partial drawers.

#1068: invoke the parent embedder build via super().model instead of reaching
into cached_property's .func attribute, so the uncapped/fallback path survives
chromadb changing `model` to a plain @property or other descriptor.
2026-06-20 18:05:15 -03:00
mvalentsevandhrabbach abaf09be0a fix(pgvector): strip NUL bytes so a transcript NUL no longer aborts the mine (#1829)
PostgreSQL cannot store NUL (0x00) in text or jsonb. On the pgvector write path
a NUL in `document` is rejected by psycopg ("PostgreSQL text fields cannot
contain NUL (0x00) bytes") and a NUL in `metadata` becomes a JSON unicode escape
the jsonb cast rejects ("unsupported Unicode escape sequence"). `_execute`
re-wraps either as BackendError and `_mine_impl` re-raises, so the whole mine
exits non-zero and every file after the offending one is left unmined. ChromaDB,
SQLite, and Qdrant store the byte verbatim, so only pgvector hard-fails.

Add a recursive `_strip_nul` helper and apply it to id, document, and metadata
in `_PgVectorClient.upsert_rows`, mirroring the backend-layer sanitization
`_sanitize_documents_for_chromadb` already does for lone surrogates on the same
bulk-ingest paths. ids are SHA-256 hashes and metadata keys are fixed field
names, so the id and key passes are no-ops in practice; only transcript-derived
values change.

Co-authored-by: hrabbach <181709360+hrabbach@users.noreply.github.com>
2026-06-21 02:03:05 +05:00
Igor Lins e Silva 0601026026 perf(mcp): answer overview tools from the sqlite aggregate to fix large-palace timeouts (#1748, #1379)
tool_status / list_wings / list_rooms / get_taxonomy paged the entire
collection metadata through the chroma client (`_fetch_all_metadata`, a
1000-row offset loop), which cold-loads the HNSW index and materializes
hundreds of MB of dicts. On six-figure palaces these exceed the MCP host
tool-call limit (180k drawers ~3-4 min; 349k times out at 120-240s). The 5s
metadata cache only dedups repeat calls — it does not stop the cold-call
timeout.

A correct single-query SQL cross-tab already exists
(`backends.chroma._sqlite_wing_room_counts`) and is already the CLI default
(`miner.status`), but the MCP tools never used it — and the MCP-side sqlite
reader only ran behind the `vector_disabled` recovery path.

Add `_sqlite_taxonomy()` (guards on `_is_chroma_backend()`, returns None to
fall back) and wire it as the default path into all four overview tools. They
now answer from one GROUP BY without touching HNSW. Non-chroma backends
(qdrant, sqlite_exact) and unbootstrapped/legacy layouts fall back to the
existing client path unchanged.

graph_stats (also named in #1379) builds an in-memory graph via build_graph()
and needs its own treatment — tracked separately.
2026-06-20 17:47:33 -03:00
Igor Lins e Silva 157022ab1b perf(embedding): cap ORT intra-op threads so a background mine doesn't pin every core (#1068)
ChromaDB's ONNX embedder builds its InferenceSession without a thread cap, so
ORT's intra-op pool defaults to the physical core count. OMP_NUM_THREADS is
inert against it (ORT owns its own pool), so a background `mempalace mine`
pins 4-5 cores and stacked Stop-hook fires turn the machine into a thermal
event.

Add an `embedding_threads` config knob (env MEMPALACE_EMBEDDING_THREADS or
config.json). Unset/"auto" caps the intra-op pool at half the logical CPUs so
a fresh install stays usable out of the box; a positive integer sets an exact
count; 0/negative leaves ORT uncapped for users who want max throughput.

The cap is applied via SessionOptions at session construction:
- `_MempalaceONNX` (default minilm) overrides the `model` cached_property to
  rebuild the session the same way upstream does plus the cap, falling back to
  upstream's uncapped build if chromadb internals shift.
- `EmbeddinggemmaONNX` builds its session through the shared
  `_intra_op_session_options()` helper.
2026-06-20 17:47:22 -03:00
fatkobra 03f638a279 updated tests/test_qdrant_bulk_metadata_scroll.py because of CI failure after push the 2nd commit 2026-06-20 17:47:17 +00:00
fatkobra 5fda4e5231 fix: apply suggested reviewer suggestions 2026-06-20 16:51:30 +00:00
fatkobra b0eddf68dd fix(backends): single-scroll bulk metadata fetch for Qdrant; bump scroll page size (#1796) 2026-06-20 16:17:06 +00:00
Igor Lins e Silva 94d1ced11e Merge pull request #1827 from MemPalace/dependabot/pip/ruff-0.15.18
chore(deps-dev): bump ruff from 0.15.15 to 0.15.18
2026-06-19 18:25:11 -03:00
Igor Lins e Silva 49e427ba35 Merge pull request #1828 from MemPalace/fix/daemon-review-followups
fix(daemon): address post-merge review feedback on #1826
2026-06-19 12:46:10 -03:00
Igor Lins e Silva 7fb981c538 fix(daemon): address post-merge review feedback on #1826
Five fixes from the Copilot review of the merged daemon PR:

1. Privacy: the queue DB's SQLite WAL/SHM sidecars hold un-checkpointed
   verbatim payloads but were created with the caller's umask. Set the
   owner-only umask in run_server BEFORE DaemonRuntime builds the QueueStore
   (not only once the HTTP server starts), and harden any existing sidecars in
   QueueStore._init_db as defense-in-depth.

2. DoS guard: reject a negative Content-Length in the request reader.
   rfile.read(-1) would block until the client disconnects and bypass the
   MAX_BODY_BYTES cap.

3. Side effects: extract _wal_log (+ _ensure_wal, _WAL_FILE, _WAL_REDACT_KEYS)
   into a new side-effect-free mempalace/wal.py. The CLI sync path and the
   daemon service layer obtained _wal_log via `from .mcp_server import _wal_log`,
   which runs mcp_server's import-time stdio protection (os.dup2(2, 1);
   sys.stdout = sys.stderr) in a non-MCP process and misroutes operator output.
   mcp_server/cli/service now import from mempalace.wal.

4. Correctness: run_mcp_tool treated any dict as success. Write tools that
   return a bare {"error": ...} (e.g. tool_create_tunnel/tool_delete_tunnel
   validation) were recorded as succeeded; now the "error" key infers failure.

5. Hook budget: get_client_if_running()/health() take an explicit timeout, and
   the hook "is the daemon up?" precheck uses a short HOOK_PROBE_TIMEOUT (0.5s)
   so a wedged daemon can't stall the hook for the default 5s.

Adds tests/test_wal.py (import isolation + redaction) and daemon tests for the
umask ordering, negative Content-Length, run_mcp_tool error inference, and the
short probe timeout.
2026-06-19 11:56:09 -03:00
fatkobra 32ec879c8b fix(claude-plugin):reviewer feedback for _validate_transcript_path function calls Path.resolve(), which can raise an OSError 2026-06-19 14:21:04 +00:00
Igor Lins e Silva 4e03ce90bc Merge pull request #1826 from MemPalace/feat/daemon-mode
feat: opt-in local daemon for queued MemPalace writes
2026-06-19 11:05:03 -03:00
fatkobra 393107e4ac fix(claude-plugin): resolve SessionEnd merge semantics 2026-06-19 13:44:52 +00:00
mvalentsev d09392c11a feat(hooks): add a budget-safe SessionEnd save hook for clean exits (#1341)
Short sessions that exit cleanly below SAVE_INTERVAL and without a PreCompact
were never saved. Add a SessionEnd hook that takes one final flush.

Claude Code budgets SessionEnd hooks at 1.5s and a plugin-provided timeout
cannot raise it, and a cold mempalace start exceeds that, so the wrapper
backgrounds the work and returns immediately; the detached child completes the
transcript ingest, project mine, and diary checkpoint after the session exits.

The handler validates transcript_path through _validate_transcript_path before
any ingest or diary write, so a traversal or wrong-suffix path is rejected while
the independent project mine still runs.

Adds hook_session_end, both shell wrappers, the plugin hooks.json entry, the
session-end CLI choice, and focused tests.

(cherry picked from commit 10e1450e04fc7cec72984ab7442d3b4fca1490e8)
2026-06-19 13:00:14 +00:00
Igor Lins e Silva fe0391ab56 fix(daemon): skip reverse-DNS in server_bind so startup can't block ~30s
HTTPServer.server_bind() resolves server_name via socket.getfqdn(host). For the
daemon's 127.0.0.1 bind that lookup is pointless, and on a host with slow or
absent reverse DNS it blocks startup until the resolver times out (~30s) — which
looks exactly like the daemon never coming up. This is why the first daemon
HTTP-lifecycle test timed out on the macOS CI runner (httpd_bound=False after
30s) while every later one bound in seconds once the OS had cached the negative
lookup. Bind via TCPServer directly and set server_name from the literal host.
2026-06-19 09:49:44 -03:00
Igor Lins e Silva f868ee78d4 fix(daemon): Windows-safe pid liveness probe; finalize cross-platform daemon tests
_pid_alive used os.kill(pid, 0) as an existence check. On Windows signal 0
is signal.CTRL_C_EVENT, so Python routes it to GenerateConsoleCtrlEvent and
sends a console Ctrl-C to the target's process group rather than probing the
pid. DaemonClient polls a same-process endpoint during startup, so on a CI
runner with an attached console that Ctrl-C was delivered back to the
interpreter as a spurious KeyboardInterrupt — the Windows CI hang that
interrupted the suite at the first daemon HTTP-lifecycle test (socket.py
recv). Probe via the Win32 OpenProcess/WaitForSingleObject handle API instead,
which has no signalling side effects. This is also a real Windows production
bug, not just a test artifact.

Tests:
- Skip the two owner-only (0600) permission tests on Windows: os.chmod cannot
  represent POSIX mode bits there (files report 0o666); the daemon relies on
  user-profile ACLs on Windows.
- _start_server now captures and re-surfaces a run_server thread crash instead
  of spinning for 30s and failing with a bare assert (diagnoses the macOS
  startup flake).
- Add a regression test asserting _pid_alive is correct and emits no console
  control event when hammered like the poll loop.
- Remove the temporary win32 exit-hang diagnostic fixture from conftest now
  that the root cause is fixed.
2026-06-19 09:38:34 -03:00
Igor Lins e Silva 95211e84cb test: win32-only diagnostic for daemon process-exit hang
The Windows CI run passes all 666 tests then hangs at interpreter shutdown
(KeyboardInterrupt at socket.py:723) until the runner kills it. All daemon
lifecycle tests assert their server threads died, so the hang is a different
non-daemon thread blocked on a socket — not the daemon server thread. CI
round-trips can't show which thread it is.

Add a win32-only session fixture that:
  - arms faulthandler.dump_traceback_later(130s) to print every thread's stack
    to stderr once the hang has run a while, and
  - prints every live thread (name + daemon flag) at session teardown — a
    non-daemon thread present there is the shutdown blocker.

Gated to sys.platform == 'win32' so Linux/macOS CI see no extra output. Remove
once the Windows hang is fixed.
2026-06-19 09:01:50 -03:00
Igor Lins e Silva d859d5a565 fix: daemon client bypasses proxy discovery; tests force-shutdown server thread
DaemonClient.request now uses a no-proxy opener (build_opener(ProxyHandler({})))
instead of urllib.urlopen. The daemon is always on 127.0.0.1, so a request must
never go through an HTTP proxy — this is the correct production choice. It also
bypasses urllib's proxy discovery (macOS _scproxy via SystemConfiguration), which
runs on the first request to any host and is NOT bounded by the per-request
timeout: on a CI runner with no network it hangs for tens of seconds, which looked
exactly like the daemon never came up (test_daemon_http_lifecycle_executes_job
timed out at 30.18s). With the no-proxy opener the lifecycle runs in 0.78s and no
server thread is leaked — which also removes the timing skew that made the
sqlite_exact concurrent-connection test flake on macOS CI.

The leaked server thread was also the Windows exit-hang root cause: a slow/failed
client.shutdown() POST left serve_forever running, and the interpreter blocked on
the open listening socket at process exit. Tests now capture the httpd run_server
creates (by subclassing daemon.ThreadingHTTPServer) and force httpd.shutdown() +
server_close() from the test thread if the normal shutdown path leaves the thread
alive, asserting the thread died so a leak becomes a visible failure instead of
a silent exit hang.
2026-06-19 08:52:34 -03:00
Igor Lins e Silva 5f58cdbfa8 fix: unblock daemon PR CI + address review comments
CI was red on all three platforms for the daemon-mode draft PR. Root causes
and fixes:

- Linux 3.9 collection error: `_submit_daemon_job`'s `dedupe_key: str | None`
  parameter annotation is evaluated at def time, and hooks_cli.py has no
  `from __future__ import annotations` — `str | None` raises TypeError on 3.9.
  Reverted to `dedupe_key: str = None` (the original, 3.9-safe). The other
  `int | None` in the file is a function-local annotation, which is never
  evaluated, so it was never the problem.

- macOS/Windows daemon lifecycle flakes: the 3 HTTP-lifecycle tests failed at
  the 10s readiness deadline on contended CI runners (localhost bind is
  sub-second locally but took ~5s when it passed on the macOS fleet, >10s when
  it didn't), and because the server thread never shuts down on timeout,
  run_server's `os.environ["MEMPALACE_PALACE_PATH"]` + `os.umask(0o077)`
  mutations leaked into the rest of the suite — poisoning every later test that
  reads MempalaceConfig().palace_path (the 60+ test_mcp_server cascade on macOS;
  the at-exit socket hang → SIGINT on Windows). Bumped the readiness deadline
  to 30s and added a module-scoped snapshot + autouse fixture in test_daemon.py
  that force-restores the env + umask to the pre-suite baseline after every
  daemon test, so a leaked server thread can't poison other test files.

Gemini review comments (fixed in code, no thread replies per convention):

- daemon.py `_connect()` was a bare `sqlite3.connect` whose `with`-block only
  managed the transaction, not the connection — an unbounded FD leak in a
  long-lived daemon running thousands of jobs (also the source of the Windows
  "unclosed database" ResourceWarning noise). Converted to a closing
  @contextlib.contextmanager.
- `QueueStore.finish()` gained `only_if_running`; `_safe_finish` passes it so a
  late worker finish can't overwrite a shutdown-cancelled job back to
  succeeded/failed — removes the reliance on process-exit timing.
- `DaemonClient.request` wraps the final `json.loads` in try/except
  JSONDecodeError → DaemonError, so a non-JSON 2xx response surfaces as a
  structured error instead of a bare JSONDecodeError.
- test_sync.py: removed the module-level `import mempalace.mcp_server` and moved
  the stdout-rebinding side effect into an autouse fixture scoped to
  TestServiceRunSyncReport, so the embedder/Chroma import chain is no longer
  forced at collection time for the existing sync tests.

Coverage: added focused happy-path tests for service.run_sync early-returns,
run_mine backend application + invalid mode, execute_job kind dispatch,
run_diary_write arg forwarding, run_mcp_tool write-tool dispatch, and
print_job_result — lifts service.py from 57% to 85% so the new files
(service 85%, daemon 80%) don't drag the total below the 80% CI gate now that
the daemon tests complete and the gate is actually evaluated.
2026-06-19 08:33:37 -03:00
fatkobra 3c7808e1bc fix(mcp): guard remaining None palace_path in _mcp_sqlite_integrity_refusal. Added one regression test calling the function directly with palace_path=None. 2026-06-19 09:51:02 +00:00
fatkobra 4c46290b80 fix(mcp): applied 3 of the 4 reviewer suggestions 2026-06-19 08:59:25 +00:00
dependabot[bot] 6bccf82a8c chore(deps-dev): bump ruff from 0.15.15 to 0.15.18
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.15 to 0.15.18.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.18)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 07:54:00 +00:00
fatkobra 63a382fc6d fix(mcp): gate startup on sqlite integrity failures 2026-06-19 06:42:36 +00:00
Igor Lins e Silva aa96bb5623 feat: add opt-in local daemon for queued MemPalace writes
- New mempalace/daemon.py: long-lived localhost HTTP server (127.0.0.1) with a
  SQLite WAL job queue, single worker thread, bearer-token auth, and owner-only
  file perms (0600/0700) on queue DB, token, endpoint, and log.
- New mempalace/service.py: transport-neutral job execution surface shared by the
  daemon, with per-job env isolation so one job's backend/palace switch cannot
  leak into the next. mcp_tool is allowlisted to write-classified tools only.
- Crash recovery re-queues jobs left 'running' by a killed daemon; jobs that
  already exhausted MAX_ATTEMPTS are dead-lettered to 'failed' instead of being
  retried (non-idempotent diary_write would otherwise duplicate verbatim
  content on every restart).
- Bounded retention prunes terminal jobs older than 7 days
  (MEMPALACE_DAEMON_RETENTION_DAYS); queued/running jobs are never touched so a
  crash mid-prune cannot drop in-flight work.
- CLI: --daemon/--background on mine/sync submit to the queue; new
  `mempalace daemon {start,stop,status,jobs,wait}` subcommand. Strictly opt-in:
  no flag, env, or config means no daemon and no behavior change.
- Hooks opt in via MEMPALACE_HOOKS_DAEMON or config hooks.daemon; when the daemon
  is not already running, hooks fall back to the existing direct/spawn path so
  the 500ms hook budget is preserved (hooks never auto-start the daemon).
- service.run_sync renders the same operator-facing report shape as the direct
  CLI sync path (no_source, out_of_scope, by_source, Re-run/Removed hints) and
  drops the old KeyError-prone 'deleted' read.
2026-06-18 23:46:11 -03:00
Igor Lins e Silva afa749c141 Merge pull request #952 from Davez69gto/feat/csharp-extensions
feat(miner): add C# and .NET file extensions to READABLE_EXTENSIONS
2026-06-18 14:28:59 -03:00
Igor Lins e Silva f0e76c4363 Merge pull request #169 from adv3nt3/feat/pi-cli-normalizer
feat: add Pi agent JSONL session normalizer
2026-06-18 13:20:25 -03:00
Igor Lins e Silva 5935dacc98 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	mempalace/miner.py
2026-06-18 13:19:48 -03:00
Igor Lins e Silva 349485b6b6 Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	mempalace/normalize.py
2026-06-18 13:19:48 -03:00
Igor Lins e Silva 78a32c69c1 Merge pull request #204 from FBISiri/feat/gemini-cli-import
feat: add Gemini CLI / AI Studio session import support
2026-06-18 13:04:14 -03:00
Igor Lins e Silva 22cf8afd1e Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts:
#	mempalace/normalize.py
2026-06-18 12:42:26 -03:00
Igor Lins e Silva cbf6cbe851 Merge pull request #731 from sjhddh/feat/continue-dev-parser
feat(normalize): add Continue.dev session parser
2026-06-18 12:38:15 -03:00
Igor Lins e Silva 301aa335e1 Merge pull request #1720 from jsiu93/feat/java-project-scanner
fix: detect Java project manifests
2026-06-18 12:38:00 -03:00
Igor Lins e Silva d4391abb59 Merge pull request #1368 from EVSalomon/feat/add-support-swift-kotlin
feat(miner): add support for Swift and Kotlin file extensions
2026-06-18 12:37:40 -03:00
Igor Lins e Silva 617aa0c3f3 feat(miner): add PHP ecosystem file extensions (#1819) 2026-06-18 12:37:26 -03:00
fatkobra f0b5cbf374 fix(mcp): cache writer lock setup failures 2026-06-18 12:47:17 +00:00
fatkobra e594bad9d6 fix(mcp): refuse second writer for same palace 2026-06-18 11:24:18 +00:00
fatkobra ea71c5234e fix reviewer feedback for chroma and tests 2026-06-18 11:03:06 +00:00
fatkobra d704433701 fix(chroma): route stale hnsw divergence to sqlite fallback 2026-06-18 10:37:58 +00:00
fatkobra d8d88f4d21 fix reviewer feedback: To prevent a KeyError and provide a clear, actionable assertion failure message if PreCompact is ever missing 2026-06-18 09:20:41 +00:00
fatkobra ad5e7dfe2c fix(claude-plugin): run final mine on SessionEnd 2026-06-18 09:07:06 +00:00
ManuelReschke c2f42cd57e feat(miner): add PHP ecosystem file extensions 2026-06-18 10:31:18 +02:00
Arnold Wender f56e3067eb fix(mcp): harden delete_by_source per review — strip surrogates + type guard
Address Gemini review on #1729:
- normalize source_file with strip_lone_surrogates so exact matching hits
  rows mined from non-ASCII paths via cp1252 stdin (#1488), mirroring
  tool_add_drawer's ingestion-side normalization
- isinstance(str) guard so a non-string source_file returns a clean error
  instead of AttributeError
- default missing wing/room to "" in the dry-run sample, consistent with
  the rest of the file
- add tests: non-string rejection + surrogate-normalization match
2026-06-17 15:08:29 +02:00
Arnold Wender d486aef083 feat(mcp): add mempalace_delete_by_source bulk-cleanup tool (#1722)
Adds an MCP tool to remove every drawer mined from a given source_file
exact match, for cleaning up benchmark/test data accidentally mined into
a user wing (ShareGPT dumps, results_mempal_*.jsonl, language config
JSON) that drowns out real memories in semantic search.

Matching is pushed to the backend via delete(where={"source_file": ...})
the same idiom the miner and diary-ingest paths already use so it is not
subject to the SQLite variable limit regardless of how many drawers share
the source. Defaults to a dry run reporting match count and a sample;
dry_run=false commits. Absent source is an idempotent no-op, not an error.
2026-06-17 15:08:07 +02:00
Eldar Shlomi 9f434e0bfd test(migrate): cover swap-failure rollback
Adds end-to-end regression coverage for the migration swap path where os.replace hits EXDEV, the shutil.move fallback fails, and the original palace must be restored from the rename-aside copy.
2026-06-15 10:40:24 -03:00
Igor Lins e Silva bc9c052b51 Merge pull request #1810 from MemPalace/develop
Release v3.4.1 — promote develop to main
2026-06-15 06:11:35 -03:00
Igor Lins e Silva db6e4f0654 Merge pull request #1811 from MemPalace/fix/3.4.1-review-followups
fix(hooks): portable mtime in macOS hook throttles; doc cleanup
2026-06-15 06:00:18 -03:00
Igor Lins e Silva 3f20305eda style(hooks): single-quote the static python -c mtime snippet
The snippet has no shell interpolation — the path arrives via argv, not
string interpolation — so single quotes are correct and make it
unambiguous that nothing is shell-expanded. Behavior is identical:
`sys.argv[1]` contains no `$`, so it was never expanded (verified
empirically). Matches the single-quoted `python -c` blocks already in
hooks/cursor/lib/common.sh. No functional change.
2026-06-15 05:50:08 -03:00
Igor Lins e Silva 868b4c9b39 fix(hooks): portable mtime in macOS hook throttles; doc cleanup
Address review feedback surfaced on the 3.4.1 release promotion (#1810).

Bug fix — `date -r FILE` is GNU-only. On BSD/macOS `date -r` expects
epoch seconds, not a path, so the staleness/throttle checks in the new
Cursor and Antigravity hooks silently failed on macOS: the state GC
swept on every fire and the pending-save guard was skipped. Replace
with a portable `os.path.getmtime` one-liner via the already-resolved
$MEMPAL_PYTHON_BIN (cursor/lib, antigravity/lib, antigravity save hook).
This restores the "bash 3.2.57 / macOS default" compatibility the
Antigravity changelog claims.

Docs:
- Correct the MCP tool count to 33 (was 19/29/31 in 21 places across
  plugin manifests, READMEs, and website docs — all drifted from the
  TOOLS dict / mcp-tools.md reference, which both have 33).
- Fix broken CHANGELOG link to the Cursor skill (skills/, not
  .cursor-plugin/skills/).
- Fix one-too-many `../` in skills/mempalace/SKILL.md's cursor-hooks
  link (resolved above the repo root).
- Add the required `mcpServers` wrapper to the mcp.json example in
  .cursor-plugin/README.md so copy-paste yields a valid Cursor config.

Left intentionally unchanged: the os.dup2 fd-1 redirect in
mcp_server.py is deliberate (#225 keeps JSON-RPC off fd 1).
2026-06-14 19:54:16 -03:00
Igor Lins e Silva 327da58c09 Merge pull request #1809 from MemPalace/chore/release-3.4.1
chore(release): 3.4.1
2026-06-14 17:56:22 -03:00
Igor Lins e Silva b5c79a1eea chore(release): 3.4.1
Bump version across all sources (version.py, pyproject.toml, both
Claude plugin manifests, Codex plugin manifest, README badge, uv.lock)
and promote the Unreleased changelog to 3.4.1.

Shipping: Cursor IDE plugin + hooks, first-class Antigravity IDE
support (with zero-config interpreter resolution), embeddinggemma
bulk re-embed OOM fix, and backup-retention pruning.

Also rebuilds the CHANGELOG compare-link block, which had been left
at v3.2.0: adds the full 3.3.0-3.4.1 chain plus the previously
undocumented 3.4.0, and points Unreleased at v3.4.1...HEAD. Every
version header now resolves to a compare link.
2026-06-14 17:47:09 -03:00
Igor Lins e Silva 68a971b8a1 Merge pull request #1807 from MemPalace/codex/develop-341-windows-closet-boost-fix
test: stabilize closet boost fixture on Windows
2026-06-14 15:24:07 -03:00
Igor Lins e Silva 160a852bdb test: stabilize closet boost fixture on Windows 2026-06-14 15:17:01 -03:00
Igor Lins e Silva 7f5bbd8be8 Merge pull request #1803 from MemPalace/codex/1800-mine-lock-cleanup
fix(palace): clean source mine locks safely
2026-06-14 15:06:21 -03:00
Igor Lins e Silva 0cb45084bc Merge pull request #1624 from trek-e/fix/search-retry-preserve-collection
fix: preserve collection_name on MCP search retry
2026-06-14 15:03:38 -03:00
Igor Lins e Silva a40e0b7a47 fix: address mine lock review feedback 2026-06-14 14:58:20 -03:00
Igor Lins e Silva 58c45a9905 Merge pull request #1804 from MemPalace/codex/close-blob-seq-sqlite
fix: close blob seq sqlite migration connection
2026-06-14 14:50:50 -03:00
Igor Lins e Silva 9f16295f77 Merge pull request #1721 from thismilktea/fix/mcp-add-drawer-idempotency-fail-closed
fix(mcp): fail closed when add_drawer idempotency pre-check fails
2026-06-14 14:45:23 -03:00
Igor Lins e Silva 8e53959bb9 Merge pull request #1802 from MemPalace/codex/develop-341-ci-polish
test: stabilize release validation on develop
2026-06-14 14:44:59 -03:00
Igor Lins e Silva d860a008a1 fix: close blob seq sqlite migration connection 2026-06-14 13:12:00 -03:00
Igor Lins e Silva 5d5397bf52 fix(palace): clean source mine locks safely 2026-06-14 12:50:32 -03:00
Igor Lins e Silva f89bc08876 test: stabilize release validation on develop 2026-06-14 12:25:53 -03:00
Igor Lins e Silva 97ba05cfe3 fix(mcp): avoid Chroma open when cached DB disappears 2026-06-14 12:12:59 -03:00
Igor Lins e Silva 422edc2dda Merge pull request #1717 from Hexecu/fix/diary-write-schema-anyof
fix(mcp): drop top-level anyOf from diary_write schema
2026-06-14 11:18:10 -03:00
Igor Lins e Silva 0a2937d766 Merge pull request #1582 from mvalentsev/fix/1580-neighbor-scope-parent-drawer-id
fix(searcher): scope neighbor expansion by parent_drawer_id (#1580)
2026-06-14 11:14:31 -03:00
Igor Lins e Silva 7e4bd816d1 Merge pull request #1780 from ggettert/fix/hallway-file-palace-scoped
fix(hallways): scope hallway-file path to MempalaceConfig.palace_path (#1778)
2026-06-14 11:12:14 -03:00
Igor Lins e Silva 75eb86a93a Merge pull request #1781 from fatkobra/fix/1776-id-recipe-v3-clean
fix(ids): use length-prefixed recipe v3
2026-06-14 11:12:01 -03:00
Igor Lins e Silva 80c37602c0 Merge pull request #1790 from fatkobra/fix/1772-shell-hooks-delegate-python
fix(hooks): delegate shell hooks to Python runner
2026-06-14 11:11:09 -03:00
Igor Lins e Silva b286701535 Merge pull request #1777 from mvalentsev/fix/1770-embeddinggemma-chunked-batching
fix(embedding): chunk EmbeddinggemmaONNX batches to bound ONNX memory (#1770)
2026-06-14 11:10:54 -03:00
Igor Lins e Silva c50434594c Merge pull request #1749 from mvalentsev/fix/repair-legacy-fts5-postrun
fix(repair): run post-rebuild FTS5 cleanup on legacy cmd_repair path (#1747)
2026-06-14 11:10:41 -03:00
Igor Lins e Silva 528d711f95 Merge pull request #1782 from fatkobra/fix/1763-mcp-logical-chunked-drawers
fix(mcp): treat chunked drawers as logical drawers
2026-06-14 11:10:32 -03:00
Igor Lins e Silva 4fbdaf2c32 Merge pull request #1793 from mvalentsev/fix/1774-backend-connect-races
fix(backends): serialize first connect in sqlite_exact and pgvector (#1774, #1775)
2026-06-14 11:10:23 -03:00
Tom BoucherandClaude Opus 4.8 c45d069f59 fix(tests): run fact_checker __main__ via subprocess to clear runpy warning
`tests/test_fact_checker.py` imports symbols from `mempalace.fact_checker` at
module top (putting it in sys.modules), then `TestCLI.test_exits_nonzero_when_
issues_found` re-executed the same module as __main__ via
`runpy.run_module("mempalace.fact_checker", run_name="__main__")`. runpy warns
because it re-runs an already-imported module against a half-initialized state:

  RuntimeWarning: 'mempalace.fact_checker' found in sys.modules after import of
  package 'mempalace', but prior to execution of 'mempalace.fact_checker'

Run the CLI in a fresh process via `subprocess.run([sys.executable, "-m",
"mempalace.fact_checker", ...])` instead — no sys.modules collision, and it
exercises the real `python -m` entry point. Assertions are preserved
(SystemExit code 1 → returncode 1; captured stdout substring → result.stdout).
The child's entity registry (`~/.mempalace/known_entities.json`, resolved via
expanduser at import) is redirected by overriding both HOME and USERPROFILE in
the subprocess env so it works on POSIX and Windows.

Verified: `pytest tests/test_fact_checker.py -W error::RuntimeWarning` passes
(26) with the warning promoted to error — proving it no longer fires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 11:11:22 -04:00
fatkobra 950713eef6 fix(hooks): typo regression addressed 2026-06-13 06:37:26 +00:00
fatkobra 41e5c20191 fix(hooks): preserve fail-loud parse diagnostics 2026-06-12 16:58:20 +00:00
mvalentsevandjphein f6b6a69197 fix(backends): serialize first connect in sqlite_exact and pgvector (#1774, #1775)
Co-Authored-By: jphein <19301265+jphein@users.noreply.github.com>
2026-06-12 21:55:36 +05:00
fatkobra 0b00d78c98 fix(hooks): normalize Windows transcript paths in shell hooks 2026-06-12 15:25:36 +00:00
dependabot[bot] 2b457a2366 chore(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 07:53:38 +00:00
dependabot[bot] 38de37adbf chore(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 07:53:35 +00:00
dependabot[bot] 13d6c3e7a1 chore(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 07:53:32 +00:00
fatkobra 5a8f0258d6 fix(ids): simplify v3 length-prefixed hashing 2026-06-12 07:39:57 +00:00
fatkobra 761ebf78bb fix(mcp): avoid mutating drawer metadata 2026-06-12 07:37:04 +00:00
Igor Lins e Silva 7e45720dc8 Merge pull request #1632 from undeadindustries/feat/cursor-hooks-support
feat: add Cursor IDE support (hooks, plugin, skill, docs, tests)
2026-06-11 19:29:29 -03:00
fatkobra 0fc3a238d7 fix(mcp): treat chunked drawers as logical drawers 2026-06-11 20:22:41 +00:00
fatkobra 5024e90d68 fix(ids): use length-prefixed recipe v3 2026-06-11 19:41:36 +00:00
Grace Gettert f5ee33b216 fixup(hallways): address gemini-code-assist review on PR #1780
Two catches on tests/test_hallways_palace_scoped.py
TestMultiPalaceIsolation.test_save_then_load_under_different_palace_returns_empty:

1. Stale comment referencing the removed _HALLWAY_FILE back-compat shim
   (deleted in the prior fixup commit). Removed.
2. _legacy_hallway_file was not monkey-patched, so the test isolation gap
   let _load_hallways check the host's real ~/.mempalace/hallways.json
   when evaluating the legacy-warning branch. Now patched to a tmp_path
   sibling, matching the helper pattern used in test_palace_graph_tunnels.
2026-06-11 19:18:54 +00:00
Grace Gettert 4fd12318df fixup(hallways): drop _HALLWAY_FILE back-compat shim, migrate existing tests to resolver
Replaces the back-compat shim in _load_hallways/_save_hallways (which
honored direct monkey-patches of the _HALLWAY_FILE module constant) with
a clean single-source-of-truth resolver, matching the palace_graph
tunnel-file migration in 3.3.6.

The three existing test sites (tests/test_hallways.py,
tests/test_hallways_pagination.py, tests/test_mcp_server.py) now
monkey-patch _get_hallway_file and _legacy_hallway_file directly,
exactly mirroring the helper in tests/test_palace_graph_tunnels.py.

Production code now has one branch through the path resolution instead
of two. No behavior change. 269/269 hallway + tunnel + mcp-server
tests pass on Python 3.11 and 3.12, ruff clean.
2026-06-11 19:11:52 +00:00
Grace Gettert bf71ae7168 fix(hallways): scope hallway-file path to MempalaceConfig.palace_path (#1778)
Pre-3.4 the hallway store was hardcoded at ~/.mempalace/hallways.json
regardless of the configured palace_path, so two palaces on one host
silently shared one file. Mining into palace-A leaked records into
palace-B's hallway code paths.

Apply the 3.3.6 tunnel-file migration pattern:

  * MempalaceConfig.hallway_file resolves to <dirname(palace_path)>/hallways.json
  * hallways._get_hallway_file(config) reads through MempalaceConfig
  * hallways._legacy_hallway_file() exposes the pre-migration hardcoded path
    for one-time orphan detection; _load_hallways logs a one-line warning
    when the legacy file exists but the configured one doesn't, matching
    palace_graph._load_tunnels behavior. No auto-migration — silent merging
    risks clobbering newer data.

Atomic-write + 0600 semantics unchanged. Module-level _HALLWAY_FILE
constant kept and honored when monkey-patched directly, so the three
existing test sites that patch it (test_hallways.py, test_hallways_pagination.py,
test_mcp_server.py) keep working without modification.

New coverage in tests/test_hallways_palace_scoped.py mirrors the analogous
tunnel tests: resolver default + custom palace_path + env-var redirect,
orphaned-legacy warning + no-warning when paths match, and an end-to-end
multi-palace isolation regression guard.

Closes #1778
2026-06-11 19:04:36 +00:00
mvalentsev 4187521ba4 fix(embedding): lock EF cache and lazy load, guard inputs (#1770)
Two threads sharing a cold EmbeddinggemmaONNX via _EF_CACHE could each
build a full model session, and two factory callers could each keep a
private instance. The load is now double-check locked with the session
published last, and the factory cache has an atomic check-then-construct
behind a lock-free fast path. __call__ wraps a bare string, returns []
for None and empty input before the lazy download, and its annotation
matches the accepted types.
2026-06-11 23:01:48 +05:00
mvalentsevandmojie5 07f8789514 fix(embedding): chunk EmbeddinggemmaONNX batches to bound ONNX memory (#1770)
One session.run over a repair-scale batch (5000 docs) allocates
attention buffers far beyond available RAM and the kernel kills the
process. Mirror chromadb's ONNXMiniLM_L6_V2 and embed in sub-batches
of 32; per-chunk padding also stops one long doc inflating the whole
batch.

Co-Authored-By: mojie5 <262519016+mojie5@users.noreply.github.com>
2026-06-11 21:08:49 +05:00
Igor Lins e Silva 8e19e815cc merge: resolve recall-protocol conflict with develop 2026-06-11 05:07:43 -03:00
Igor Lins e Silva f31db69e03 merge: resolve CHANGELOG conflict with develop 2026-06-11 04:28:42 -03:00
Igor Lins e Silva 2ec4baec3f Merge pull request #1771 from undeadindustries/feat/antigravity-support
feat(antigravity): add recall skill, optional rule, shared protocol
2026-06-11 04:26:51 -03:00
undeadindustriesandCursor 60a4610c6b fix(antigravity): sync recall skill protocol with canonical shared source
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 #1771.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 13:19:24 +10:00
undeadindustriesandCursor fc89a1b67e feat(antigravity): add recall skill, optional rule, shared protocol
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>
2026-06-11 13:09:40 +10:00
jpandClaude Fable 5 47bddb066e review(gemini): marker-race stub, created-list lock, exact-order + exactly-one-ran asserts
- Stub _write_marker on the 8 concurrent writer backends: upsert()
  rewrites the marker on every call with a plain open('w'), so backends
  sharing one local_path race on the same file (sharing violations on
  Windows) — a test-design artifact, not the contract under test
- Guard the fixture's created list with a lock for the threaded tests
- Assert exact distance-ordered ids in the query/filter arms
- Reindex race: exactly one 'ran' (index absent beforehand, so the
  advisory-lock winner must build)

Re-run live after changes: 15/15 pass (PG 16.10, pgvector 0.8.2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:46:27 -07:00
jpandClaude Fable 5 374400a530 test(backends): live-substrate conformance module for pgvector
Mirrors the portable fake-client arms of test_pgvector_backend.py
against a real PostgreSQL+pgvector server and adds live-only arms the
in-memory fake cannot exercise: real <=> operator ground truth, JSONB
pushdown vs local-fallback equivalence, cross-namespace isolation on
real tables, 8-connection concurrent writers, and the advisory-lock
serialization of run_maintenance('reindex') under a 2-connection race.

Gated on MEMPALACE_PGVECTOR_LIVE_DSN (same pattern as the qdrant live
gate); skips cleanly when unset. First run: 15/15 pass on PostgreSQL
16.10 + pgvector 0.8.2 (+AGE 1.6.0 in the same server), psycopg 3.3.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:38:27 -07:00
Igor Lins e Silva f124bd2c84 Merge pull request #1633 from undeadindustries/feat/antigravity-support
feat: add Antigravity IDE support (hooks, plugin, skill, docs, tests)
2026-06-10 08:08:25 -03:00
Igor Lins e Silva df0e29ac8b fix(test): make hybrid search closet preview test robust on Windows 2026-06-10 08:07:09 -03:00
Igor Lins e Silva 692599aa94 merge: resolve CHANGELOG conflict with develop 2026-06-10 07:46:22 -03:00
Igor Lins e Silva ad67c1a121 Merge pull request #1737 from Sathvik-1007/fix/1621-update-drawer-room-casing
fix(mcp): preserve room/wing casing on case-only update
2026-06-10 07:24:07 -03:00
Igor Lins e Silva 4d60db05ba Merge pull request #1741 from ggettert/feat/mcp-list-hallways
feat(mcp): expose list_hallways and delete_hallway tools
2026-06-10 07:23:50 -03:00
Igor Lins e Silva 77eaa312e0 Merge pull request #1622 from mvalentsev/fix/1535-mine-limit-skips-already-mined
fix(miner): count only new work toward --limit, not already-mined skips (#1535)
2026-06-10 07:23:36 -03:00
Igor Lins e Silva 8c120bee34 Merge pull request #1692 from mvalentsev/feat/1662-mcp-mine-tool
feat(mcp): add mempalace_mine tool (#1662)
2026-06-10 07:22:44 -03:00
Igor Lins e Silva 140c380584 Merge pull request #1697 from margaretjgu/fix/backup-retention
fix(backups): add max_backups retention to bound backup disk usage
2026-06-10 07:22:07 -03:00
Igor Lins e Silva 7d7dc78107 Merge pull request #1723 from mvalentsev/fix/hnsw-dim-none-deletions
fix(backends): keep post-deletion dim-None HNSW segments instead of quarantining (#1710)
2026-06-10 07:12:00 -03:00
mvalentsevandnord- d057dee9c3 fix(repair): run post-rebuild FTS5 cleanup on legacy cmd_repair path (#1747)
A clean `mempalace repair --yes` (legacy path) finished without
_vacuum_and_rebuild_fts5: the bulk delete_collection + re-upsert cycle
leaves the FTS5 inverted index inconsistent, so the next repair aborts
at the sqlite integrity preflight. rebuild_index() got this cleanup
when #1517 was fixed; cmd_repair never did.

Extract the shared epilogue _post_rebuild_cleanup() (close chroma
handles, then VACUUM + rebuild FTS5) and call it from both full-rebuild
paths so they cannot drift apart again. Cleanup runs on the legacy
success path only; failure/restore paths are unchanged.

Closes #1747

Co-Authored-By: nord- <3777600+nord-@users.noreply.github.com>
2026-06-10 14:33:19 +05:00
undeadindustriesandCursor 24ad98df8c feat: add mempalace-recall skill and optional Cursor recall rule
Ports the OpenClaw "search before answering" protocol to the Cursor and
Claude plugin surfaces so the agent reads the palace before answering
about past work, people, projects, or prior decisions instead of
guessing from model memory.

- integrations/shared/recall-protocol.md: single source of truth for the
  recall protocol, referenced by the skill and the rule so they cannot
  drift.
- skills/mempalace-recall/SKILL.md: recall-only skill (the mempalace
  skill keeps setup/mine/status); cross-linked from the ops skill.
- rules/mempalace-recall.mdc: plugin recall rule, alwaysApply: false so
  it only fires on recall-relevant turns and never adds MCP latency to
  greenfield work.
- examples/cursor/rules/: opt-in copies for non-plugin users, including
  an aggressive alwaysApply: true variant documented with its latency
  tradeoff.
- .claude-plugin/skills/mempalace-recall/SKILL.md: Claude plugin parity.
- tests: assert the recall skill and rules/ discovery layout; the
  shipped rule must be alwaysApply: false.
- docs: .cursor-plugin/README.md and the cursor-hooks guide now describe
  the three layers of recall (hook + skill + rule).

The Antigravity plugin mirror lands as a follow-up on the antigravity
branch, where .antigravity-plugin/ exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:40:35 +10:00
Igor Lins e Silva d05bb11724 Merge pull request #1734 from MemPalace/feat/qdrant-embedder-identity
feat(backends): shared embedder-identity sidecar + qdrant identity (RFC 001)
2026-06-08 20:03:19 -03:00
Grace Gettert ef2261feb8 docs(mcp): document list_hallways and delete_hallway tools
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.
2026-06-08 20:07:23 +00:00
Grace Gettert eab11969da feat(mcp): expose list_hallways and delete_hallway tools
Closes #1739.

Hallways shipped in 3.3.6 (#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.
2026-06-08 19:43:25 +00:00
Sathvik-1007 d514787f95 fix(mcp): str-coerce metadata before case comparison
Addresses review: old_meta values could be non-string after
corruption or external writes. str() prevents AttributeError.
2026-06-08 21:12:34 +05:30
Sathvik-1007 676ee682ec fix(mcp): preserve room/wing casing on case-only update
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 #1621
2026-06-08 20:49:33 +05:30
Igor Lins e Silva ed77125ae3 feat(backends): shared embedder-identity sidecar + qdrant identity (RFC 001, #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 #1730. Refs #743, #1724.
2026-06-08 10:20:45 -03:00
Igor Lins e Silva 4ceb880d19 Merge pull request #1732 from MemPalace/feat/maintenance-hooks
feat(backends): observable maintenance hooks + pgvector indexed-build path (RFC 001)
2026-06-08 09:51:29 -03:00
Igor Lins e Silva fdbafe5420 feat(backends): observable maintenance hooks + pgvector indexed-build path (RFC 001, #1725)
Adds the maintenance contract RFC 001 specifies but #1679 deferred, and gives
pgvector an opt-in HNSW index path with concurrency-safe builds.

- base.py: MaintenanceResult (status ran/already_running/noop + free-form
  stats), UnsupportedMaintenanceKindError, BaseBackend.maintenance_kinds
  ClassVar (reserved: analyze/compact/reindex; a backend with no analogue MUST
  omit, not no-op), and BaseCollection.maintenance_state()/run_maintenance()
  defaults. EmbeddingCollection forwards both (BaseCollection methods shadow
  __getattr__).
- sqlite_exact: analyze (ANALYZE) + compact (VACUUM, autocommit + page stats);
  omits reindex (exact scan, no ANN index). maintenance_state reports row/page
  counts.
- pgvector: reindex builds the optional HNSW index, serialized by a
  session-level pg_advisory_lock so concurrent daemon writers learn
  "already_running" instead of each stacking an ACCESS EXCLUSIVE build (the
  production wedge). It is opt-in: the default exact `<=>` scan is the
  100%-recall path; an HNSW index trades exact recall for scale, so an operator
  invokes it deliberately. Also analyze; omits compact (autovacuum). Advertises
  supports_server_side_indexes. maintenance_state reports index presence.
- qdrant/chroma: empty maintenance_kinds (qdrant self-optimizes; chroma
  maintenance is the separate repair CLI) — the faithful "omit" default.

Tests: contract + sqlite (real, CI-runnable) + pgvector advisory-lock flow via
a fake client (ran/noop/already_running, no live Postgres). Full suite green:
2488 passed, 82.47% coverage.

Benchmark three-phase wiring is deferred — the existing benchmarks/ are
task-benchmarks, not backend-comparison harnesses, so there is nothing to wire
into yet.

Closes #1725. Refs #743.
2026-06-08 08:44:20 -03:00
Igor Lins e Silva 13de7a6743 Merge pull request #1731 from MemPalace/feat/embedder-identity
feat(backends): embedder-identity contract + three-state enforcement (RFC 001)
2026-06-08 08:21:58 -03:00
Igor Lins e Silva 212051da35 feat(backends): embedder-identity contract + three-state enforcement (RFC 001, #1724)
The explicit-embedding backends (pgvector, qdrant, sqlite_exact) embed through
EmbeddingCollection and persist nothing about which model produced their
vectors. Swapping two same-dimension models (minilm <-> embeddinggemma, both
384-d) silently corrupts retrieval with no error — the worst failure class for
a verbatim-recall system. This records the model name and refuses a swap on open.

- base.py: EmbedderIdentity dataclass, Embedder protocol,
  EmbedderIdentityUnknownWarning, and a three-state check_embedder_identity()
  helper (known_match / known_mismatch / unknown) raising
  EmbedderIdentityMismatchError / DimensionMismatchError. BaseCollection gains
  get/set/effective_embedder_identity hooks.
- embedding.py: current_model_name() (cheap) + get_embedder_identity() (probed
  dimension, cached per process).
- Persistence per backend: sqlite_exact meta table, pgvector marker JSON
  (preserved across rewrites), chroma sidecar JSON. EmbeddingCollection forwards
  the hooks explicitly (BaseCollection methods shadow __getattr__).
- palace.get_collection enforces at open: a model swap raises; a brand-new empty
  collection records the current model; a populated-but-unrecorded legacy palace
  warns and is resolved via the CLI. The check needs no model load.
- CLI: `mempalace palace set-embedder [--model NAME] [--force]` records/overrides
  identity without mutating global config or loading a foreign model.

Chroma additionally keeps its native embedding-function check. Qdrant identity
persistence is a fast-follow (no local metadata slot — needs a companion store).

Closes #1724. Refs #743.
2026-06-08 08:14:26 -03:00
Igor Lins e Silva 52144704e7 Merge pull request #1727 from MemPalace/feat/searcher-backend-neutral
feat(searcher): metric-aware distance→similarity conversion (RFC 001 §10)
2026-06-08 06:21:59 -03:00
Igor Lins e Silva ffa8ac142c feat(searcher): metric-aware distance→similarity conversion (RFC 001 §10, #1726)
The searcher hard-coded `max(0, 1 - distance)` for vector similarity, which is
correct only for cosine. A backend reporting L2 or inner-product distances — or
a legacy Chroma palace built without `hnsw:space=cosine` — was silently
mis-ranked: L2 distances routinely exceed 1.0 and floored every result to 0.

- backends/base.py: `BaseBackend.distance_metric` ClassVar (cosine default) and
  a `BaseCollection.distance_metric` property per RFC 001 §2.1.
- backends/chroma.py: `ChromaCollection.distance_metric` reads the real
  `hnsw:space`, so a legacy-L2 palace reports "l2" and ranks correctly.
- searcher.py: `_distance_to_similarity(distance, metric)` (cosine/l2/ip,
  monotonic + bounded) and `_metric_for_collection(col)`; `_hybrid_rank` and the
  CLI display are now metric-aware. Cosine output is unchanged.
- Same conversion applied to the MCP `similarity` field, layers.py L3 search,
  and the dedup threshold so reported similarities are consistent.

Cosine behavior is byte-for-byte preserved; only non-cosine metrics change.
Part B of #1726 (routing the chroma.sqlite3 BM25 fallback through
BaseCollection) is tracked separately. Refs #1726, #743.
2026-06-08 02:48:26 -03:00
mvalentsev 9a8c87acfe fix(backends): keep post-deletion dim-None HNSW segments instead of quarantining (#1710)
_missing_dimensionality_appears_recoverable required total_elements_added == len(id_to_label) before keeping a dim-None segment, but total_elements_added is a monotonic add-counter while id_to_label holds only the live elements. After any delete the counts diverge, so every post-deletion segment was quarantined; once the WAL is pruned that destroys the only copy of those vectors. Relax the check to >= and keep the bijection and size checks as the integrity backstop.
2026-06-08 01:27:52 +05:00
chenyuxuan 6c5e1be3db fix(mcp): fail closed when add_drawer idempotency pre-check fails 2026-06-07 17:32:25 +08:00
jsiu93 74453ee3ac fix: handle rootless Java subprojects 2026-06-07 16:01:57 +08:00
jsiu93 ae415b8620 fix: detect Java project manifests 2026-06-07 15:50:15 +08:00
raman325 ca38ae899d docs(openclaw): address review round 1
- Fix mempalace_find_tunnels params: (required) -> optional. The MCP
  handler defaults both wing_a and wing_b to None
  (mempalace/mcp_server.py:1277), so the prior docs were factually
  wrong. Caught by gemini-code-assist on PR #1719.
- Clarify implicit-vs-explicit tunnel distinction with consistent
  casing and a brief in-line definition (implicit = discovered from
  drawer content overlap; explicit = user/agent-declared link).
  Suggested by copilot-pull-request-reviewer.
- Split the mempalace_memories_filed_away one-liner into a short
  description plus 'Returns' and 'When to call' sub-bullets for
  readability. Suggested by copilot-pull-request-reviewer.
2026-06-06 22:58:04 -04:00
raman325 29d7e1edcf docs(openclaw): catch up SKILL.md with 8 newer MCP tools
The openclaw skill was last updated when mempalace exposed 19 MCP
tools. Since then 13 more agent-facing tools have landed; this PR
documents the 8 that openclaw should expose so agents can call them
natively instead of falling back to `npx mcporter call ...`:

Search & Browse:
  - mempalace_list_drawers   (paginated drawer listing)
  - mempalace_get_drawer     (fetch a single drawer by id)

Palace Graph:
  - mempalace_create_tunnel  (explicit cross-wing link)
  - mempalace_list_tunnels   (enumerate explicit tunnels)
  - mempalace_delete_tunnel  (remove an explicit tunnel)
  - mempalace_follow_tunnels (walk explicit tunnels from a room)

Write / Session:
  - mempalace_update_drawer  (mutate content or relocate a drawer)
  - mempalace_memories_filed_away (ack the silent auto-save hook)

The 3 admin-only tools (mempalace_sync, mempalace_hook_settings,
mempalace_reconnect) are intentionally left out — they're host/admin
operations, not agent-facing memory operations. The Hermes
MemoryProvider plugin landing in MemPalace/mempalace#1684 makes the
same call.

Version bumped 3.3.0 -> 3.4.0 (additive tool surface, no breaking
changes to existing tool docs).
2026-06-06 22:55:13 -04:00
Hexecu b1537be5b5 fix(mcp): drop top-level anyOf from diary_write schema
The mempalace_diary_write tool declared a top-level anyOf in its
input schema to require either entry or content. Anthropic's Messages
API rejects any tool schema with a top-level anyOf/oneOf/allOf and
returns a 400 for the entire tools array, so every MCP session failed
to start.

The entry/content constraint is already enforced at dispatch: content
is remapped to entry before the handler runs, and a missing value
returns -32602. Removing the combinator restores compatibility without
weakening validation.

Closes #1711
2026-06-07 00:45:48 +02:00
mvalentsev c4da6d500b fix(searcher): scope neighbor expansion by parent_drawer_id (#1580) 2026-06-06 18:46:17 +05:00
mvalentsev c9dc4c466d fix(miner): count only new work toward --limit, not already-mined skips (#1535) 2026-06-06 17:56:08 +05:00
mvalentsev 2a66bad497 feat(mcp): add mempalace_mine tool (#1662)
Expose mining as an MCP tool so clients that cannot shell out (Claude
Desktop, LM Studio, Aionui, Desktop Commander) can index projects,
conversations, or documents in-conversation, not only through the
`mempalace mine` CLI.

tool_mine is a synchronous wrapper over the existing miners (miner.mine,
convo_miner.mine_convos, format_miner.mine_formats) that cmd_mine already
calls, so it adds no new ingestion logic and no backend coupling. Miner
stdout is captured at the Python and file-descriptor level so it cannot
corrupt the JSON-RPC channel (#225); no Unix-only calls, so it works on
Windows. The miners keep the palace write lock, so a concurrent mine
returns a structured already-running error.
2026-06-06 17:54:38 +05:00
Milla J be954671ca Merge pull request #1706 from MemPalace/develop
Release v3.4.0 — promote develop to main
2026-06-06 01:43:11 -07:00
Igor Lins e Silva 939a076baf Merge pull request #1709 from MemPalace/ci/publish-single-job
ci(publish): build + publish in one job (fix BlobNotFound artifact handoff)
2026-06-06 05:13:55 -03:00
Igor Lins e Silva 19d2f9216d ci(publish): build + publish in one job to avoid cross-job artifact handoff
The two-job split passed the wheel from the build job to the publish job
via upload/download-artifact, which failed repeatedly with BlobNotFound on
the same-run download (GitHub artifact-service flake) — the PyPI upload step
never ran. Collapse into a single `publish` job that builds and publishes in
the same workspace, removing the handoff entirely. The job keeps the `pypi`
environment gate, OIDC (id-token: write), refs/tags checkout, and the
on-main + version-manifest checks.
2026-06-06 05:10:52 -03:00
Igor Lins e Silva 72a69e2476 Merge pull request #1707 from MemPalace/fix/embedding-wrapper-oneormany
fix(backends): wrap bare-str OneOrMany inputs before embedding
2026-06-06 05:08:07 -03:00
Igor Lins e Silva 051f0cc1ec fix(backends): normalize all OneOrMany inputs in EmbeddingCollection
Address #1707 review:
- _as_list also wraps a bare dict (single metadata) — list({'k':1}) -> ['k']
  would drop the values — and returns list inputs as-is (no copy, Copilot
  perf note); other iterables are materialized once.
- Normalize ids and metadatas (not just documents) in add/upsert/update so
  a scalar id/metadata stays length-aligned with documents/embeddings.
- Widen query_texts annotation to list[str] | str to match the behavior.
- Tests: bare-str ids + dict metadatas, dict wrapping, list-returned-as-is.
2026-06-06 04:56:19 -03:00
Igor Lins e Silva d464c28e0a Merge pull request #1708 from MemPalace/ci/publish-workflow-dispatch
ci(publish): add workflow_dispatch manual trigger
2026-06-06 04:50:45 -03:00
Igor Lins e Silva d51e9434f9 ci(publish): qualify checkout ref to refs/tags + tighten tag regex
Address Copilot review on #1708:
- Check out refs/tags/<tag> instead of the bare name, so a same-named
  branch can't be resolved instead of the tag object (checkout prefers
  branches on ambiguous refs).
- Tighten the dispatch-input validation to require vMAJOR.MINOR.PATCH
  with an optional -/+ suffix, rejecting loose values like v3 / v3foo.
2026-06-06 04:50:02 -03:00
Igor Lins e Silva 15833ab01a ci(publish): add workflow_dispatch manual trigger
GitHub doesn't reliably emit a release event when a draft tied to a
pre-existing tag is published (e.g. publishing the long-standing v3.3.6
draft fired no run). Add a workflow_dispatch trigger with a 'tag' input so
a maintainer can run the publish for any existing tag from the Actions tab
or 'gh workflow run'. A resolve step picks the release tag or the input,
format-validates it (^v[0-9][0-9A-Za-z.+-]*$) before any use as a git ref,
and the existing on-main + version-match checks + the pypi approval gate
apply unchanged to both paths.
2026-06-06 04:43:35 -03:00
Igor Lins e Silva ce929a8241 fix(backends): wrap bare-str OneOrMany inputs before embedding
EmbeddingCollection did _embed_texts(list(documents)). For ChromaDB's
OneOrMany shape, a bare str document splits into per-character 'docs'
(list("abc") -> ['a','b','c']), embedding each character and breaking
length alignment with ids/metadatas on explicit-vector backends
(pgvector, sqlite_exact). Normalize str -> [str] via _as_list() at all
four sites (add/upsert/update documents, query query_texts) and pass the
normalized list to the inner backend too. Addresses PR #1706 review
(Gemini + Copilot, HIGH).
2026-06-06 04:35:15 -03:00
Igor Lins e Silva 9694e2cb57 Merge pull request #1705 from MemPalace/chore/bump-3.4.0
chore(release): 3.4.0
2026-06-06 03:57:46 -03:00
Igor Lins e Silva 73d4e43b22 chore(release): bump README version badge to 3.4.0
Enforced by test_readme_claims.py::TestVersionBadge — a sixth version
location outside the version-guard 5-file set.
2026-06-06 03:41:49 -03:00
Igor Lins e Silva e66cfff0c0 chore(release): 3.4.0
Minor release. Highlights since v3.3.6:
- feat: pluggable vector backends (qdrant, pgvector, sqlite_exact)
- feat: Docker image for MCP server + CLI
- feat: mempalace migrate-wings (normalize legacy wing names)
- feat: known-systems lexicon for compound product names
- fix: embeddinggemma works with ChromaDB 1.5.x (default-model search)
- fix: drawer_id collision data-loss; WAL import side-effect / kill-switch
- ci: PyPI Trusted Publishing pipeline
2026-06-06 03:31:34 -03:00
Igor Lins e Silva ab716d02a5 Merge pull request #1675 from M8SON/fix/wing-normalize-strip-sep
fix(config): strip leading/trailing separators in normalize_wing_name
2026-06-06 03:26:33 -03:00
Igor Lins e Silva ed772a01f9 Merge remote-tracking branch 'origin/develop' into fix/wing-normalize-strip-sep 2026-06-06 03:24:08 -03:00
Igor Lins e Silva 708ef4a9fd Merge pull request #1702 from MemPalace/feat/migrate-wing-normalize
feat(migrate): mempalace migrate-wings — normalize legacy wing names (#1675 follow-up)
2026-06-06 03:20:26 -03:00
Igor Lins e Silva 8ec438ef57 docs(recovery): add wing-name migration guide for migrate-wings 2026-06-06 03:12:49 -03:00
Igor Lins e Silva e46374d24b feat(migrate): mempalace migrate-wings — normalize legacy wing names
Follow-up to the wing-name normalization (#1675). Palaces built before the
rule filed drawers under leading/trailing-separator wing names (e.g. a
Claude Code path-encoded dir `-home-user-proj` -> `_home_user_proj`); the
new derivation strips those, so searches/diary reads under the new name miss
the old memories — the history is split, not lost.

`migrate_wing_names` (CLI: `mempalace migrate-wings [--dry-run] [--yes]`)
re-keys the `wing` metadata field on drawers and closets to the normalized
form, merging collisions. Design (verified against the codebase):

- Drawer/closet IDs embed the wing as an opaque prefix that is never decoded
  back into a wing, and mining idempotency keys on `source_file`, so IDs are
  left untouched: closet ->drawer_id pointers stay valid and future mining
  still skips already-mined files. No risky extract-rebuild needed.
- `topics_by_wing` registry keys are re-keyed (merging on collision).
- Tunnels resolve via existing read-time normalization and need no rewrite.
- Backend-agnostic (uses the get/update collection abstraction); idempotent;
  dry-run-able. The strip is applied by the migration itself, so it is
  correct regardless of whether the running build's normalize_wing_name
  already carries the #1675 change.

Tests: pure planner coverage (strip, no-op, empty/non-string, collision) +
hermetic backend integration (relabel, merge, dry-run, idempotency).
2026-06-06 03:11:46 -03:00
Igor Lins e Silva 6ac2de8e04 Merge pull request #1700 from MemPalace/fix/1245-diary-write-and-lint
fix(mcp): repair diary_write content alias + restore -32602 diagnostic (fixes red develop)
2026-06-06 01:25:28 -03:00
Igor Lins e Silva ab669fd3b1 test(miner): compare default wing to normalized dirname, not raw name
test_load_config_uses_defaults_when_yaml_missing asserted the derived
wing equals project_root.name. That only held when the random tempfile
name had no separators; tempfile's alphabet includes '_', so once
normalize_wing_name strips leading/trailing '_' (this PR), a name like
'tmpXXXX_' makes the derived wing diverge from the raw name. Compare
against normalize_wing_name(project_root.name) — the actual contract —
which is deterministic across platforms. (Surfaced as a test-windows
failure on this PR, but it was cross-platform flaky.)
2026-06-06 01:23:09 -03:00
Igor Lins e Silva b130301bd5 fix(mcp): address bot review on #1700
- searcher: read the unfiltered fallback result via _first_or_empty()
  instead of raw["documents"][0], matching the codebase's QueryResult/dict
  polymorphism helper and guarding the empty-result IndexError (Gemini).
- mcp_server: the content->entry remap now fills only when 'entry' is
  absent or None, so an explicit (even "") entry wins over the alias —
  the truthiness check could clobber an empty entry (Gemini/Copilot).
- mcp_server: diary_write schema now expresses the real contract —
  agent_name required + anyOf(entry, content) — so schema-validating
  clients can legally call with content only (Copilot).
- test: lock the explicit-empty-entry edge (content must not override).
2026-06-06 01:11:54 -03:00
Igor Lins e Silva ad9874fcd5 fix(mcp): repair diary_write content alias + restore -32602 diagnostic
#1245 landed on develop with three defects that turned the branch red:

- tool_diary_write gave `entry` a default (`entry: str = None`), which
  silently disabled the signature-based missing-parameter diagnostic
  (-32602) — two TestParamShapeDiagnostics tests failed.
- the `content` alias was never added to the tool input schema, so the
  dispatch arg-filter stripped `content` before the handler ever saw it,
  so the alias never actually worked.
- the filtered-search fallback inlined into search_memories pushed it
  over the C901 complexity ceiling (30 > 25), and mcp_server.py was left
  unformatted.

Fix:
- restore `entry` as a required param (revives the -32602 diagnostic)
- add `content` to the diary_write schema and remap content->entry at
  dispatch, before the handler, so a content-only call still satisfies
  the required `entry` (entry wins if both are supplied)
- extract the fallback into _query_drawers_with_filter_fallback() so
  search_memories drops back under the complexity ceiling
- ruff format mcp_server.py
- add regression tests for the content alias (content-only + both-supplied)

Keeps #1245's filtered-search recall fix intact; turns develop green.
2026-06-06 00:42:26 -03:00
Igor Lins e Silva 334fc6d0be Merge pull request #1673 from terrizoaguimor/fix/sanitize-documents-chromadb-chokepoint
fix(backends): strip lone surrogates from documents at the ChromaDB chokepoint
2026-06-06 00:23:46 -03:00
Igor Lins e Silva 1ed7f49f13 Merge pull request #1635 from trek-e/fix/mcp-search-stale-index-retry
fix(mcp): retry stale-index transient in tool_search
2026-06-06 00:23:34 -03:00
Igor Lins e Silva 097687cf63 Merge pull request #1687 from robertcprice/fix/tool-use-input-list-guard
Fix crash when tool_use input is a list instead of dict
2026-06-06 00:23:20 -03:00
Igor Lins e Silva 46e3b22316 Merge pull request #1694 from mvalentsev/fix/1693-hook-diary-agent
fix(hooks): file stop-hook diary checkpoints under the harness agent identity (#1693)
2026-06-06 00:17:14 -03:00
Igor Lins e Silva bbe6ff9477 Merge pull request #1245 from lucian-the-traveler/fix/filtered-search-fallback-and-diary-write-alias
fix: filtered search fallback + diary_write content alias
2026-06-06 00:16:58 -03:00
Igor Lins e Silva b1df41b943 Merge pull request #1137 from arnoldwender/fix/convo-miner-preserve-response-structure
fix(convo_miner): preserve blank lines and indentation in AI responses
2026-06-06 00:16:33 -03:00
Igor Lins e Silva e1fce46006 Merge pull request #1602 from mvalentsev/fix/1573-quarantine-rearm-mtime
fix(backends): re-arm HNSW quarantine gate on mtime change and explicit reconnect (#1573)
2026-06-06 00:16:12 -03:00
Igor Lins e Silva cace2230d8 Merge pull request #1618 from mvalentsev/fix/1579-hnsw-flush-at-mine-end
fix(backends): lower HNSW bloat-guard thresholds to fix sub-50k persist (#1579)
2026-06-06 00:15:52 -03:00
Igor Lins e Silva 04af6a436d Merge pull request #1100 from arnoldwender/fix/closet-llm-gbk-symbols
fix(closet_llm): replace non-ASCII symbols in progress output (#1034)
2026-06-05 23:45:38 -03:00
Igor Lins e Silva e00bc666e7 Merge pull request #1102 from arnoldwender/fix/normalize-utf8-bom
fix(normalize): handle UTF-8 BOM in transcript files
2026-06-05 23:45:23 -03:00
Igor Lins e Silva 593cfebd79 Merge pull request #1682 from potterdigital/fix/status-sqlite-count
fix(status): count drawers from sqlite instead of cold-loading the HNSW index
2026-06-05 23:44:40 -03:00
Igor Lins e Silva 6434370034 Merge pull request #1666 from MemPalace/fix/80-drawer-id-collision-delimiter
fix(ids): delimit hash inputs to prevent drawer_id collisions (#80)
2026-06-05 23:43:27 -03:00
Igor Lins e Silva 82af1fff27 Merge pull request #1678 from mvalentsev/fix/1676-mcp-wal-import-side-effect
fix(mcp): defer WAL setup so import no longer recreates ~/.mempalace (#1676)
2026-06-05 23:43:12 -03:00
Igor Lins e Silva 5289bdfedb Merge pull request #1631 from YuRiNjjot/fix-embeddinggemma-chromadb
fix(embedding): add embed_query/embed_documents to EmbeddinggemmaONNX for ChromaDB 1.5.x
2026-06-05 23:42:51 -03:00
Igor Lins e Silva 694289c423 Merge pull request #1698 from MemPalace/ci/pypi-trusted-publishing
ci: add PyPI trusted-publishing workflow
2026-06-05 23:33:37 -03:00
Igor Lins e Silva 723e45bf2b Merge pull request #1679 from MemPalace/codex/pluggable-vector-backends-qdrant
Add pluggable vector backends
2026-06-05 23:33:16 -03:00
Igor Lins e Silva 37e34f6cb5 docs: correct pgvector table_dimension comment after real-Postgres testing
Validated the backend against a real Postgres 18.4 + pgvector 0.8.2 instance
(full live roundtrip incl. close/reopen + same-dim write). The reviewer's
claim that a vector(n) column's atttypmod is dimension+4 does NOT reproduce:
raw atttypmod equals the bare dimension on 0.8.x, so the original direct read
was already correct. Keep format_type() anyway as the canonical, version-proof
way to read the typmod, and correct the comment to reflect reality instead of
asserting a bug that does not exist.
2026-06-05 23:21:20 -03:00
Igor Lins e Silva f88951e8d4 Merge remote-tracking branch 'origin/develop' into codex/pluggable-vector-backends-qdrant
# Conflicts:
#	README.md
2026-06-05 23:18:54 -03:00
Igor Lins e Silva 4bc1ea8078 fix: address Copilot second-pass review on the pluggable-backend PR
Three findings from the Copilot review on ec5d1eb:

- pgvector (real correctness bug): table_dimension() read the raw
  pg_attribute.atttypmod of the vector(n) column, which is not the bare
  dimension, so reopening a stored pgvector palace could raise a false
  DimensionMismatchError on the next same-dimension write. Now rounds through
  format_type(atttypid, atttypmod) (the type's own typmod_out), which yields
  the canonical vector(N) regardless of encoding or pgvector version. The live
  roundtrip test now closes + reopens and writes a same-dim vector to guard it.

- chroma (real correctness bug): _lexical_search_via_sqlite() returned
  LexicalHit.id as the internal embeddings.id rowid instead of the public
  embeddings.embedding_id, so lexical_search -> get(ids=...) did not round-trip
  (broke hybrid-search id lookups). Now selects e.embedding_id and maps rowid
  -> public id. Existing FTS test schema updated to include embedding_id (real
  Chroma schema) and assert the public id; added an end-to-end round-trip test
  through a real ChromaBackend collection.

- sqlite_exact (error-message quality): CollectionNotInitializedError was
  raised with palace_path instead of the collection name in get_collection and
  delete_collection, inconsistent with the other backends and line 287. Now
  names the collection; added a regression test.

Earlier first-pass findings (palace.py unknown-backend KeyError, dedup.py
docstring) were already fixed in ec5d1eb.
2026-06-05 23:14:35 -03:00
Igor Lins e Silva dc7d0be10c docs: clarify publish.yml trigger comment
Address review on #1698: a published GitHub Release may create the v* tag
or reuse an existing one. Reword the header so it no longer implies the tag
is always created at release time, and state the real guarantee — the
in-workflow checks make the pipeline self-contained (tag on main + matches
the version manifest), independent of version-guard.
2026-06-05 23:13:20 -03:00
Igor Lins e Silva 15852edbaf Merge pull request #1696 from sebaplaza/feat/docker-image
feat(docker): add Docker image for MCP server and CLI
2026-06-05 23:07:31 -03:00
Igor Lins e Silva eb3ff2e61a ci(docker): fix latest/main publishing, add arm64 + GPU build check
Review fixes for the Docker packaging PR:

- docker-publish: tie the `latest` tag to pushes on `main` (the release
  branch). Previously it was gated on `is_default_branch`, but the
  default branch is `develop` and the workflow never ran there, so
  `latest` was never produced. main + `v*` tags publish; develop is
  validated via the pull_request trigger but does not publish.
- docker-publish: publish multi-arch amd64+arm64 (Apple Silicon / ARM)
  on real pushes via setup-qemu-action; PRs stay amd64-only for speed.
- docker-publish: only export the GHA cache on in-repo events (fork PRs
  get a read-only cache, which just emits 403 noise).
- docker-publish: add a build-only job that validates Dockerfile.gpu
  compiles so the CUDA variant can't silently rot.
- Dockerfile: correct the persistence comment — the default `minilm`
  model caches under ~/.cache/chroma (ChromaDB S3), not
  ~/.cache/huggingface (that's the optional embeddinggemma model).
- docker-compose: drop the redundant MEMPALACE_PALACE_PATH override (it
  duplicated the HOME=/data default); document overrides as examples.
2026-06-05 22:39:15 -03:00
Igor Lins e Silva 0225eb4a08 docs: bump version on develop, not directly on main
Address review on #1698: committing the version bump straight to main
bypasses branch protection and drifts develop behind. Bump on develop
first; it reaches main via the develop -> main merge.
2026-06-05 22:30:05 -03:00
Igor Lins e Silva 1dadd33f35 ci: add PyPI trusted-publishing workflow
Publish to PyPI on a published GitHub Release via Trusted Publishing
(OIDC — no stored token), gated by the `pypi` environment's manual
approval. The build job verifies the release tag is reachable from main
and matches mempalace/version.py before building the sdist + wheel; a
separate publish job holds the id-token scope and does the upload.

Documents the one-time setup (PyPI trusted publisher + `pypi`
environment) and the per-release runbook in docs/RELEASING.md.
2026-06-05 22:26:41 -03:00
Igor Lins e Silva ec5d1eb1c7 feat: add pgvector backend + namespace-isolation conformance contract
Adds a second external storage backend (Postgres/pgvector) alongside Qdrant
to prove the BaseBackend/BaseCollection contract generalizes across substrates
(SQL + JSONB containment filters + pgvector `<=>` ranking vs Qdrant's REST/dict
model), and addresses the review feedback on PR #1679.

Backend (mempalace/backends/pgvector.py):
- table-per-(namespace, palace, collection) isolation; advertises
  supports_namespace_isolation
- JSONB filter pushdown for the containment subset, local-exact fallback for
  $or/$contains/comparisons/where_document
- BM25 lexical search; marker-based mismatch protection
- optional psycopg dependency (lazy import), in-memory fake for CI, live test
  gated on MEMPALACE_PGVECTOR_LIVE_URL
- registered in registry/__init__/pyproject entry point + [pgvector] extra;
  MEMPALACE_PGVECTOR_DSN / MEMPALACE_PGVECTOR_NAMESPACE config; README docs

Isolation contract (RFC 001):
- PalaceRef/BaseBackend document the per-id MUST and the cross-namespace MUST,
  gated on the new supports_namespace_isolation capability token
- runnable conformance suite (tests/_backend_conformance.py,
  tests/test_backend_conformance.py); qdrant + pgvector run it via their fakes

Marker fail-loud guard:
- qdrant and pgvector now refuse get_collection when local_path is None instead
  of silently opening a remote collection with no mismatch protection

Review fixes:
- palace._open_collection_or_explain handles unknown-backend KeyError as a CLI
  state message instead of an escaping stack trace
- dedup.py docstring no longer claims "No API calls" unconditionally (false for
  remote backends)
2026-06-05 21:41:21 -03:00
margaretjgu 9be2b97b6c fix(backups): add max_backups retention to bound backup disk usage
mempalace migrate (.pre-migrate.* full-palace copies) and mempalace repair
max-seq-id (chroma.sqlite3.max-seq-id-backup-* DB copies) each wrote a fresh,
full-size, timestamped backup every run and never deleted the old ones. On a
machine that mines or repairs on a schedule, those copies could silently
accumulate until they filled the disk.

Add a configurable max_backups setting (default 10; env MEMPALACE_MAX_BACKUPS
or config.json) and a shared prune_backups helper that trims the oldest copies
after each new backup is written. Pruning is keyed by filesystem mtime, scoped
strictly to each backup's own naming pattern so live data is never touched, and
best-effort so a deletion failure can never abort the migrate/repair that just
succeeded. Set max_backups to 0 to keep every backup.
2026-06-05 14:52:06 -04:00
plaza-s 8757ba2650 fix(docker): address gemini review on MR #1696 2026-06-05 13:35:36 +02:00
sebaplaza 717a57ff43 feat(docker): add container image for MCP server and CLI
Add a multi-stage, uv-based Dockerfile producing a CPU image (with the
extract + spellcheck extras), plus a CUDA variant (Dockerfile.gpu) for
onnxruntime-gpu accelerated embeddings.

A single flexible entrypoint dispatches to the MCP stdio server (default)
or the mempalace CLI. All state -- palace, config, and the lazily
downloaded embedding model -- persists under /data via HOME, runs as a
non-root user, and is exposed as a volume.

Also add a docker-compose.yml for convenience, a GHCR publish workflow,
and a Docker section in the README.
2026-06-05 12:32:33 +02:00
mvalentsevandYC-AIUSER 4e0fce010e fix(hooks): file stop-hook diary checkpoints under the harness agent identity (#1693)
Stop-hook checkpoints were saved via _save_diary_direct with a hardcoded
agent_name="session-hook". tool_diary_read filters Chroma metadata by
agent, so mempalace_diary_read(agent_name="claude") never surfaced any
hook-saved checkpoint. Derive the diary identity from the harness
(claude-code -> claude, codex -> codex; an unknown harness keeps its own
name) and thread it through to tool_diary_write. Make agent_name a
required keyword argument so the identity is always explicit.

Co-Authored-By: YC-AIUSER <273917354+YC-AIUSER@users.noreply.github.com>
2026-06-05 12:26:27 +05:00
robertcprice 5ba8399bb2 Fix crash when tool_use input is a list instead of dict
Some Claude Code JSONL transcripts have tool_use blocks where the
`input` field is a list rather than a dict (e.g. multi-content tool
calls). This causes an AttributeError on line 554 when code tries to
call `.get()` on the list.

Guard by normalizing list inputs to an empty dict, allowing mining
to proceed without losing other tool metadata.

Fixes: mining Claude Code conversations crashes with
  AttributeError: 'list' object has no attribute 'get'
  at normalize.py:554
2026-06-03 20:29:48 -04:00
sjhddh f5d2212577 style(tests): apply ruff 0.4.x format to test_normalize
Fixes lint CI: ruff format --check flagged blank-line and long-dict
wrapping in the Continue.dev parser tests.
2026-06-03 18:36:50 +02:00
Brian potterandClaude Opus 4.8 ce46747282 fix(status): count drawers from sqlite instead of cold-loading the HNSW index
`mempalace status` opened the ChromaDB collection purely to tally drawers by
wing/room — and opening it cold-loads the HNSW vector index. On a 398k-drawer
palace that load costs ~60s of CPU on every invocation, even though the counts
live in chroma.sqlite3's relational tables (`repair-status` already reads them
in <1s; `status` was the outlier).

Read the wing/room histogram directly from chroma.sqlite3 via a new
`_sqlite_wing_room_counts` helper, falling back to the existing ChromaDB-client
path when the sqlite read is unavailable (missing DB, un-bootstrapped
collection, sustained writer lock, or an unexpected schema) — preserving the
state-specific guidance for absent/empty palaces.

Measured on a 398,315-drawer / 3.5GB palace: status CPU ~60s -> ~1s.

Review hardening:
- PRAGMA busy_timeout so a transient checkpoint lock is waited out rather than
  instantly demoted to the slow path; a sustained lock still falls back.
- COALESCE over string/int/float so a numeric wing/room matches the ChromaDB
  path instead of dropping to "?".
- Explicit `s.scope = 'METADATA'` so the segment join can't silently
  double-count on a future ChromaDB layout.

Tests: exact-tally (anti fan-out), no-cold-load regression (proven failable by
reverting the fix), numeric-metadata, partial-metadata "?" bucketing,
locked-DB fallback, and collection-absent None routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 02:08:19 -05:00
Milla J 02b8753d97 Merge pull request #1680 from MemPalace/fix/1619-hallway-pagination
fix(hallways): paginate drawer fetch to avoid SQLite variable overflow on large wings (#1619)
2026-06-02 22:18:15 -07:00
Milla J 300e824e94 docs(hallways): correct col contract in compute_hallways_for_wing docstring (#1680)
The docstring still said col "must support .get(where=..., include=...)",
but this PR changed the fetch to count() + paginated
get(limit=, offset=, include=) filtered client-side, precisely to avoid the
get(where=...) path that overflows SQLite's variable limit on large wings.

Update the Args entry to describe the real contract so fake collections and
alternate backends implement the right shape.

Docstring only — no behavior change.
2026-06-02 21:56:29 -07:00
Milla J af2bd0e10a style(tests): format test_hallways_pagination.py with ruff (#1680)
The where-filter update to the pagination regression test left one list
comprehension past the line-length limit, so `ruff format --check .` failed
in CI while every test platform passed. Wrap the comprehension as ruff
format produces it — no logic change.

Restores the lint job to green.
2026-06-02 21:45:14 -07:00
Milla Jandgemini-code-assist[bot] 526d58ae93 Update tests/test_hallways_pagination.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-02 19:56:16 -07:00
Milla J 4d3daf96d3 fix(hallways): paginate drawer fetch to avoid SQLite variable overflow on large wings (#1619)
compute_hallways_for_wing fetched the whole wing in a single
col.get(where={"wing": wing}). ChromaDB binds one SQL variable per matched id,
so on a wing larger than SQLITE_MAX_VARIABLE_NUMBER (32766) the call raised
"too many SQL variables" inside chromadb. The exception was caught, so the mine
completed — but the wing's hallway graph silently never built, and the
cross-wing tunnels promoted from it were starved, on exactly the large wings
that benefit most from navigation. Confirmed threshold: a 42,062-drawer wing
crashed; a 29,629-drawer wing succeeded.

Replace the single where-get with the established pagination pattern: count()
+ get(limit=5000, offset=...) filtered to the wing client-side — matching
miner.status, palace.regenerate_closets, and palace_graph.build_graph, which
already paginate to dodge the same 32766 limit.

Tests:
- test_hallways_pagination: a collection whose where-get raises (simulating the
  overflow) while count() + paginated get works — RED before, GREEN after.
- test_hallways: _fake_collection updated to the paginated API; existing
  hallway tests are unchanged in behavior.

Closes #1619.
2026-06-02 19:15:10 -07:00
Igor Lins e Silva 30b82ba95f fix: avoid qdrant lexical full scan on empty text hits 2026-06-02 21:50:08 -03:00
Igor Lins e Silva 6aa8e93bc9 feat: add pluggable vector backends 2026-06-02 21:38:53 -03:00
mvalentsevandGrace Gettert 0dd0ade3b9 fix(mcp): defer WAL setup so import no longer recreates ~/.mempalace (#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()`, #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 #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>
2026-06-03 02:55:19 +05:00
M8SONandClaude Opus 4.8 b5f3915d5b fix(config): strip leading/trailing separators in normalize_wing_name
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>
2026-06-01 21:21:56 -04:00
Mario Gutierrez e54896699b fix(backends): keep single-string documents whole in surrogate sanitiser
Per Gemini review on #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.
2026-06-01 17:19:10 -05:00
Mario Gutierrez 6dd99ec4b0 fix(backends): strip lone surrogates from documents at the ChromaDB chokepoint
#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.
2026-06-01 16:38:40 -05:00
undeadindustriesandCursor a329acf23c fix(antigravity): resolve interpreter from console-script shebang
`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 #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>
2026-05-31 10:41:09 +10:00
Milla J 206cfbb6ca fix(ids): delimit hash inputs to prevent drawer_id collisions (#80)
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 #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 #1572 (Copilot finding #13). Styleguide audit
documented above: 8 fix/migration + 11 enumerated exempt sites.
2026-05-30 12:44:47 -07:00
mvalentsevandC-LaForest 22804b8e08 fix(backends): guard mtime_appeared discard behind _freshness membership
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 #1602.

Co-Authored-By: C-LaForest <C-LaForest@users.noreply.github.com>
2026-05-30 22:40:01 +05:00
mvalentsevandC-LaForest c19d894f68 fix(backends): re-arm HNSW quarantine gate on mtime change and explicit reconnect (#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>
2026-05-30 22:40:01 +05:00
mvalentsevand0xKingVee9527 7b1f6247d0 fix(backends): detect sub-threshold segments by link_lists state, not 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>
2026-05-30 22:35:34 +05:00
mvalentsev b11f41484c style(test): use _HNSW_MISSING_METADATA_DATA_FLOOR constant instead of magic 1024 2026-05-30 22:35:34 +05:00
mvalentsevandTim Harmon 10d1ce6a94 fix(backends): lower HNSW bloat-guard thresholds to fix sub-50k persist (#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>
2026-05-30 22:35:34 +05:00
Arnold Wender 038a7e0e15 fix(convo_miner): preserve blank lines and indentation in AI responses
_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.
2026-05-30 14:47:16 +02:00
Arnold Wender 29db125b9b fix(closet_llm): replace non-ASCII symbols in progress output (#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 #681 affects closet_llm.py and cli.py.

Replace with ASCII equivalents: [OK], [FAIL], [!], and hyphen.
2026-05-30 14:47:03 +02:00
Arnold Wender 818e4f3b31 fix(normalize): use utf-8-sig to handle BOM-prefixed transcript files
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.
2026-05-30 14:47:01 +02:00
Igor Lins e Silva 48f4eb6dfe test(embedding): expect 3 hf_hub_download calls (model + .onnx_data weights + 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).
2026-05-30 02:57:09 -03:00
Igor Lins e Silva 9b7cfc9940 Merge pull request #1617 from mvalentsev/fix/1611-config-json-str-type
fix(backends): repair missing _type in collection config (#1611)
2026-05-30 00:37:13 -03:00
undeadindustriesandCursor c420a9f66c fix(cursor): address igorls review on PR #1632
Resolves the maintainer review on the Cursor IDE support PR. Cursor-only
scope; cross-IDE items (wing-naming convention, shared-file merge order)
are coordinated on the separate Antigravity branch.

followup_message default (the one "decide before merge" item):
- Keep the stop-hook followup ON by default. Cursor's transcript format
  is undocumented and mempalace/normalize.py has no Cursor parser, so the
  background `mempalace mine --mode convos` is best-effort only and does
  not yet yield clean verbatim drawers. The followup is therefore the
  load-bearing verbatim-capture path; defaulting it off would leave a
  default Cursor install capturing nothing.
- Add an opt-out (MEMPAL_CURSOR_SILENT=1, or MEMPAL_VERBOSE=false) for
  users who want the Claude-style "zero tokens in chat" behaviour. The
  hook still mines and keeps its counters/markers when silenced.
- Correct the misleading "background mine captures it" comments in the
  save and precompact hooks; update hooks/cursor/README.md and the guide.

Hygiene fixes:
- Drop the hardcoded "version" field from .cursor-plugin/plugin.json and
  marketplace.json (mempalace/version.py is the single source of truth);
  tests now assert the field stays absent.
- Remove the committed .cursor-plugin/{commands,skills} symlinks (they
  break on Windows clones with core.symlinks=false and were redundant
  with the real repo-root components that `source: "."` already serves);
  add a guard test that no symlinks exist under .cursor-plugin/.
- Document the preCompact synchronous-mine timeout tradeoff and that an
  incremental/append-only mine is recoverable if killed (no corruption).
- Add a Cursor-namespaced, daily-throttled TTL sweep (MEMPAL_STATE_TTL_DAYS,
  default 30) to lib/common.sh that GCs stale cursor_*.count/.pending only,
  after the kill-switch check; shared logs and antigravity_* are untouched.

Verification: full suite green (2424 passed, 3 skipped), ruff check +
format clean, bash -n clean on all cursor scripts. +30 Cursor tests
(followup opt-out, state GC, TTL validation, no-symlink/version guards).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:13:51 +10:00
undeadindustriesandCursor df295bd743 fix(antigravity): atomic counter write, background --version probe, state-file GC
Addresses igorls' review on PR #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>
2026-05-30 10:18:46 +10:00
Igor Lins e Silva 387d50df27 Merge pull request #1647 from MemPalace/dependabot/pip/ruff-0.15.15
build(deps-dev): bump ruff from 0.15.14 to 0.15.15
2026-05-29 19:38:12 -03:00
Igor Lins e Silva 55d4e379a7 Merge pull request #1652 from MemPalace/fix/file-already-mined-multi-mtime-groups
fix(palace): file_already_mined iterates all groups when source_file has multiple parent_drawer_id mining passes
2026-05-29 19:37:56 -03:00
Milla J 13c38ac588 fix(palace): file_already_mined iterates all groups when source_file 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 #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 #1628 fixed at the searcher layer.
2026-05-29 11:50:44 -07:00
dependabot[bot] a0508cb065 build(deps-dev): bump ruff from 0.15.14 to 0.15.15
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](https://github.com/astral-sh/ruff/compare/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>
2026-05-29 07:59:47 +00:00
Tom Boucher 2d4fae1ac3 fix(mcp): retry stale-index transient in tool_search 2026-05-27 12:37:02 -04:00
undeadindustriesandCursor b06cf2452e fix(antigravity): address gemini-code-assist review on PR #1633
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>
2026-05-27 19:49:19 +10:00
undeadindustries bf156fb010 feat: add Antigravity IDE support (plugin, MCP, skill, hooks, docs, tests)
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.
2026-05-27 19:19:17 +10:00
undeadindustriesandCursor df5db57e79 fix(cursor): address gemini-code-assist review on PR #1632
Five fixes from the Gemini Code Assist review on
https://github.com/MemPalace/mempalace/pull/1632 — three real bugs,
two cleanups, all consistent with the bash-3.2-compatibility
contract documented in the original commit.

Bug fixes (high)
----------------

1. hooks/cursor/lib/common.sh — config.json kill-switch check used
   a `python3 - <<'PYEOF' ... PYEOF` heredoc inside a `$(...)`
   command substitution. The heredoc body contains parens which
   trips the macOS bash 3.2.57 parser bug. Replaced with a
   `python -c '...'` call passing the config path as argv[1]. Matches
   the pattern already used in mempal_parse_stdin in the same file.

2. hooks/cursor/install.sh — a relative `--install-dir` was written
   verbatim into hooks.json. Cursor invokes hook commands from its
   own working directory (typically the project root), so a relative
   command path would silently fail to launch the hook. Now resolved
   to an absolute path against `$PWD` before being baked in.

3. hooks/cursor/mempal_save_hook_cursor.sh — `MEMPAL_SAVE_INTERVAL=0`
   would crash bash on `$((NEXT % 0))` (division by zero). Extended
   the existing sanitiser case to coerce 0 to the default interval
   alongside empty / non-numeric values.

Cleanups (medium)
-----------------

4. hooks/cursor/install.sh — the EMPTY_CHECK_PY temp file is now
   inlined as `python -c '...'`. Removes a small leak window
   (tmpfile would linger if the script were interrupted between
   mktemp and rm -f) and shortens the script.

5. hooks/cursor/install.sh — `mktemp -t prefix` has subtly different
   semantics on BSD (macOS) vs GNU mktemp. Switched to the
   portable absolute-template form `mktemp "${TMPDIR:-/tmp}/...XXXXXX"`
   which behaves identically on both.

Regression tests
----------------

- tests/test_cursor_hooks_shell.py
    test_save_interval_zero_is_coerced_to_default — guards fix #3.
- tests/test_cursor_hooks_install.py — new TestInstallDirAbsolutePath
  class:
    test_relative_install_dir_is_absolutized_in_hooks_json — guards
        fix #2 against regression.
    test_absolute_install_dir_is_preserved_verbatim — guards that
        the relative-to-absolute resolution does not mangle paths
        that were already absolute.

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

- bash -n on all three edited scripts: clean.
- uv run pytest tests/test_cursor_hooks_*.py tests/test_cursor_plugin_manifest.py: 132 passed (was 129; +3 regression tests).
- uv run pytest tests/ --ignore=tests/benchmarks: 2399 passed,
  3 skipped (pre-existing).
- uv run ruff check . / ruff format --check .: clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:17:36 +10:00
undeadindustriesandCursor 071fa015e0 feat: add Cursor IDE support (hooks, plugin, skill, docs, tests)
Adds first-class Cursor IDE integration alongside the existing Claude
Code and Codex hook flows, so Cursor users get the same automatic
diary saves, pre-compaction transcript capture, and session-start
memory recall — without changing any default behaviour for existing
users.

What's included
---------------

Cursor hook scripts (hooks/cursor/):
  - mempal_save_hook_cursor.sh       — Stop event, counter +
    loop_count guard, pending-save marker consumption, background
    mempalace mine, followup_message emission.
  - mempal_precompact_hook_cursor.sh — synchronous mine before
    compaction, drops a pending_save marker, returns user_message.
  - mempal_wake_hook_cursor.sh       — sessionStart event,
    wing-scoped recall guidance via additional_context.
  - lib/common.sh                    — shared parsing + state helpers
    (bash 3.2 safe, no heredoc-in-subshell traps).
  - install.sh                       — idempotent installer with
    --scope, --variant, --dry-run, --uninstall. Recognises existing
    entries by basename so re-installs across paths work.
  - STDIN_SHAPE.md, README.md        — payload schemas + quick
    reference.

Cursor plugin (.cursor-plugin/ + repo-root components):
  - plugin.json, marketplace.json, README.md.
  - skills/mempalace/SKILL.md  — model-invocable skill mirroring the
    Claude plugin's skill surface.
  - commands/mempalace-{help,init,mine,search,status}.md  — slash
    commands for marketplace-published installs (filename = slug).
  - mcp.json                   — auto-registers the mempalace MCP
    server, wrapped under the documented mcpServers key.

Examples + docs:
  - examples/cursor/hooks.json, hooks.minimal.json + README.
  - website/guide/cursor-hooks.md + sidebar entry.
  - README.md and CHANGELOG.md updates.

Tests (129 new, all green):
  - tests/test_cursor_hooks_shell.py     — 75 behavioural tests for
    the three hook scripts: kill switches, input parsing, counter
    logic, loop prevention, pending markers, wing inference, logging.
  - tests/test_cursor_hooks_install.py   — 19 contract tests for the
    installer: dry-run, idempotent merge, basename-matched uninstall,
    refusal to overwrite malformed JSON.
  - tests/test_cursor_plugin_manifest.py — 35 contract tests for the
    plugin: manifest validity, version sync with mempalace.version,
    mcp.json shape, skill/command frontmatter, default-discovery
    layout invariants.

Design notes
------------

- Local-first and zero-API by default; hooks never call external
  services. Same privacy model as the existing Claude Code hooks.
- Fail-open: hook scripts deliberately do not use set -e so a broken
  hook can never block the user's conversation.
- Cursor preCompact cannot block + return a followup, so we
  synchronously mine the transcript and drop a pending_save marker
  that the next stop hook consumes — guarantees verbatim capture
  before context window compression.
- Cursor's default plugin discovery requires real commands/, skills/,
  and mcp.json at the plugin root (verified against the cached
  cloudflare plugin); .cursor-plugin/{commands,skills} are convenience
  symlinks back to those canonical locations.
- bash 3.2 compatibility throughout: avoids heredoc-in-command-
  substitution parser bugs; uses python -c for JSON parsing;
  basename-matched entry recognition in install.sh.
- All changes are additive. No existing files are removed, no
  existing hooks change behaviour, and no new runtime dependencies
  are introduced.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:05:40 +10:00
YuRiNjjot 15ac4303f5 fix(embedding): add embed_query/embed_documents to EmbeddinggemmaONNX 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.
2026-05-27 05:28:38 +02:00
Igor Lins e Silva 6957c7e599 Merge pull request #1615 from MemPalace/chore/sync-3.3.6-back-to-develop
chore: sync main back to develop after v3.3.6 release
2026-05-26 21:32:51 -03:00
Tom Boucher 234343ef68 fix: preserve collection name on MCP search retry 2026-05-25 20:09:34 -04:00
mvalentsevandClaude Opus 4.7 c4d8804b44 fix(backends): close sqlite connection before PersistentClient
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>
2026-05-25 13:56:37 +05:00
d88597cd72 fix(backends): repair missing _type in collection config (#1611)
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>
2026-05-25 13:49:16 +05:00
Igor Lins e Silva 753df201ac Merge main back into develop after v3.3.6 release
Brings in the three commits that landed on main during the v3.3.6
release cycle but not on develop:

  - 62a555c fix(release): align ruff pin to 0.15.14 + hoist COCA
            imports out of hot paths (#1614)
  - a1cf052 Merge pull request #1614 from MemPalace/fix/release-3.3.6-ruff-pin-align
  - db1fbe8 Merge pull request #1610 from MemPalace/release/3.3.6

Resolves conflicts in miner.py and palace.py where develop's #1613
(known-systems lexicon, Tier 3) had added _apply_known_systems_prepass
to module-top imports + the prepass call in build_closet_lines. Main
side had only the COCA hoist from #1614. Resolution: keep develop's
side (the prepass additions).

Mirrors the v3.3.5 sync-back pattern (#1442).
2026-05-24 20:51:42 -03:00
Igor Lins e Silva d0d011eb33 Merge pull request #1613 from MemPalace/feat/known-systems-lexicon-compound-matcher
feat(entity): known-systems lexicon keeps multi-word product names atomic
2026-05-24 20:09:25 -03:00
Igor Lins e Silva dbbf4c5af5 Merge develop into feat/known-systems-lexicon-compound-matcher
Resolves conflicts in miner.py and palace.py against the COCA-hoist
work from #1612 just merged. Both files had local imports for
_get_coca_filter / _candidate_entity_words in the function bodies;
after #1612 those moved to module top. Resolution: hoist
_apply_known_systems_prepass alongside _get_coca_filter at module
top in both files, drop the now-empty local imports.

No behavior change; just removes the local-import duplication that
would have been left in place if the merge auto-resolved naively.
2026-05-24 20:02:30 -03:00
Igor Lins e Silva c9d8a40a37 Merge pull request #1612 from MemPalace/chore/hoist-coca-filter-imports
perf(miner,palace): hoist COCA filter imports out of per-drawer hot paths
2026-05-24 20:00:33 -03:00
Igor Lins e Silva be819da146 Merge pull request #1608 from MemPalace/chore/readme-move-scam-alert-below-header
docs(readme): move scam/retention alerts below header, soften tone
2026-05-24 19:47:40 -03:00
Milla J cbd1567b6a fix(entity): precompile known-systems regex once in cached loader
Addresses gemini-code-assist MEDIUM finding on PR #1613: the previous
implementation of _apply_known_systems_prepass compiled a regex pattern
for every compound on every call, repeating the work on every drawer
mined and every closet built. With 59 compounds × N drawers, that's
59N re.compile() calls for a workload where the patterns never change.

The fix moves compilation into _get_known_systems (already lru_cache'd
to size=1), which now returns tuple[tuple[str, re.Pattern], ...] —
pairs of (canonical name, pre-compiled case-insensitive word-bounded
regex). _apply_known_systems_prepass consumes the cached tuple and
does zero compilation in the hot path.

Behavior is identical: same word boundaries, same case-insensitive
matching, same longest-first ordering, same graceful-degrade on
malformed json. All 74 entity_detector tests still pass on macOS plus
the full 2258-test suite on Linux Py 3.9 / 3.11 / 3.13.
2026-05-24 15:02:32 -07:00
Milla J 653e9774e8 feat(entity): known-systems lexicon keeps multi-word product names atomic
Adds a curated list of multi-word product/system names ("Claude Code",
"GitHub Copilot", "Visual Studio Code", "GPT-4", …) and a compound
pre-pass that detects them atomically before the existing single-word
extraction runs. Without this, the regex-based detector decomposes
"Claude Code" into "Claude" + "Code" — and the COCA filter (shipped
in v3.3.6) then drops "Code" as a content word, leaving "Claude" alone
with the wrong attribution.

What ships

  - mempalace/data/known_systems.json — 59 curated compounds covering
    common AI assistants, IDEs, model names, cloud platforms, and
    Office/Google apps. Each entry is multi-word or hyphenated;
    single-word product names ("ChatGPT", "Cursor") have no
    decomposition risk and stay handled by the existing regex.

  - mempalace/entity_detector.py — _get_known_systems() (cached loader,
    mirrors _get_coca_filter from Tier 2) and _apply_known_systems_prepass
    which scans for each compound case-insensitively with word boundaries,
    counts occurrences, and returns the masked text + count dict so the
    subsequent single-word + multi-word loops don't re-decompose.

  - mempalace/miner.py and mempalace/palace.py — same pre-pass wired
    into _extract_entities_for_metadata (per-drawer tagger) and
    build_closet_lines (closet pointer construction). Without these,
    the new behavior would only apply at init-time and per-drawer
    metadata would still decompose compounds.

How it interacts with Tier 2

  Tier 2 (COCA filter) blocks single-word content nouns like "Code"
  and "Brutal". Tier 3 protects multi-word product names so they
  don't get decomposed in the first place. They complement each
  other: the compound pre-pass runs FIRST and masks compounds out
  of the text; the COCA filter then runs on the remaining
  single-word candidates.

Behavior verification

  Before this PR, mining a document containing "Claude Code wrote the
  patch" three times emitted entities:
    Claude;Claude Code;Code (Code filtered by COCA);

  After this PR, the same document emits:
    Claude Code

  The standalone "Claude" no longer appears (it never actually appeared
  alone in the source) and decomposition stops at the compound boundary.

Tests

  Nine new tests in tests/test_entity_detector.py covering:
    - "Claude Code" detected as atomic compound at extract_candidates
    - "Claude" alone NOT in results when only mentioned as part of compound
    - Case-insensitive compound matching (claude code, CLAUDE CODE, etc.)
    - Single-word "Code" still filtered by COCA (no Tier 2 regression)
    - Single-word real name "Aya" still detected (no regression on names)
    - Multiple distinct compounds in one text both detected
    - Unknown two-word phrase still detected via existing multi-word regex
    - known_systems.json ships with expected schema (>=20 entries, all multi-token)
    - known_systems.json contains expected high-value entries

Verification

  Full pytest 2267 passed / 3 skipped on macOS, coverage 85.34%.
  Linux Py 3.9 / 3.11 / 3.13 via CI-matching pip install -e ".[dev]":
  2258 passed each. End-to-end mine of a compound-rich corpus
  confirms chromadb entities metadata now shows compounds atomic
  (Claude Code, GPT-4, GitHub Copilot, Visual Studio Code) with no
  decomposition.
2026-05-24 13:50:53 -07:00
Milla J 5fe54df4c8 perf(miner,palace): hoist COCA filter imports out of per-drawer hot paths
The COCA content-word filter shipped in PR #1605 imported
`_get_coca_filter` and `_candidate_entity_words` locally inside two
hot paths:

  - `palace.build_closet_lines` — runs per source file during mine
  - `miner._extract_entities_for_metadata` — runs per drawer during mine

Both imports are now at module top, where they're resolved once at
import time instead of on every per-drawer call. Module-top imports
also make the dependency graph visible to static analysis (pylint's
C0415 was flagging the locals).

No behavior change. The `_get_coca_filter()` call is unchanged — only
the import statement moved. End-to-end mining produces identical
chromadb output. Addresses the MEDIUM finding gemini-code-assist
raised on PR #1605 review.

Verification: full pytest 2258 passed / 3 skipped / coverage 85.35%.
ruff check + format clean. Linux Py 3.9 / 3.11 / 3.13 via CI-matching
`pip install -e ".[dev]"`: 2249 passed each. End-to-end mine of a
test corpus produces the expected drawer + closet pointer.
2026-05-24 12:58:21 -07:00
Igor Lins e Silva f51ecc1621 docs(readme): move CAUTION/IMPORTANT alerts below header, soften tone
The scam-alert and Claude-Code-retention admonitions were the first
content visitors saw on the repo page — louder than the project
introduction. Moves both below the logo/title/badges so the project
identity reads first, and softens the scam block (drops the H1
"CRITICAL SECURITY WARNING" + all-caps shouting + redundant emoji)
to a single-paragraph CAUTION. All factual content preserved:
impostor-domain warning, official sources, malware caveat, link to
docs/HISTORY.md.
2026-05-24 13:48:20 -03:00
sjhddh 25b918ee7e feat(normalize): add Continue.dev session parser
Add _try_continue_json() normalizer for Continue.dev AI assistant
sessions (~/.continue/sessions/*.json). Parses history array with
role/content pairs, handles tool calls, system messages, and metadata.

Closes #59 (partial — adds Continue.dev format support)

Includes comprehensive test coverage for valid sessions, edge cases,
malformed input, and unicode content.
2026-05-18 13:08:09 +00:00
FBISiri a9a2c35e65 feat: add Gemini CLI / AI Studio JSON session import support
Adds _try_gemini_json parser to normalize.py for three layouts:

  1. Gemini API contents format (~/.gemini/sessions/*.json):
     {"contents": [{"role": "user", "parts": [{"text": "..."}]}, ...]}
  2. Messages-wrapper variant:
     {"messages": [{"role": "user", ...}, {"role": "model", ...}]}
  3. Flat top-level list with role="model".

This complements the existing _try_gemini_jsonl parser (which handles
~/.gemini/tmp/<hash>/chats/session-*.jsonl with session_metadata
sentinel) — JSONL covers Gemini CLI runtime sessions, JSON covers
exported / Studio-saved transcripts.

## Review feedback addressed (PR #204)

bgauryy review:
- #1 Parser-precedence bug: _try_gemini_json runs *before*
  _try_claude_ai_json so the {"messages":[..., role=model, ...]}
  layout is no longer silently claimed by the Claude parser. The
  Gemini parser's has_model_role guard prevents false-positives
  against Claude / ChatGPT data.
- #2 Layout 2a coverage: TestGeminiJson.test_messages_wrapper_format
  + test_messages_wrapper_does_not_get_claimed_by_claude pin the
  fix in place.
- #3 Test conflicts with current main: rebased onto develop;
  tests restructured into TestGeminiJson class.
- #4 tempfile/os.unlink → pytest tmp_path everywhere.
- #5 elif not text → else (the elif branch was dead).
- #6 Module docstring updated to mention Google AI Studio.

Tests: 9 new cases in TestGeminiJson covering all three layouts,
multi-part text joining, non-text part skipping, has_model_role
disambiguation, dispatch-chain regression for review #1.
2026-05-09 14:05:45 +08:00
adv3nt3 66b0c5066b feat: add Pi agent JSONL session normalizer
Add _try_pi_jsonl parser for Pi agent session files stored at
~/.config/pi/agent/sessions/{encoded-cwd}/{timestamp}_{uuid}.jsonl.

Uses type "message" entries with role "user"/"assistant". Skips
toolResult messages, model_change, thinking_level_change, and other
operational events. Requires session header (type "session" with
"version" key) to avoid false positives.

Format documented at github.com/badlogic/pi-mono session.md and
verified via Context7. Sample data provided by tunnckoCore in #59.

Refs: #59
2026-05-08 14:08:31 +02:00
git e039a675af feat(miner): add support for Swift and Kotlin file extensions
- Updated READABLE_EXTENSIONS in miner.py to include ".swift", ".kt", and ".kts".
- Added tests in test_miner.py to ensure scanning includes Swift and Kotlin files.
2026-05-06 00:26:34 +02:00
Lucian Tennyson d12c96e43e fix: filtered search fallback and diary_write content alias
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.
2026-04-27 17:53:10 -04:00
David WallaceandClaude Sonnet 4.6 6165aa2d17 feat(miner): add C# and .NET file extensions to READABLE_EXTENSIONS
Adds .cs, .csproj, .sln, .razor, and .cshtml so C#/.NET projects
are indexed by the project miner. .razor/.cshtml are analogous to
the already-supported .jsx/.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:05:52 -04:00
grtninja 5dcdd1e14d docs: remove GitHub Discussions reference from contributing guide 2026-04-11 03:51:57 -04:00
grtninja 2cd7b2b023 docs: front-load MX3 hardware requirement 2026-04-11 02:34:01 -04:00
grtninja 510f671660 docs: add MX3 public shim examples 2026-04-11 02:34:01 -04:00
279 changed files with 78842 additions and 2019 deletions
+2 -2
View File
@@ -8,11 +8,11 @@
"name": "mempalace",
"source": {
"source": "local",
"path": "./.codex-plugin"
"path": "./"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "NONE"
"authentication": "ON_INSTALL"
},
"category": "Coding"
}
+77
View File
@@ -0,0 +1,77 @@
# MemPalace — Antigravity plugin
In-repo packaging for the MemPalace integration with Google's [Antigravity IDE](https://antigravity.google/).
This directory is the source of truth for what gets installed at
`~/.gemini/config/plugins/mempalace/` when the user runs the installer.
## Layout
```
.antigravity-plugin/
├── plugin.json # marker manifest (verified minimal schema)
├── mcp_config.json # auto-registers the mempalace-mcp stdio server
├── hooks.json.tmpl # template — installer renders to hooks.json
├── skills/
│ ├── mempalace/
│ │ └── SKILL.md # ops skill: setup, mine, status, CLI delegation
│ └── mempalace-recall/
│ └── SKILL.md # recall-only skill: search-before-answer protocol
├── rules/
│ └── mempalace-recall.md # optional recall rule (complements the skill)
└── README.md # this file
```
The hook scripts themselves live at `hooks/antigravity/`. The installer
copies them into `<install-dir>/hooks/` and renders `hooks.json.tmpl`
into a `hooks.json` whose `command` paths point at the absolute install
location.
## Three recall layers
MemPalace can store everything, but it only helps if the agent actually
*reads* the palace before answering. Three layers wire that in, from
eager to on-demand:
1. **Wake hook** (`hooks/antigravity/mempal_wake_hook_antigravity.sh`,
`PreInvocation` event, gated to `invocationNum == 1`). On the first
model call of a conversation it runs `mempalace wake-up` and injects
the **actual palace content verbatim** via Antigravity's
`injectSteps[].ephemeralMessage` output. This is Antigravity's native
equivalent of Cursor's `sessionStart` `additional_context`, except it
delivers the memory itself rather than a directive to go fetch it.
2. **Recall skill** (`skills/mempalace-recall/SKILL.md`). The
search-before-answer protocol the agent follows when a turn is
recall-relevant — tool selection, unhappy paths, anti-patterns. It
covers recall only; the `mempalace` skill covers setup / mine /
status.
3. **Optional recall rule** (`rules/mempalace-recall.md`). A lightweight
markdown rule that nudges the agent to search before answering when
Antigravity's matcher decides the turn is recall-relevant. It is
deliberately recall-scoped (not an always-on global rule) so it never
adds latency to greenfield work, honouring MemPalace's "memory should
feel instant" budget.
All three point to the single canonical protocol in
[`integrations/shared/recall-protocol.md`](../integrations/shared/recall-protocol.md)
so the skill and rule never drift.
## Install
```bash
bash hooks/antigravity/install.sh
```
The installer is idempotent and the uninstaller matches by basename, so
re-runs and partial installs are safe.
See [website/guide/antigravity.md](../website/guide/antigravity.md) for
the full user-facing guide and [hooks/antigravity/README.md](../hooks/antigravity/README.md)
for the hooks-specific documentation.
## Verified surfaces
Every file in this directory maps to a surface verified against
[Google's Antigravity docs](https://antigravity.google/docs/). See
[hooks/antigravity/INVESTIGATION.md](../hooks/antigravity/INVESTIGATION.md)
for the full audit, including the surfaces deliberately not shipped.
+20
View File
@@ -0,0 +1,20 @@
{
"mempalace-save": {
"Stop": [
{
"type": "command",
"command": "__PLUGIN_DIR__/hooks/mempal_save_hook_antigravity.sh",
"timeout": 30
}
]
},
"mempalace-wake": {
"PreInvocation": [
{
"type": "command",
"command": "__PLUGIN_DIR__/hooks/mempal_wake_hook_antigravity.sh",
"timeout": 5
}
]
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"name": "mempalace"
}
@@ -0,0 +1,18 @@
# MemPalace recall
Before answering anything that may already be in the user's memory
palace — past work, prior decisions, a person, a project, or "what did
we do / decide / discuss last time?" — search the palace first:
1. Call `mempalace_search` with a short keyword query. Use
`mempalace_kg_query` for relational or time-bound facts.
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
stored content.
3. If results are empty, say so — do not invent an answer. If the MCP
server is unavailable, surface the error; do not fall back to guessing.
Skip recall for pure greenfield work with no memory relevance (renaming
a variable, fixing a typo). Recall is question-driven, not reflexive.
Full protocol: <https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md>. Deeper guidance:
the `mempalace-recall` skill.
@@ -0,0 +1,119 @@
---
name: mempalace-recall
description: "Recall protocol for MemPalace — search the palace before answering about past work, people, projects, or prior decisions. Apply when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace; or when mempalace-recall is invoked. Complements the mempalace setup skill and requires the mempalace-mcp server."
---
# MemPalace Recall
Search-before-answer protocol for MemPalace. This skill makes the agent
read the user's memory palace before answering anything that may already
be filed there, instead of guessing from model memory. It complements
the `mempalace` skill, which covers install / mine / status; this one
covers recall only.
## Step 0 — Verify MemPalace is available
Before relying on recall, confirm MemPalace is installed and reachable:
- Official release page: <https://github.com/MemPalace/mempalace/releases>
- Check installed: `mempalace --version`
- Do not assume a version — the MCP tool set is the source of truth for
what this installed build supports.
If the `mempalace_*` MCP tools are not available, tell the user the
server is not connected and point them at the `mempalace` skill to set
it up. Do not silently fall back to answering from model memory.
## Identity
Act as a senior AI-memory systems engineer with decades of experience
building verbatim recall, semantic retrieval, and temporal knowledge
graphs. Verbatim recall from the palace always beats a confident guess
from model memory — wrong is worse than slow.
## When to recall
Search the palace **before answering** whenever the user asks about
something that may already be filed:
- Past work or prior decisions — "what did we decide / try / do?"
- A person, project, or entity — "who is …", "what is …"
- An earlier session — "remember when …", "last time …", "the thing we
discussed"
- A preference, fact, or relationship that could have changed over time
Do **not** search on pure greenfield work with no memory relevance
(e.g. "rename this variable", "fix this typo"). Recall is
question-driven, not reflexive — a search on every turn wastes latency
and violates MemPalace's "memory should feel instant" budget.
## Protocol
1. On wake-up, the MemPalace PreInvocation hook injects verbatim palace
content via `injectSteps[].ephemeralMessage` on the first model call
of a conversation. If memory was injected, start from it before
searching further.
2. **Before responding** about people, projects, past events, or prior
decisions: call `mempalace_search` first. For relational or temporal
facts ("who reported to whom in March", "what was true then"), call
`mempalace_kg_query` instead or as well.
3. **If unsure** about a fact (name, age, relationship, preference): say
"let me check the palace" and query. Wrong is worse than slow.
4. **Return verbatim.** Quote the drawer's exact stored words. Never
summarize, paraphrase, or lossy-compress what the palace returns —
that is the whole point of the system.
5. **After a substantive session**, record continuity with
`mempalace_diary_write` (background hooks may already do this — do not
double-file).
6. **When a fact changes**, call `mempalace_kg_invalidate` on the old
fact, then `mempalace_kg_add` for the new one.
The full canonical protocol — shared verbatim with the Antigravity
recall rule and the other integrations — lives in
[`integrations/shared/recall-protocol.md`](https://github.com/MemPalace/mempalace/blob/main/integrations/shared/recall-protocol.md).
## Tool selection
| You need | Tool |
|---|---|
| Find any memory by meaning | `mempalace_search` (start here) |
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
| The chronological story of an entity | `mempalace_kg_timeline` |
| Recent session continuity | `mempalace_diary_read` |
| Which wings / rooms exist (scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
| Record this session | `mempalace_diary_write` |
`mempalace_search` takes a short natural-language `query` (keywords or a
question — not a system prompt or pasted conversation) plus optional
`wing` / `room` filters and `limit` (default 5).
## Unhappy paths
- **Empty results.** Say the palace has nothing on this; do not invent an
answer to fill the gap. Offer to widen the search (drop the wing
filter) or to file the new information.
- **MCP unavailable / tool error.** Surface the error plainly and suggest
the user verify the server (`mempalace status`, or re-run the
installer `hooks/antigravity/install.sh`). Do not silently fall back
to guessing from model memory.
- **Stale or conflicting facts.** Prefer the knowledge graph's
time-valid answer; if a fact has changed, invalidate the old one and
add the new one rather than overwriting context silently.
## Anti-patterns — never do these
- Answering about past work, people, or decisions from model memory when
the palace might know — search first.
- Paraphrasing or summarizing stored content instead of quoting it
verbatim.
- Searching reflexively on every turn, including pure greenfield coding
with no memory relevance.
- Pasting the full conversation or a system prompt into the `query`
argument — keep queries short and keyword-driven.
## Official References
- MemPalace: <https://github.com/MemPalace/mempalace>
- MemPalace releases: <https://github.com/MemPalace/mempalace/releases>
- Antigravity documentation: <https://antigravity.google/docs>
- Agent Skills specification: <https://agentskills.io/specification>
@@ -0,0 +1,92 @@
---
name: mempalace
description: MemPalace — mine projects and conversations into a searchable memory palace. Use when the user asks about MemPalace, memory palace, mining memories, searching memories, palace setup, wings, rooms, or drawers; or when they want to recall past work that may already be filed in their palace.
---
# MemPalace
A searchable memory palace for AI — mine projects and conversations, then search them semantically. Verbatim storage, local-first, zero external API by default.
## Prerequisites
Ensure `mempalace` is installed:
```bash
mempalace --version
```
If not installed (uv recommended):
```bash
uv tool install mempalace # or: pip install mempalace
```
## Dynamic, version-correct instructions
MemPalace exposes operation-specific instructions through the CLI so this skill stays accurate as MemPalace evolves. To get instructions for any operation:
```bash
mempalace instructions <command>
```
Always prefer the CLI output over what is written here when the two disagree — the CLI is the single source of truth for the installed version.
## Common operations
These are the five operations users ask for most often. Each one wraps a single MemPalace CLI subcommand. The `mempalace instructions <name>` form returns the full, version-correct guidance.
### `help` — discover what MemPalace can do
```bash
mempalace instructions help
```
Use when the user is new, unsure what's possible, or asks "what can you do".
### `init` — first-run setup of the palace
```bash
mempalace instructions init
```
Use when the user has just installed MemPalace, no palace exists yet, or the user explicitly asks to set up / configure / re-initialize their palace.
### `mine` — ingest a project or conversation directory
```bash
mempalace instructions mine
```
Use when the user wants to fold a project's files into their palace, or to ingest exported conversation transcripts into the palace as searchable memory.
### `search` — find verbatim memories by semantic query
```bash
mempalace instructions search
```
Use when the user wants to recall something from the past, find a previous decision, or rediscover code/notes/conversations they already wrote.
### `status` — what's in the palace right now
```bash
mempalace instructions status
```
Use when the user asks "what's in my palace", "how big is my palace", or wants a summary of wings, rooms, and drawer counts.
## MCP tools (preferred over CLI)
Inside Antigravity, the MemPalace MCP server registers a rich set of tools. Use these instead of shelling out to the CLI for live operations (search, diary writes, drawer adds, knowledge graph queries, palace status). The MCP tools always reflect the current palace state without spawning a subprocess.
The MCP server is auto-registered when this plugin is installed at `~/.gemini/config/plugins/mempalace/`. If the server does not appear in Antigravity's MCP store, run `mempalace-mcp --version` to verify the binary is on PATH, then restart Antigravity.
## Design principles (verbatim from the project)
- **Verbatim always** — never summarize, paraphrase, or lossy-compress user data.
- **Local-first, zero external API by default** — extraction, embedding, and LLM-assisted refinement happen on the user's machine.
- **Privacy by architecture** — the system never calls out to external services for core operations.
- **Performance budgets** — hooks under 500ms; startup injection under 100ms.
- **Background everything** — filing, indexing, and timestamps happen via hooks in the background; zero tokens spent on bookkeeping in the chat window.
If a request would violate any of these principles, refuse and explain — even if it would be technically convenient.
+4 -3
View File
@@ -1,6 +1,6 @@
# MemPalace Claude Code Plugin
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and 5 guided skills.
A Claude Code plugin that gives your AI a persistent memory system. Mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and 5 guided skills.
## Prerequisites
@@ -41,16 +41,17 @@ After installing the plugin, run the init command to complete setup (installs th
## Hooks
MemPalace registers two hooks that run automatically:
MemPalace registers three hooks that run automatically:
- **Stop** -- Saves conversation context every 15 messages.
- **SessionEnd** -- Runs one final save in the background on a clean exit, so short sessions that never hit the Stop interval or a compaction are still captured.
- **PreCompact** -- Preserves important memories before context compaction.
Set the `MEMPAL_DIR` environment variable to a directory path to automatically run `mempalace mine` on that directory during each save trigger.
## MCP Server
The plugin automatically configures a local MCP server with 19 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
The plugin automatically configures a local MCP server with 36 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything.
## Full Documentation
+11
View File
@@ -12,6 +12,17 @@
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-session-end-hook.sh\"",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
@@ -0,0 +1,43 @@
#!/bin/bash
# MemPalace SessionEnd Hook — thin wrapper calling the Python CLI.
#
# Claude Code documents a default SessionEnd hook timeout of 1.5s, and
# "timeouts set on plugin-provided hooks do not raise the budget"
# (https://code.claude.com/docs/en/hooks). A cold `mempalace` start alone
# exceeds 1.5s, so the final mine must NOT run in the foreground — it would be
# killed before it saved anything. Unlike the foreground Stop/PreCompact plugin
# wrappers, this one backgrounds the hook and returns immediately; the detached
# child finishes the save after the session has exited. All logic lives in
# mempalace.hooks_cli for cross-harness extensibility.
run_mempalace_hook() {
if command -v mempalace >/dev/null 2>&1; then
exec mempalace hook run "$@"
fi
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
fi
if "$MEMPAL_PYTHON_BIN" -c "import mempalace" >/dev/null 2>&1; then
exec "$MEMPAL_PYTHON_BIN" -m mempalace hook run "$@"
fi
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
exec python -m mempalace hook run "$@"
fi
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
exit 1
}
# Capture stdin (the SessionEnd JSON) before backgrounding — the parent's
# stdin is gone once we return. Forward it to the detached worker, which runs
# the final mine on its own time and outlives this process.
payload="$(cat)"
(
printf '%s' "$payload" | run_mempalace_hook --hook session-end --harness "${MEMPALACE_HOOK_HARNESS:-claude-code}"
) >/dev/null 2>&1 </dev/null &
disown 2>/dev/null || true
# Return immediately so the harness never blocks on session exit.
printf '{}'
+2 -2
View File
@@ -8,8 +8,8 @@
{
"name": "mempalace",
"source": "./.claude-plugin",
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
"version": "3.3.6",
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, guided setup.",
"version": "3.7.0",
"author": {
"name": "milla-jovovich"
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "mempalace",
"version": "3.3.6",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
"version": "3.7.0",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
},
@@ -0,0 +1,67 @@
---
name: mempalace-recall
description: Recall protocol for MemPalace — search the palace before answering about past work, prior decisions, people, or projects. Use when the user asks what was decided, what happened before, who someone is, what was discussed last time, or anything that may already be filed in their memory palace.
allowed-tools: Bash
---
# MemPalace Recall
Search-before-answer protocol for MemPalace. Read the user's memory
palace before answering anything that may already be filed there,
instead of guessing from model memory. This complements the `mempalace`
skill (install / mine / status); this one covers recall only.
## Step 0 — Verify MemPalace is available
```bash
mempalace --version
```
If the `mempalace_*` MCP tools are not available, tell the user the
server is not connected and point them at the `mempalace` skill or
`/init`. Do not silently fall back to answering from model memory.
## When to recall
Search the palace **before answering** whenever the user asks about
something that may be filed:
- Past work or prior decisions — "what did we decide / try / do?"
- A person, project, or entity — "who is …", "what is …"
- An earlier session — "remember when …", "last time …"
- A preference, fact, or relationship that could have changed over time
Skip recall for pure greenfield work with no memory relevance (renaming
a variable, fixing a typo). Recall is question-driven, not reflexive.
## Protocol
1. Before responding about people / projects / past events / prior
decisions: call `mempalace_search` first. Use `mempalace_kg_query`
for relational or time-bound facts.
2. If unsure about a fact: say "let me check the palace" and query.
3. Return the drawer's **verbatim** text — never summarize or paraphrase
stored content.
4. After a substantive session, record continuity with
`mempalace_diary_write` (skip if a background hook already saved).
5. When a fact changes: `mempalace_kg_invalidate` the old fact, then
`mempalace_kg_add` the new one.
## Unhappy paths
- **Empty results** — say the palace has nothing on this; do not invent
an answer. Offer to widen the search or file the new information.
- **MCP error / server down** — surface the error, suggest `mempalace
status` or re-running `/init`; never fall back to guessing.
- **Palace index corrupt / compactor error** — if the server reports an
HNSW segment-writer error, a ChromaDB compaction failure, or stays
"Not connected" after a write, the index is out of sync with
`chroma.sqlite3` but the rows are intact. Tell the user to stop the
server and rebuild from SQLite (`mempalace repair --mode from-sqlite
--archive-existing --yes`), not re-mine, which drops MCP-added drawers
and diary entries (#1843). Do not repair in-process.
- **Conflicting facts** — trust the knowledge graph's time-valid answer;
invalidate-then-add rather than overwriting silently.
The canonical protocol, shared across all MemPalace integrations, lives
in `integrations/shared/recall-protocol.md`.
+1 -1
View File
@@ -1,6 +1,6 @@
# MemPalace - Codex CLI Plugin
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.
Give your AI a persistent memory -- mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and guided skills.
## Prerequisites
+4 -9
View File
@@ -1,7 +1,7 @@
{
"name": "mempalace",
"version": "3.3.6",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
"version": "3.7.0",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
},
@@ -18,16 +18,11 @@
"search"
],
"skills": "./skills/",
"hooks": "./hooks.json",
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
},
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "MemPalace",
"shortDescription": "AI memory system for Codex",
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 19 MCP tools, auto-save hooks, and guided skills.",
"longDescription": "Give your AI a persistent memory — mine projects and conversations into a searchable palace backed by ChromaDB, with 36 MCP tools, auto-save hooks, and guided skills.",
"developerName": "milla-jovovich",
"category": "Coding",
"capabilities": [
+144
View File
@@ -0,0 +1,144 @@
# MemPalace Cursor Plugin
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (36 MCP tools), ships 5 slash commands, two model-invocable skills (setup/mining/search and a recall protocol), and an optional recall rule.
> Hooks (auto-save + session-start memory recall) are shipped separately under `hooks/cursor/` so the plugin is safe to install in any Cursor workspace without touching the agent loop. See [Hooks](#hooks-optional) below.
## Prerequisites
- Python 3.9+
- Cursor 1.7+ (plugin manifest schema requires it)
## Installation
### Local clone (recommended while not in the marketplace yet)
Symlink (or copy) this repository into Cursor's local plugins folder:
```bash
ln -s /path/to/mempalace ~/.cursor/plugins/local/mempalace
```
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
### Marketplace
Once published, install via the Cursor marketplace panel and select `mempalace`. Required-plugin distribution from a team marketplace is also supported.
## Post-Install Setup
After installing the plugin, run the `init` command in a Cursor chat:
```
/mempalace-init
```
(Or just say "use the mempalace skill" — Cursor will model-invoke the bundled skill.)
This installs the `mempalace` package via `uv tool` or `pip`, initializes a palace under `~/.mempalace/`, and verifies the MCP server is reachable.
## Available Slash Commands
| Command | Description |
|---------------------|-----------------------------------------------------------------------------------|
| `/mempalace-help` | Show available tools, skills, CLI commands, hooks, and architecture |
| `/mempalace-init` | Set up MemPalace — install, configure, onboard |
| `/mempalace-search` | Search your memories across the palace using semantic search |
| `/mempalace-mine` | Mine projects and conversations into the palace |
| `/mempalace-status` | Show palace overview — wings, rooms, drawer counts |
> Cursor commands are global, not plugin-namespaced — that's why each slug is prefixed with `mempalace-` rather than appearing as `/help`, `/init`, etc. This keeps them collision-free with built-in or other-plugin commands.
## Skills
Two model-invocable skills ship at the plugin root under `skills/`:
| Skill | What it does |
|-------|--------------|
| `mempalace` | Setup, mining, status, and the dynamic `mempalace instructions` CLI. |
| `mempalace-recall` | Search-before-answer protocol — makes the agent read the palace before answering about past work, people, projects, or prior decisions instead of guessing. |
Cursor surfaces these automatically when a request matches their description, or you can attach them explicitly.
## Recall rule (optional)
The plugin also ships a Cursor rule at the plugin root under `rules/mempalace-recall.mdc`:
```yaml
description: When the user asks about past work, prior decisions, people, ... call mempalace_search before answering ...
alwaysApply: false
```
It is `alwaysApply: false` on purpose — Cursor loads it only when its matcher judges the turn recall-relevant, so it never fires on unrelated coding work and never adds MCP latency to greenfield tasks. The rule, the `mempalace-recall` skill, and the `sessionStart` hook all reference the same canonical protocol in [`integrations/shared/recall-protocol.md`](../integrations/shared/recall-protocol.md).
Want recall forced into **every** conversation regardless of context? Copy the aggressive `alwaysApply: true` variant from [`examples/cursor/rules/`](../examples/cursor/rules/README.md) into `~/.cursor/rules/`. That is a deliberate, heavier opt-in, not a default.
## MCP Server
This plugin ships `mcp.json` at the plugin root, so Cursor auto-loads the `mempalace-mcp` server on plugin install:
```json
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
```
All 36 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, …) become available to the agent immediately. No manual `~/.cursor/mcp.json` edit required.
If the server doesn't appear, confirm `mempalace-mcp` is on the user `$PATH`:
```bash
command -v mempalace-mcp
```
If it isn't, run `/init` (or `mempalace install` from a terminal) — `mempalace-mcp` is installed alongside the `mempalace` package.
## Hooks (optional)
Cursor's hooks system is configured separately from plugins (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), so this plugin does **not** wire hooks itself. The MemPalace repository ships three Cursor-native hooks under [`hooks/cursor/`](../hooks/cursor/) that you install with one command.
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace (recommended):
```bash
hooks/cursor/install.sh --scope user --variant full
```
Project scope — writes `.cursor/hooks.json` under the current project only:
```bash
hooks/cursor/install.sh --scope project --variant full
```
What you get:
| Hook event | What it does |
|----------------|-------------------------------------------------------------------------------------------------------|
| `sessionStart` | Injects an `additional_context` recap of relevant memories scoped to the workspace wing |
| `stop` | Counts agent turns; every N turns, emits a `followup_message` instructing a memory checkpoint |
| `preCompact` | Synchronously mines the transcript before compaction, drops a marker so the next `stop` saves a diary |
Full details: [`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md) and [`hooks/cursor/README.md`](../hooks/cursor/README.md).
## Uninstall
Remove the local plugin symlink:
```bash
rm ~/.cursor/plugins/local/mempalace
```
Then in Cursor: <kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
If you also installed the hooks, remove them (leaves any unrelated hooks in `hooks.json` untouched):
```bash
hooks/cursor/install.sh --scope user --uninstall
```
## Full Documentation
See the main [README](../README.md) for complete documentation, architecture details, and advanced usage.
+17
View File
@@ -0,0 +1,17 @@
{
"name": "mempalace",
"owner": {
"name": "milla-jovovich",
"url": "https://github.com/MemPalace"
},
"plugins": [
{
"name": "mempalace",
"source": ".",
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, slash commands, and a guided skill for Cursor.",
"author": {
"name": "milla-jovovich"
}
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "mempalace",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, slash commands, and a guided skill for Cursor.",
"author": {
"name": "milla-jovovich"
},
"homepage": "https://github.com/MemPalace/mempalace",
"repository": "https://github.com/MemPalace/mempalace",
"license": "MIT",
"keywords": [
"memory",
"ai",
"rag",
"mcp",
"chromadb",
"palace",
"search"
]
}
+55
View File
@@ -0,0 +1,55 @@
# Keep the build context lean — only what `uv sync` + the package need.
# VCS
.git/
.gitignore
# Python build artifacts / caches
*.egg-info/
dist/
build/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
htmlcov/
.coverage
coverage.xml
# Virtual environments (rebuilt inside the image)
.venv/
venv/
# Local config / secrets
.env
.env.*
.envrc
mempal.yaml
# Editor / OS cruft
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Repo material not needed at runtime
.devcontainer/
.github/
.agents/
.claude/
.codex/
.codex-plugin/
.claude-plugin/
benchmarks/
docs/
website/
landing/
assets/
examples/
tests/
*.md
!README.md
+21 -6
View File
@@ -13,7 +13,7 @@ jobs:
matrix:
python-version: ["3.9", "3.11", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
@@ -24,18 +24,29 @@ jobs:
test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: 'pip'
- run: pip install -e ".[dev]"
- run: python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing --cov-fail-under=80 --durations=10
# ChromaDB's rust HNSW core intermittently fails compaction or reader
# initialization on Windows ("Failed to apply logs to the hnsw segment
# writer" / "Error creating hnsw segment reader: Nothing found on disk")
# regardless of our code — long-standing, non-reproducible-on-Linux/macOS
# flakes. Retry ONLY those specific transient errors (via --only-rerun) so real,
# deterministic failures still fail on the first run. Linux/macOS jobs
# deliberately run with no reruns so genuine regressions surface there.
- run: >-
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace
--cov-report=term-missing --cov-fail-under=80 --durations=10 --reruns 2
--reruns-delay 5 --only-rerun
"Failed to apply logs to the hnsw segment writer|Error creating hnsw segment reader: Nothing found on disk"
test-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.13"
@@ -45,7 +56,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.11"
@@ -53,6 +64,10 @@ jobs:
# Keep this pin identical to the `ruff==` pin in pyproject.toml
# ([project.optional-dependencies].dev and [dependency-groups].dev)
# so CI and `pip install -e ".[dev]"` format/lint identically.
- run: pip install "ruff==0.15.14"
# This drifted once (CI on 0.15.14 against pyproject's 0.15.20) and the
# drift was invisible: the lint job never installs from pyproject, so a
# dependabot bump to the pin there passes CI without the new version
# ever running. test_ruff_pins_match asserts the two stay equal.
- run: pip install "ruff==0.16.1"
- run: ruff check .
- run: ruff format --check .
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
+136
View File
@@ -0,0 +1,136 @@
name: Docker
on:
push:
# `main` is the release branch: pushes here publish the released image and
# update `latest`; `v*` tags publish versioned images. develop does not
# publish — it is validated via the pull_request trigger below.
branches: [main]
tags: ["v*"]
pull_request:
branches: [main, develop]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Run the image before anyone can publish it. A green `build` only proves the
# Dockerfile compiles — it never started a container, so defects that break
# the first documented command shipped anyway (#2187, #2188). This builds
# amd64 natively, loads it into the local daemon, and exercises the README
# paths: CLI mine + verbatim search across two containers, a real MCP stdio
# handshake, and `compose config` on both shipped Compose files.
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# amd64 only and `load: true`: the smoke run needs a real image in the
# local daemon, and buildx cannot load a multi-arch manifest. Shares the
# `build` job's gha cache scope, so this is mostly a cache hit.
- name: Build image for smoke test
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: false
load: true
tags: mempalace:smoke
# Read the publish job's cache too (its amd64 layers are identical),
# but write to a private scope so an amd64-only export never lands on
# top of that job's multi-arch one. Same split as `scope=gpu` below.
cache-from: |
type=gha
type=gha,scope=smoke
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=smoke' || '' }}
# Reaches Chroma's S3 once to fetch the ~80 MB embedding model, so it is
# network-dependent; that download is itself part of a user's first run.
- name: Smoke test
run: ./scripts/docker-smoke.sh mempalace:smoke
build:
runs-on: ubuntu-latest
# Never publish an image the smoke test has not cleared.
needs: smoke
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
# Needed for the emulated linux/arm64 build on real pushes.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
# Only authenticate + push for in-repo events. Fork PRs lack the
# packages:write token, so they build (to validate the Dockerfile) but
# do not push.
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# latest -> main (the latest release); semver tags -> released versions.
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
# Publish multi-arch (amd64 + arm64 for Apple Silicon / ARM hosts) on
# real pushes; keep PRs amd64-only so the emulated arm64 build does not
# slow the PR check.
platforms: ${{ github.event_name != 'pull_request' && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
# Fork PRs get a read-only Actions cache, so writing it just emits 403
# noise — only export cache on in-repo events.
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max' || '' }}
# Build-only validation for the CUDA image so it cannot silently rot (CUDA base
# tag drift, cross-stage interpreter/venv copy paths, the `gpu` extra). The
# runner has no GPU, so this only proves the image *compiles*; it is never
# published (users build it themselves, per the README).
build-gpu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build GPU image (validation only — not published)
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.gpu
platforms: linux/amd64
push: false
cache-from: type=gha,scope=gpu
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=gpu' || '' }}
+124
View File
@@ -0,0 +1,124 @@
name: Publish to PyPI
# Publishes mempalace to PyPI via Trusted Publishing (OIDC — no stored token).
#
# Triggers:
# * release: published — the normal path (drafting + publishing a GitHub
# Release fires this).
# * workflow_dispatch — a manual escape hatch. GitHub does not reliably emit
# a `release` event when a *draft* tied to a pre-existing tag is published,
# so this lets a maintainer run the publish for any existing tag from the
# Actions tab ("Run workflow" → enter the tag, e.g. v3.4.0).
#
# Build + publish run in ONE job on purpose: a two-job split hands the wheel
# between jobs via upload/download-artifact, which failed repeatedly with
# `BlobNotFound` on the same-run download. Building and publishing in the same
# job removes that handoff entirely. The job is gated by the `pypi` environment
# (manual approval), and the checks below keep it self-contained — it never
# uploads a tag that isn't on main or doesn't match the version manifest.
#
# One-time setup required before the first run (see docs/RELEASING.md):
# 1. PyPI → Manage project `mempalace` → Publishing → Add a trusted publisher:
# Owner: MemPalace Repository: mempalace
# Workflow: publish.yml Environment: pypi
# 2. GitHub → repo Settings → Environments → New environment `pypi`,
# add yourself as a Required reviewer (this is the manual approval gate).
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to build and publish (e.g. v3.4.0). Used when the release event didn't fire."
required: true
type: string
permissions:
contents: read
jobs:
publish:
name: Build + publish to PyPI
runs-on: ubuntu-latest
# The `pypi` environment gate (required reviewer) pauses the whole job until
# approved, and scopes the OIDC trust on the PyPI side.
environment:
name: pypi
url: https://pypi.org/p/mempalace
permissions:
contents: read # checkout
id-token: write # mint the short-lived OIDC token for Trusted Publishing
steps:
- name: Resolve the release tag
id: tag
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ github.event.inputs.tag }}
run: |
set -euo pipefail
tag="${RELEASE_TAG:-${INPUT_TAG:-}}"
if [[ -z "$tag" ]]; then
echo "::error::no tag to publish — neither a release tag nor a workflow_dispatch input was provided"
exit 1
fi
# Validate the shape before it is ever used as a git ref (ref-injection
# guard): require vMAJOR.MINOR.PATCH with an optional -/+ suffix, so
# loose values like 'v3' or 'v3foo' are rejected.
re='^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
if [[ ! "$tag" =~ $re ]]; then
echo "::error::tag '$tag' is not a valid release tag (expected vMAJOR.MINOR.PATCH[-suffix])"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Resolved tag: $tag"
- uses: actions/checkout@v7
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
- name: Verify the tag is on main
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
git fetch --no-tags origin main
tag_sha=$(git rev-parse HEAD) # the checked-out tag commit
main_sha=$(git rev-parse FETCH_HEAD)
if git merge-base --is-ancestor "$tag_sha" "$main_sha"; then
echo "Tag $TAG ($tag_sha) is reachable from main — OK."
else
echo "::error::tag $TAG is not an ancestor of main. Releases must be cut from main."
exit 1
fi
- name: Verify the tag matches the version manifest
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
# version.py is the source of truth (per CLAUDE.md); version-guard.yml
# separately enforces that pyproject + the three plugin manifests agree.
py_version=$(grep -E '^__version__' mempalace/version.py | cut -d'"' -f2)
tag_version="${TAG#v}"
if [[ "$tag_version" != "$py_version" ]]; then
echo "::error::tag $TAG does not match mempalace/version.py ($py_version). Bump the version files before releasing."
exit 1
fi
echo "Tag $TAG matches manifest version $py_version — OK."
- uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Build sdist + wheel
run: |
python -m pip install --upgrade build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
check-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Extract versions from all sources
id: versions
+1
View File
@@ -2,6 +2,7 @@
dist/
build/
__pycache__/
.hypothesis/
*.pyc
.pytest_cache/
mempal.yaml
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+204 -1
View File
@@ -6,6 +6,197 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [Unreleased]
---
## [3.7.0] — 2026-08-11
### Features
- **Embeddings via any OpenAI-compatible `/v1/embeddings` endpoint.** New `embedding_model: "openai-compat"` option computes embeddings on a server (LM Studio, llama.cpp, vLLM, Ollama's OpenAI shim, or a self-hosted endpoint) instead of a local ONNX model — useful for larger/multilingual embedders such as Qwen3-Embedding, or GPU offload. New `OpenAICompatEmbeddingFunction` in [`mempalace/embedding.py`](mempalace/embedding.py) speaks the standard `/v1/embeddings` protocol over stdlib `urllib` (no new dependency), batches requests, re-sorts the response by `index`, and L2-normalizes for the cosine collection. Endpoint settings are resolved by `MempalaceConfig` as a single source of truth — `embedding_api_url` / `embedding_api_model` / `embedding_api_key` in `config.json`, each overridable via the matching `MEMPALACE_EMBEDDING_API_*` env var. The embedding function's `name()` encodes the model id so changing it forces `mempalace repair rebuild-index` (different vector space). Mirrors the existing `openai-compat` LLM provider naming; stays local when the endpoint is on your machine/LAN. (#1559)
- **`mempalace_search` / `mempalace search` — `since`/`before` date window.** Semantic search is poor at temporal queries ("what did we discuss this week?" scores ~0.35 even when matching drawers exist), so the search surfaces now accept the same `[since, before)` window `list_drawers` gained in #1128: inclusive/exclusive ISO bounds compared wall-clock against each drawer's `filed_at`, undated drawers excluded while a bound is active. The window applies on every candidate path (vector, `candidate_strategy="union"`, and the BM25-only fallback); the vector candidate pool widens under an active window (ChromaDB cannot range-compare string metadata server-side), and a full pool is flagged via `date_filter_pool_truncated` instead of passing silently. Shared parsing lives in the new `mempalace.date_window` module, also backing the `list_drawers` filter. (#463)
- **Hermes agent memory provider (core).** In-package Hermes `MemoryProvider` files live turns through the shared `file_conversation_exchange()` path (same metadata as convo mining), with a background worker so the agent loop never blocks on Chroma. Wake-up L1 uses the long-lived collection only — a second PersistentClient is not opened, avoiding local SQLite races. Install/backfill/docs remain a stacked follow-up. (#1915, #2215)
- **Explicit RFC 002 source adapters on `mempalace mine`.** `mempalace mine <source> --source <adapter>` resolves adapters through the registry, holds the palace writer lease for the full ingest, and keeps dry-runs inert (no real backend/KG open). Legacy `--mode` paths are unchanged. (#2068, #2062)
- **MCP refuses writes when the served library is no longer the one that started.** Detects mempalace (and chromadb when that backend is active) version drift or uninstall after upgrade without restarting the server; opt-out via `MEMPALACE_MCP_ALLOW_STALE_LIBRARY`. (#2081, #899)
- **Hook write-routing through the daemon.** Background hook saves and mines can honor the shared write-routing policy so multi-session setups serialize mutations through one local owner instead of racing the palace. (#2030, #1963)
### Performance
- **EmbeddingGemma groups documents by size before sub-batching.** The tokenizer pads every row of a sub-batch to the longest sequence in it, so arrival order decided the bill: one long verbatim message dragged a whole sub-batch up to its own length. Measured over 43,157 `sweep` drawers from 160 Claude Code transcripts, padded token slots drop 39.7% and the quadratic attention term 45.0%. Vectors move by at most one float32 ULP (1.2e-07 absolute, cosine 0.99999992), which is reduction-order rounding and not a change of meaning. Applies to `embedding_model: embeddinggemma` only; the default MiniLM embedder pads to a fixed width and was never affected. (#2104)
- **HNSW capacity probes are cached** and invalidated by palace file signature, so repeated MCP status/taxonomy paths no longer re-scan native segment files on every call. (#2051, #1471)
- **`chunk_text` line numbering is O(N)** via incremental tallies, fixing multi-second hangs on large sources. (#2054, #2055)
### Bug Fixes
- **Orphaned per-source-file mine locks are reaped instead of accumulating forever.** `_cleanup_mine_lock_file` reclaims a lock correctly on the happy path, but only for the specific lock its own `mine_lock` context manager just released — a process killed abruptly (SIGKILL, force-quit, host crash) never reaches that cleanup, and nothing else revisited the file afterward. One long-lived installation was found with 5,636 stale entries in `~/.mempalace/locks/`, the oldest several months old, none held by any live process. `mine_lock` now opportunistically reaps locks older than an hour via the same nonblocking-flock-reacquire safety check `_cleanup_mine_lock_file` already uses, throttled to once per 15 minutes so it costs nothing on the common path. `mine_palace_*.lock` (the newer per-palace lock) is untouched — it has its own lifecycle and holder tracking.
- **Windows MCP stdout capture falls back cleanly** when fd-level redirection is unavailable, and fails closed if protocol stdout cannot be restored after a successful redirect. (#2211, #2210)
- **Claude Code `subagents/` transcripts are skipped by default** when mining conversations (98%+ noise on typical workspaces); `--include-subagents` opts back in. (#1330, #1217)
- **Worktree transcript cwd no longer mints a throwaway wing** — wing is derived from the project root above `.claude/worktrees/`. (#2206)
- **Markdown emphasis/bold no longer scores as emotional content** in the general extractor. (#2199, #2197)
- **Encoding hardening on Windows locales:** pin `encoding=utf-8` on config/dialect opens; safer mojibake repair that does not destroy clean Portuguese/Vietnamese/Turkish prose; repair-encoding CLI reconfigures stdio like other entry points; remaining non-ASCII CLI symbols replaced for GBK consoles. (#2098, #2208, #2194, #1104, #1034, #2193)
- **`rebuild_index` holds the palace writer lease** for the full snapshot→rebuild/swap cycle so concurrent writers cannot recreate HNSW divergence mid-repair. (#2195)
- **`MEMPALACE_PALACE_PATH` is restored after each service entrypoint** so multi-call processes do not leak the first call's palace into the second. (#2192)
- **systemd unit uses `Restart=always`** and documents `MEMPALACE_MCP_IDLE_HOURS=0` so the idle watchdog does not leave a dedicated server down after a clean exit. (#2203, #2204)
- **Mining works again on the default Chroma backend.** Once Chroma began declaring `requires_explicit_embeddings`, every write started routing through `EmbeddingCollection`, whose `_embed_texts` built rows with `list(ndarray)` — that unpacks into `np.float32` *scalars*, which chromadb rejects outright (`Expected embeddings to be a list of floats or ints, a list of lists, a numpy array, or a list of numpy arrays`). `mine`, and every other write against a default palace, aborted on the first drawer. Vectors now convert to real Python floats. The suite was structurally blind to this: conftest's autouse embedding fixture replaces `_embed_texts` itself for every module outside `test_embedding` / `test_embeddinggemma`, so the defective function was never executed under test — the regression tests therefore live in `test_embedding.py`, where that stub does not apply. (#2187)
- **ChatGPT data exports are parsed instead of stored as raw JSON.** A real `conversations.json` is a top-level array of conversations, which no parser claimed, so `mine --mode convos` chunked the raw JSON and lost every speaker turn while reporting success. Each conversation now normalizes to its own transcript, as Claude.ai privacy exports already do, so per-conversation dedup survives a re-export. The ChatGPT parser also type-checks its nested shapes, so an unrelated array carrying a `mapping` key is declined instead of raising. (#2160)
- **Local backends enforce process-lifetime single-writer ownership.** File-backed and unknown backends require one writer owner for the full process lifetime (daemon holds the lease until workers exit; writable MCP HTTP acquires ownership before bind and refuses startup when blocked). Read-only MCP may coexist; `sqlite_exact` opens genuine query-only/immutable readers; remote Milvus/Zilliz remain multi-process. Addresses multi-writer SQLite/WAL corruption from MCP HTTP + daemon + mine topologies. (#2079, #2045)
- **Chroma HNSW write defaults match chromadb** (`batch_size=100` / `sync_threshold=1000`) instead of the old 2/2 bloat guard that rewrote segments thousands of times on large mines. (#2107, #2106)
- **Repair and recovery are safer under contention.** `repair --mode from-sqlite` takes the mine-lock before archiving; rebuilds preserve a verified temp collection when the live swap fails; sparse drawers with zero `embedding_metadata` rows are no longer dropped; truncated ID pagination fails loud instead of pretending success. (#2109, #2086, #2087)
- **`repair --mode from-sqlite --dry-run` is a true preview.** It no longer archives or re-embeds; it prints per-collection would-be counts from SQLite ground truth and exits without touching the palace. Unreadable counts fail closed instead of inventing zeros. (#2133, #2095, #1654)
- **`repair --dry-run` is a true preview in the default (legacy) mode too.** That path ignored the flag entirely and ran the real rebuild — deleting any existing `<palace>.backup`, copying the palace over it, and re-filing the drawers collection. It now prints a read-only plan and exits without opening a chromadb client, which is itself a write to `chroma.sqlite3`. The plan names the live-collection delete the rebuild performs, warns when an existing backup would be destroyed, and reports the truncation guard as disabled when `--confirm-truncation-ok` is set. An isolated FTS5 inverted-index error is reported as auto-healable instead of raising the manual-recovery abort a real run never reaches, unreadable counts fail closed with a non-zero exit, and the `--dry-run` help no longer claims to be `--mode max-seq-id` only. (#2144)
- **`repair` and `migrate` survive a socket or a named pipe in the palace directory.** Both take a whole-directory `shutil.copytree` backup before they overwrite the palace, and `copytree` cannot duplicate a Unix domain socket left beside `chroma.sqlite3`, nor a named pipe: it copied everything else and then raised `shutil.Error`, so the command died at the backup step before any rebuild ran, and re-running only repeated it. Neither entry carries palace data, so both are now skipped and named in the output while the backup completes. Device nodes are skipped too, because the copy dereferences them instead of failing on them. Every other copy failure still aborts the command, including a symlink whose target cannot be read: no errno separates a deleted target from one on a volume that is not mounted, so an entry that may have held data is left for the copy to fail on. (#2207)
- **HNSW divergence is preflighted before remaining `col.count()` crash sites** across mine, dedup, migrate, repair, and palace helpers. (#2093)
- **Re-mine and conversation ingest no longer lose or duplicate drawers.** Content-hash dedup prevents duplicate LLM conversation drawers; sweeper drawers are excluded from convo extract-mode purge scope and failed purges abort; search returns round-trippable `drawer_id` values for `get_drawer`. (#2050, #2125, #2089, #2090, #2044, #2080)
- **MCP and daemon lifecycle harden multi-agent use.** Read-only mode refuses config and checkpoint-ack tools that rewrite host state; stdio MCP exits on stdin EOF/broken pipe so orphaned sessions release locks; daemon jobs refused the palace lock are deferred instead of failed permanently. (#2126, #2103, #2101, #2072, #2029, #2014)
- **Entity-candidate extraction no longer hangs on long ASCII runs** (base64, minified blobs) while preserving CJK/non-ASCII text. (#2127, #2065, #2063)
- **Mining windowing rejects `chunk_overlap` above half the chunk size**, stopping infinite chunk_text loops. (#2056, #2058)
- **`docker-compose.yml` is valid again.** The `environment:` key was declared with only comments beneath it, which YAML parses as null, so Compose rejected the whole file (`services.mcp.environment must be a mapping`) — every documented Compose command failed before starting. The key is commented out along with its examples, which now use mapping syntax so uncommenting them yields a valid block. (#2188)
### Documentation
- Operator write-routing / single-writer recovery notes in `docs/write-routing-policy.md`. (#2079)
- Remote-server guide wording for read-only tools that change host state. (#2126)
- **The README's Docker section leads with the published image.** It previously documented only `docker build`, even though `ghcr.io/mempalace/mempalace` ships multi-arch — and a clone builds `develop`, not the release, so a build and a pull could differ silently. It now covers what the omissions actually cost: the MCP client config mounts a transcripts directory (without one the server starts and every mine finds nothing), the first embedding call downloads ~80 MB into `/data` and looks like a hang, bind mounts keep host ownership against the image's uid 1000 so a `0700` directory fails with a bare `PermissionError` on Linux (and `--user` is the wrong fix — `/data` is mode 700 owned by that uid), mining sources can be mounted read-only, and the GPU image is x86_64-only. (#2196)
### Internal
- **The Docker workflow runs the image before publishing it.** It previously built both images without ever starting a container or parsing a Compose file, so a green run only proved the Dockerfile compiled — which is how two defects that break the first documented command shipped past it. `scripts/docker-smoke.sh` now validates both Compose files, checks entrypoint dispatch, mines a mounted directory, asserts the drawer reads back verbatim from a separate container, and drives a real MCP stdio handshake; publication is gated on it. The script runs the same way locally: `scripts/docker-smoke.sh <image>`. (#2189)
---
## [3.6.0] — 2026-07-14
### Features
- **Turnkey secure remote / team server.** `mempalace serve` exposes the full MCP surface over HTTP with secure defaults: non-loopback binds require a bearer token, generated tokens are stored with restrictive permissions and passed through the child environment rather than argv, native TLS 1.2+ is supported, and `--read-only` both hides and refuses mutating tools. Docker Compose, systemd, and environment templates are included for deployment. (#1877, #1897, #1900)
- **Milvus storage backend.** The new opt-in `milvus` backend supports embedded Milvus Lite, self-hosted Milvus, and Zilliz Cloud through `pymilvus`, with COSINE vector search, native BM25 lexical search for new collections, namespace isolation, target-mismatch protection, and an optional `milvus` dependency extra. (#1899)
- **Atomic knowledge-graph fact replacement.** `supersede()` / `mempalace_kg_supersede` closes an open fact and opens its successor at one shared instant, so model, employer, address, and other single-valued facts can change without a hand-written invalidate/add race. Temporal queries now use half-open intervals at timestamp precision, returning only the successor at the transition boundary while preserving whole-day semantics for date-only facts. (#1913)
- **Conversation chronology is preserved.** Conversation drawers now retain transcript time as `authored_at` alongside ingest time, search results expose it, and exact hybrid-score ties prefer the more recently authored drawer. An idempotent, dry-run-first backfill script adds the metadata to existing palaces without re-embedding. `mempalace_list_drawers` also accepts inclusive `since` and exclusive `before` filing-time bounds. (#1890, #1891)
- **Mined sessions populate the associative graph.** A precision-biased, no-LLM structural extractor records code symbols, URLs, paths, and qualified identifiers as drawer entities; conversation mining then derives hallways from them. The new `mempalace hallways` CLI command exposes the graph. (#1894, #1895)
- **Per-project mining exclusions.** `exclude_patterns` in `mempalace.yaml` filters pre-scanned project files without changing the corrected `--limit` semantics. (#1213, #1953)
- **LaTeX project coverage.** `.tex` and `.bib` files are now treated as readable prose sources. (#1901)
### Performance
- **MCP initialization no longer waits for palace-wide integrity work.** stdio answers the JSON-RPC `initialize` request immediately while startup preflight runs on a background thread; a tool arriving mid-probe joins the same verdict instead of launching duplicate work. The startup SQLite probe is also skipped above a configurable size limit (512 MiB by default), while repair preflights remain strict. (#1911, #1987)
- **Qdrant metadata counts use server-side facets.** Taxonomy, overview, and status paths can count metadata remotely instead of fetching and tallying every record; the embedding wrapper now forwards the facet and bulk-metadata capabilities correctly. (#1868, #1898)
- **pgvector metadata-only fetches omit document payloads**, reducing transfer and decoding work on enumeration paths. (#1892)
### Bug Fixes
- **Chroma recovery distinguishes derived-index damage from data loss.** Valid all-layer-0 HNSW segments with an empty `link_lists.bin` are no longer quarantined repeatedly; isolated FTS5 inverted-index corruption is rebuilt from intact content during repair and after mining; embedded NUL bytes are sanitized before reaching ChromaDB. (#1716, #1872, #1878, #1927, #1928)
- **SQLite recovery is bounded, atomic, and honest.** Integrity checks wait up to 15 seconds for transient writer contention instead of reporting a healthy palace as corrupt. In-place recovery archives use atomic `os.rename` rather than `shutil.move`'s destructive copy/delete fallback. `repair --mode from-sqlite` now rebuilds FTS5, vacuums, and requires a clean final `PRAGMA quick_check`; cleanup failures return non-zero with recovery details instead of printing a false success banner. (#1945, #2015, #2017)
- **CLI search checks HNSW divergence before opening ChromaDB.** A diverged palace routes directly to the existing SQLite BM25 fallback, avoiding embedder or collection initialization against damaged native index state while leaving healthy Chroma and non-Chroma backends on their normal vector path. (#2016)
- **Writer leases recover instead of stranding servers read-only.** A server refused while a peer owns the palace now retries on each mutating call and promotes itself after the peer exits. Status remains read-only and cannot acquire the writer lease, the in-process re-entrant holder set is released even across asynchronous interruption, and `checkpoint` / `delete_by_source` are correctly classified as mutations for both read-only serving and peer-writer protection. (#1923, #1930, #1934, #1960, #1970, #1971)
- **Status reports only checks that actually ran.** Non-Chroma backends now mark the Chroma SQLite integrity check as not applicable rather than claiming an unperformed success. (#1931, #1946)
- **Conversation transcripts are treated as mutable.** Appended or rewritten sessions are purged and re-filed based on modification time, preventing later turns from being silently skipped. Claude Code `tool-results/` sidecars are excluded from conversation scans so raw machine dumps cannot flood the embedding space. (#1957, #2010)
- **Explicit palace selection now scopes derived graph state.** Project, conversation, and format mining write hallways and tunnels beside the selected `--palace` instead of leaking them into the ambient default palace. (#2018)
- **Remote and local server hardening.** Non-loopback HTTP now requires a token unless an explicit insecure override is supplied; optional-provider probes cannot reuse an ambient external key before the user selects that provider; hook transcript paths and file-copy/repair paths receive stricter local validation and no-follow handling. Importing the MCP server no longer clobbers the host application's root logger, HTTP writes can re-enter the process-wide palace lock safely, and routine client disconnects no longer emit server tracebacks. (#1859, #1860, #1864, #1885, #2003, #2004)
- **Mining and configuration correctness.** Oversized files produce a visible stderr warning, `~` in configured palace paths expands consistently, wing slugs handle special characters, and Windows background daemon / synchronous hook mines use `CREATE_NO_WINDOW`. (#923, #1852, #1857, #1863, #1865)
- **`mempalace init` handles non-ASCII `.gitignore` files on Windows.** The project-file ignore guard now reads and appends UTF-8 explicitly instead of relying on locale defaults such as GBK. (#1648)
- **L1 wake-up surfaces the latest moments.** Drawers with equal importance are now ordered by `filed_at` recency rather than insertion order, so startup context prefers recent memories instead of the oldest ones. (#1630)
- **Backend detection requires a SQLite magic header** before classifying a target as Chroma or `sqlite_exact`, preventing unrelated files from being mistaken for a palace. (#1893, #1896)
### Documentation
- Added a storage-backend configuration reference, a remote/team-server deployment guide, `authored_at` migration guidance, and refreshed the OpenClaw integration for the full 36-tool MCP surface. (#1719, #1877, #1890, #1904, #1905)
### Internal
- Expanded WAL crash-safety, idempotence, and redaction-path coverage (#1869); updated GitHub Actions and Ruff; repaired `uv.lock` drift so it again matches the Ruff 0.15.20 pin and includes Python 3.9 dependency markers introduced by the Milvus resolution.
---
## [3.5.0] — 2026-06-22
### Features
- **Opt-in local daemon for queued writes.** A new `mempalace daemon` queues MemPalace writes through a single local process so background mines, diary saves, and hook-driven ingests serialize against one palace handle instead of racing for it. Opt-in and local-only — nothing binds to a public interface. (#1826)
- **Opt-in HTTP transport for the MCP server.** `mempalace-mcp --transport http` serves JSON-RPC at `POST /mcp` (with a `GET /healthz` liveness probe) for operators running MemPalace behind a long-lived HTTP MCP client/proxy, avoiding the long-lived-stdio framing failures of #1801. stdio remains the default and is unchanged. The transport reuses the exact stdio request dispatcher (no separate write/search path), binds `127.0.0.1` by default, and is hardened against the two ways a local HTTP server leaks to the network: it pins the `Host` header to loopback on a loopback bind and rejects any non-loopback browser `Origin` (DNS-rebinding/SSRF guard), and supports an optional bearer token via `MEMPALACE_MCP_HTTP_TOKEN` (required on `/mcp`, never on `/healthz`). A 16 MiB request cap and a loud warning when bound to a non-loopback host round it out. (#1801, #1806)
- **`mempalace_checkpoint` batch-save MCP tool.** Collapses multiple `add_drawer` calls plus an optional diary entry into a single MCP round-trip for agents that want to file a whole session at once. Stores content verbatim and reuses the existing idempotent add/dedup path. (#1851)
- **`mempalace_delete_by_source` bulk-cleanup MCP tool.** Exact-match, dry-run-by-default deletion of every drawer (and its matching closet/AAAK index entries) for a given `source_file` — the recourse for benchmark/eval files mined into the same wing as real data and drowning out search. The dry run reports the drawer and closet blast radius before anything is removed, and the commit writes a WAL audit entry. (#1722, #1729)
- **Optional `source_file` filter for `mempalace_search`.** Scope a search to an exact stored source path. The filter is threaded through every search path (vector, BM25/SQLite fallback, lexical union, and index-mismatch fallback) so it never silently drops a matching drawer, and results now expose the full `source_path` as a round-trippable key. (#1815, #1817)
- **New transcript parsers / importers.** Continue.dev session parser (#731), Gemini CLI / AI Studio JSON session import (#204), and a Pi agent JSONL session normalizer (#169).
- **Wider miner language coverage.** C# / .NET, PHP (#1819), Swift / Kotlin (#1368), and Java project detection including rootless subprojects (#1720).
- **Final mine on Claude plugin `SessionEnd`.** The Claude Code plugin now runs a closing mine when a session ends so the last exchanges are captured without waiting for the next save nudge. (#1814, #1820)
### Performance
- **Overview/status MCP tools answer from the SQLite aggregate.** Large palaces no longer time out building wing/room/status overviews — the counts come from a single SQLite aggregate instead of a client-side fetch-and-tally. (#1748, #1379)
- **`graph_stats` SQLite fast path.** Knowledge-graph stats are computed in SQLite rather than walking the collection, fixing large-palace timeouts. (#1379)
- **Embedder caps ONNX-runtime intra-op threads** so a background mine no longer pins every core. (#1068)
- **Backend pagination pushed into the query.** `sqlite_exact` (#1841, #1842) and `pgvector` (#1830, #1840) now apply `get(limit, offset)` in SQL, and Qdrant fetches bulk metadata in a single scroll with a larger page size (#1796, #1832).
### Bug Fixes
- **pgvector tolerates hostile transcript bytes.** A lone Unicode surrogate (#1833) or a NUL byte (#1829) in a transcript no longer aborts the whole mine — both are sanitized before the row is written.
- **SQLite read-only URIs are percent-encoded** so palace paths with spaces or special characters open correctly, and `_sqlite_graph_stats` is routed through the same `sqlite_read_uri` helper.
- **Stale ChromaDB HNSW divergence routes to the SQLite fallback** instead of failing the read outright. (#1816, #1822)
- **Diverged-index recovery now points at `repair --mode from-sqlite`, not a re-mine.** A failed ChromaDB HNSW compaction leaves the index out of sync while the rows stay intact in `chroma.sqlite3`; the old "re-mine from source" advice silently dropped MCP-added drawers and diary entries (which have no source file). Both the legacy `repair`/`rebuild_index` error messages and the `repair-status` recommendation, plus the recall skill docs, now guide users to rebuild from SQLite. (#1843, #1847, #1849)
- **The MCP server refuses a second writer for the same palace** rather than letting two processes race the same HNSW handle. (#1818, #1823)
- **Windows hook miner spawns with `CREATE_NO_WINDOW`** so background mines no longer flash a console window. (#1783, #1848)
- **`fact_checker` `__main__` no longer emits a runpy warning** under the test runner. (#1798)
### Internal
- Live-substrate conformance test module for pgvector (#1769); dependabot bumps for `docker/login-action` (3→4), `docker/build-push-action` (6→7), and `docker/metadata-action` (5→6) (#1788, #1787, #1786); ruff dev dependency bumped to 0.15.18.
---
## [3.4.1] — 2026-06-14
### Features
- **Cursor IDE plugin (`.cursor-plugin/`).** Drops into `~/.cursor/plugins/local/mempalace` (or installs from the Cursor marketplace once published) and auto-registers the `mempalace-mcp` server, five slash commands (`/mempalace-help`, `/mempalace-init`, `/mempalace-mine`, `/mempalace-search`, `/mempalace-status`), and the model-invocable [`mempalace` skill](skills/mempalace/SKILL.md) — no manual `~/.cursor/mcp.json` edit required. The plugin manifest deliberately omits a hardcoded `version` field — `mempalace/version.py` is the single source of truth, so there is nothing to drift on the next release (a contract test enforces the field stays absent). The canonical plugin components (`commands/`, `skills/`, `mcp.json`) are real files at the plugin root; no symlinks are committed (committed symlinks materialise as broken text files on Windows clones with `core.symlinks=false`). Mirrors the surface of [`.claude-plugin/`](.claude-plugin/) and [`.codex-plugin/`](.codex-plugin/) without duplicating their hook scripts: the Cursor hook scripts under [`hooks/cursor/`](hooks/cursor/) (shipped in the same release) remain the canonical install path for `stop` / `preCompact` / `sessionStart`, wired separately by [`hooks/cursor/install.sh`](hooks/cursor/install.sh). Contract tests in [`tests/test_cursor_plugin_manifest.py`](tests/test_cursor_plugin_manifest.py) cover manifest JSON validity, kebab-case naming, `..`-free relative paths, on-disk path resolution, marketplace alignment, MCP config shape (`mcpServers` wrapper required by Cursor, unlike Claude's flat `.mcp.json`), the version-field-absent guard, the no-symlink guard, and every skill/command frontmatter — all pure file inspection so they run on any CI platform without Cursor itself.
- **Cursor IDE hook support (`stop` / `preCompact` / `sessionStart`).** Three new bash hooks live under [`hooks/cursor/`](hooks/cursor/) and share a `lib/common.sh` helpers module. The save hook counts `stop` invocations per Cursor `conversation_id` and emits a `followup_message` every `MEMPAL_SAVE_INTERVAL` (default 15) so the agent files the session into MemPalace and writes a diary entry. Unlike the silent-by-default Claude Code hook, the Cursor followup fires **on by default**: Cursor's transcript format is undocumented and `normalize.py` has no Cursor parser yet, so the background `mempalace mine --mode convos` is best-effort only and the `followup_message` is the load-bearing verbatim-capture path. Users who want the Claude-style "zero tokens in the chat window" behaviour can suppress it with `MEMPAL_CURSOR_SILENT=1` (or `MEMPAL_VERBOSE=false`); the default flips to silent once a Cursor transcript parser lands. The precompact hook synchronously mines the transcript before Cursor's compaction summarises it and drops a marker so the next `stop` forces a save nudge (Cursor's `preCompact` is observational-only — it cannot block or emit a `followup_message`, unlike Claude Code's `PreCompact`); the synchronous mine is bounded by Cursor's per-hook timeout, and because `mempalace mine` is incremental/append-only a killed mine resumes cleanly on the next run rather than corrupting the palace. The wake hook is Cursor-only: `sessionStart` returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Honours the same `MEMPALACE_HOOKS_AUTO_SAVE=false` kill switch as the Claude Code hooks, plus a new `MEMPAL_DISABLE_HOOK=1` alias and a `MEMPAL_STATE_DIR` env override. Per-conversation state files are garbage-collected by a daily-throttled, Cursor-namespaced TTL sweep (`MEMPAL_STATE_TTL_DAYS`, default 30) so `cursor_*.count` / `cursor_*.pending` cannot grow unbounded — shared logs and other editors' state are never touched. Includes an opt-in installer at [`hooks/cursor/install.sh`](hooks/cursor/install.sh) with `--scope user|project`, `--variant full|minimal`, `--dry-run`, and `--uninstall` (idempotent, preserves unrelated hooks via `python3`-based JSON merge — no `jq` dependency). Example wirings live at [`examples/cursor/hooks.json`](examples/cursor/hooks.json) and [`examples/cursor/hooks.minimal.json`](examples/cursor/hooks.minimal.json); they are intentionally not placed at the repo root because Cursor auto-loads project hooks from any trusted workspace and we do not arm hooks on contributor checkout. Per-event stdin/stdout schema documented at [`hooks/cursor/STDIN_SHAPE.md`](hooks/cursor/STDIN_SHAPE.md). Walkthrough at [`website/guide/cursor-hooks.md`](website/guide/cursor-hooks.md). Coverage added in [`tests/test_cursor_hooks_shell.py`](tests/test_cursor_hooks_shell.py) and [`tests/test_cursor_hooks_install.py`](tests/test_cursor_hooks_install.py).
- **First-class Antigravity IDE support.** New `.antigravity-plugin/` package + idempotent installer at `hooks/antigravity/install.sh` that registers MemPalace as a Google Antigravity plugin (MCP server, skill, two lifecycle hooks) at `~/.gemini/config/plugins/mempalace/`. The Stop hook background-mines the active conversation transcript every Nth fire (default 15, configurable via `MEMPAL_SAVE_INTERVAL`); the PreInvocation hook injects verbatim memory on the first model call only via Antigravity's `injectSteps[].ephemeralMessage` output, gated by `invocationNum == 1`. Both hooks are bash 3.2.57 compatible (macOS default), use the same `~/.mempalace/hook_state/` directory as the Claude Code / Codex / Cursor hooks (`antigravity_*`-namespaced state files), and respect every existing kill switch (`MEMPAL_DISABLE_HOOK`, `MEMPALACE_HOOKS_AUTO_SAVE`, `~/.mempalace/config.json` `hooks.auto_save`). Installer is `cmp`-gated (re-run produces a byte-identical install), uninstall is basename-guarded (refuses to wipe a directory whose basename isn't `mempalace`), and `--dry-run` is side-effect free. Full audit of which Antigravity surfaces we ship and which we deliberately don't is in [`hooks/antigravity/INVESTIGATION.md`](hooks/antigravity/INVESTIGATION.md). User-facing guide: [`website/guide/antigravity.md`](website/guide/antigravity.md). Standalone examples in [`examples/antigravity/`](examples/antigravity/).
- **Zero-config interpreter resolution.** `mempal_resolve_python` now derives the Python interpreter from the `mempalace-mcp` / `mempalace` console-script shebang on `$PATH` before falling back to `python3`. The common `uv tool install mempalace` / `pipx install` layout installs the console scripts into an isolated environment whose interpreter is **not** system `python3`, so the previous `command -v python3` resolution landed on a Python that couldn't import `mempalace`, the `-m mempalace` probe failed, and mining silently never fired. Resolution is pure shebang parsing + `stat` (no Python subprocess at source time, preserving the hook performance budget). `MEMPAL_PYTHON` remains the explicit override. Documented under *How the hooks find your `mempalace` install* in the guide.
### Bug Fixes
- **`embeddinggemma` no longer OOM-kills bulk re-embeds.** `EmbeddinggemmaONNX.__call__` ran a single `session.run` over its entire input, so a repair-scale batch (5000 docs) allocated attention buffers far beyond available RAM and the kernel killed the process silently: `mempalace repair --yes` on an `embedding_model: embeddinggemma` palace died right after `Building temporary collection:` with no traceback and no crash report (#1770). Embedding now runs in sub-batches of 32 docs (constructor-tunable `batch_size`), matching the internal batching of ChromaDB's bundled MiniLM embedder. Per-document vectors are unchanged: the model's pooled output is attention-masked, so sub-batch padding does not affect values. The embedder is also hardened for shared use: the process-wide EF cache and the lazy model load are thread-safe (concurrent first calls build exactly one ONNX session), and `__call__` handles a bare string, `None`, and empty input without triggering the model download.
- **Backup retention to prevent unbounded disk usage.** `mempalace migrate` (full-palace `<palace>.pre-migrate.<timestamp>` copies) and `mempalace repair max-seq-id` (`chroma.sqlite3.max-seq-id-backup-<timestamp>` copies) each wrote a fresh, full-size, timestamped backup every run and never deleted the old ones. On a machine that mines or repairs on a schedule, those copies could silently accumulate until they filled the disk — one palace was found with hundreds of GB of stale backups beside a few hundred MB of live data, hidden from a normal `du` of the home directory. A new `max_backups` setting (default `10`, env `MEMPALACE_MAX_BACKUPS`, or `config.json`) now prunes the oldest backups after each new one is written. Set it to `0` to keep every backup. Pruning is keyed by filesystem mtime, scoped strictly to each backup's own naming pattern (live data is never touched), and best-effort so a deletion failure can never abort a migration or repair that already succeeded.
---
## [3.3.6] — 2026-05-24
### Features
@@ -494,7 +685,19 @@ Initial public release.
---
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.7.0...HEAD
[3.7.0]: https://github.com/MemPalace/mempalace/compare/v3.6.0...v3.7.0
[3.6.0]: https://github.com/MemPalace/mempalace/compare/v3.5.0...v3.6.0
[3.5.0]: https://github.com/MemPalace/mempalace/compare/v3.4.1...v3.5.0
[3.4.1]: https://github.com/MemPalace/mempalace/compare/v3.4.0...v3.4.1
[3.4.0]: https://github.com/MemPalace/mempalace/compare/v3.3.6...v3.4.0
[3.3.6]: https://github.com/MemPalace/mempalace/compare/v3.3.5...v3.3.6
[3.3.5]: https://github.com/MemPalace/mempalace/compare/v3.3.4...v3.3.5
[3.3.4]: https://github.com/MemPalace/mempalace/compare/v3.3.3...v3.3.4
[3.3.3]: https://github.com/MemPalace/mempalace/compare/v3.3.2...v3.3.3
[3.3.2]: https://github.com/MemPalace/mempalace/compare/v3.3.1...v3.3.2
[3.3.1]: https://github.com/MemPalace/mempalace/compare/v3.3.0...v3.3.1
[3.3.0]: https://github.com/MemPalace/mempalace/compare/v3.2.0...v3.3.0
[3.2.0]: https://github.com/MemPalace/mempalace/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/MemPalace/mempalace/compare/v3.0.0...v3.1.0
[3.0.0]: https://github.com/MemPalace/mempalace/releases/tag/v3.0.0
+1 -1
View File
@@ -119,7 +119,7 @@ If you're planning a significant change, open an issue first to discuss the appr
- **Discord**: [Join us](https://discord.com/invite/ycTQQCu6kn)
- **Issues**: Bug reports and feature requests welcome
- **Discussions**: For questions and ideas
- **Questions and ideas**: Please use Discord or open an issue while GitHub Discussions are not enabled for this repo
## License
+102
View File
@@ -0,0 +1,102 @@
# syntax=docker/dockerfile:1.7
# MemPalace — CPU image.
#
# Multi-stage build using uv (the project ships a uv.lock, so we install from
# the frozen lockfile for reproducible images). The default runtime is the MCP
# server over stdio; the CLI is reachable through the same entrypoint.
#
# Build:
# docker build -t mempalace .
# docker build -t mempalace --build-arg EXTRAS="extract,spellcheck" .
#
# Run (MCP server over stdio, palace persisted on the host):
# docker run -i --rm -v mempalace-data:/data mempalace
#
# Run (CLI):
# docker run --rm -v mempalace-data:/data mempalace search "why GraphQL"
#
# GPU acceleration lives in Dockerfile.gpu (it needs a CUDA base image).
ARG PYTHON_VERSION=3.12
# --- builder ----------------------------------------------------------------
FROM python:${PYTHON_VERSION}-slim AS builder
# uv: fast, lockfile-driven installer. Pinned by digest-less tag for clarity;
# bump deliberately.
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /bin/
# Some transitive deps (grpcio, onnxruntime, tokenizers) ship manylinux wheels
# for cp312, but keep a compiler around so a missing wheel degrades to a source
# build instead of failing the image. Dropped from the final stage.
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# Optional extras baked into the image. CPU-safe by default; GPU is a separate
# Dockerfile. Pass a comma-separated list, e.g. EXTRAS="extract,spellcheck".
ARG EXTRAS="extract,spellcheck"
# Layer 1: dependencies only (no project) — cached across source changes.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=README.md,target=README.md \
set -e; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-install-project --no-dev ${flags}
# Layer 2: the project itself. --no-editable installs mempalace into the venv's
# site-packages (instead of an .pth pointing at /app), so the runtime stage can
# copy only /app/.venv and drop the source tree.
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
set -e; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-dev --no-editable ${flags}
# --- runtime ----------------------------------------------------------------
FROM python:${PYTHON_VERSION}-slim AS runtime
LABEL org.opencontainers.image.title="MemPalace" \
org.opencontainers.image.description="Local-first AI memory — verbatim storage, MCP server + CLI." \
org.opencontainers.image.source="https://github.com/MemPalace/mempalace" \
org.opencontainers.image.licenses="MIT"
# /data is the single persistence root: HOME points here, so the palace
# (~/.mempalace/palace), config (~/.mempalace), and the embedding-model cache
# all land under one mountable volume. The default `minilm` model caches under
# ~/.cache/chroma (~80 MB, from ChromaDB's S3); the optional `embeddinggemma`
# model caches under ~/.cache/huggingface (~300 MB). Both lazy-download on
# first use.
ENV HOME=/data \
PATH="/app/.venv/bin:${PATH}" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Non-root user owning the data volume.
RUN groupadd --gid 1000 mempalace \
&& useradd --uid 1000 --gid 1000 --home-dir /data --create-home mempalace
WORKDIR /app
# The resolved virtualenv from the builder — no build toolchain in this layer.
COPY --from=builder --chown=mempalace:mempalace /app/.venv /app/.venv
COPY --chown=mempalace:mempalace docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER mempalace
VOLUME ["/data"]
# Default to the MCP server; `docker run` it with `-i` for stdio JSON-RPC.
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["mcp"]
+112
View File
@@ -0,0 +1,112 @@
# syntax=docker/dockerfile:1.7
# MemPalace — GPU (NVIDIA CUDA) image.
#
# Multi-stage build using uv (the project ships a uv.lock, so we install from
# the frozen lockfile for reproducible images). The `gpu` extra pulls
# onnxruntime-gpu, which needs CUDA + cuDNN shared libraries at runtime, so
# this variant builds on an nvidia/cuda base instead of python:slim.
#
# The builder stage is dropped from the final image, so build tools
# (compilers, apt cache) never reach production — only the uv-managed
# interpreter and the resolved virtualenv do.
#
# Build:
# docker build -f Dockerfile.gpu -t mempalace:gpu .
#
# Run (requires the NVIDIA Container Toolkit on the host):
# docker run -i --rm --gpus all \
# -e MEMPALACE_EMBEDDING_DEVICE=cuda \
# -v mempalace-data:/data mempalace:gpu
#
# NOTE: onnxruntime-gpu ties itself to a CUDA major version. If embeddings
# fail to load on the GPU, align CUDA_IMAGE below with the CUDA release that
# the resolved onnxruntime-gpu wheel targets (see its release notes), then
# rebuild.
ARG CUDA_IMAGE=nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
# --- builder ----------------------------------------------------------------
FROM ${CUDA_IMAGE} AS builder
COPY --from=ghcr.io/astral-sh/uv:0.5 /uv /uvx /bin/
# Minimal toolchain for any source-built wheels; dropped before the runtime
# stage so compilers never ship in the production image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
ARG PYTHON_VERSION=3.12
ARG EXTRAS="extract,spellcheck,gpu"
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
UV_PYTHON_PREFERENCE=only-managed
WORKDIR /app
# Layer 1: dependencies only (no project source). Bind-mounted files keep the
# project tree out of this layer, so changing source code does not bust the
# deps cache. The uv-managed interpreter is also installed here, into a
# stable path that the runtime stage can copy verbatim.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=README.md,target=README.md \
set -e; \
uv python install ${PYTHON_VERSION}; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-install-project --no-dev --python ${PYTHON_VERSION} ${flags}
# Layer 2: the project itself. --no-editable installs mempalace into the
# venv's site-packages (instead of an .pth pointing at /app), so the runtime
# stage can copy only /app/.venv and drop the source tree.
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
set -e; \
flags=""; \
for e in $(echo "${EXTRAS}" | tr ',' ' '); do flags="${flags} --extra ${e}"; done; \
uv sync --frozen --no-dev --no-editable ${flags}
# --- runtime ----------------------------------------------------------------
FROM ${CUDA_IMAGE} AS runtime
LABEL org.opencontainers.image.title="MemPalace (GPU)" \
org.opencontainers.image.description="Local-first AI memory with CUDA-accelerated embeddings." \
org.opencontainers.image.source="https://github.com/MemPalace/mempalace" \
org.opencontainers.image.licenses="MIT"
# ca-certificates only — needed for the lazy HuggingFace model download on
# first use. No build toolchain in this stage.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV HOME=/data \
PATH="/app/.venv/bin:${PATH}" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
MEMPALACE_EMBEDDING_DEVICE=cuda
# Non-root user owning the data volume.
RUN groupadd --gid 1000 mempalace \
&& useradd --uid 1000 --gid 1000 --home-dir /data --create-home mempalace
WORKDIR /app
# Bring the uv-managed interpreter and the resolved venv across the stage
# boundary. /opt/uv/python must be copied alongside .venv: the venv's
# shebangs and binary launcher reference it.
COPY --from=builder /opt/uv/python /opt/uv/python
COPY --from=builder --chown=mempalace:mempalace /app/.venv /app/.venv
COPY --chown=mempalace:mempalace docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER mempalace
VOLUME ["/data"]
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["mcp"]
+124 -24
View File
@@ -1,21 +1,3 @@
> [!CAUTION]
> # 🚨 CRITICAL SECURITY WARNING: BEWARE OF SCAMS
> **MemPalace has NO other official websites.**
>
> The **ONLY** official sources are:
> 1. This **[GitHub repository](https://github.com/MemPalace/mempalace)**
> 2. The **[PyPI package](https://pypi.org/project/mempalace/)**
> 3. The docs at **[mempalaceofficial.com](https://mempalaceofficial.com)**
>
> **ANY other domain** (including `.tech`, `.net`, or other `.com` variants) is an **impostor** and may distribute **malware**. Do not download executables from untrusted sites. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
> [!IMPORTANT]
> **🚨 Claude Code sessions expire in 30 days w/out auto-save hooks wired!** **[Read this →](https://github.com/MemPalace/mempalace/discussions/1388)**
>
> Need the shortest recovery/setup path? Use the
> [Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html).
<div align="center">
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
@@ -31,6 +13,14 @@ Local-first AI memory. Verbatim storage, pluggable backend, 96.6% R@5 raw on Lon
</div>
> [!CAUTION]
> **Beware of impostor sites.** MemPalace has no other official websites. The **only** official sources are this **[GitHub repository](https://github.com/MemPalace/mempalace)**, the **[PyPI package](https://pypi.org/project/mempalace/)**, and the docs at **[mempalaceofficial.com](https://mempalaceofficial.com)**. Any other domain (including `.tech`, `.net`, or other `.com` variants) is an impostor and may distribute malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
> [!IMPORTANT]
> **Claude Code sessions expire in 30 days without auto-save hooks wired.** [Read this →](https://github.com/MemPalace/mempalace/discussions/1388)
>
> Need the shortest recovery/setup path? Use the [Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html).
---
## What it is
@@ -79,6 +69,107 @@ python -m venv .venv && source .venv/bin/activate
pip install mempalace
```
### Docker
A container image is also available for running the MCP server or the CLI
without a local Python toolchain. Multi-arch (amd64 + arm64), so it runs
natively on Apple Silicon:
```bash
docker pull ghcr.io/mempalace/mempalace:latest
```
Everything persists under `/data` — palace, config, and the cached embedding
model — so mount a volume there and reuse it across runs:
```bash
# MCP server over stdio — note the `-i` flag (JSON-RPC needs stdin)
docker run -i --rm -v mempalace-data:/data ghcr.io/mempalace/mempalace
# Run any CLI command instead. The container only sees what you mount, so
# mount the directory you want to mine — read-only is enough, mining never
# writes to the source.
docker run --rm -v mempalace-data:/data -v /path/to/project:/work:ro \
ghcr.io/mempalace/mempalace mine /work
docker run --rm -v mempalace-data:/data ghcr.io/mempalace/mempalace search "why GraphQL"
```
The first command that needs embeddings downloads the model into `/data`
(~80 MB for the default `minilm`, ~300 MB for `embeddinggemma`). It is a
one-off as long as the volume persists, but it does mean the first call is
slow and needs network — worth knowing before assuming a hung container.
Wire it into an MCP client (e.g. Claude Code) as a stdio server. Mount
anything you want the server to be able to mine — it cannot reach your
transcripts otherwise:
```json
{
"mcpServers": {
"mempalace": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "mempalace-data:/data",
"-v", "/absolute/path/to/.claude/projects:/transcripts:ro",
"ghcr.io/mempalace/mempalace"
]
}
}
}
```
Use a real absolute path there — `~` and `$HOME` are not expanded by every
MCP client. Paths are container paths from then on: mine `/transcripts`, not
`~/.claude/projects`.
**Mount permissions on Linux.** The image runs as uid 1000 and bind mounts
keep their host ownership, so a mounted directory has to be readable by that
uid — an ordinary `0755` checkout is fine, a `0700` directory is not, and the
failure surfaces as `PermissionError: [Errno 13]` rather than anything about
Docker. Docker Desktop maps uids on macOS and Windows, so this only bites on
Linux. Do **not** work around it with `--user`: `/data` is owned by uid 1000
inside the image, so another uid cannot write the palace at all.
`docker compose run --rm mcp` works too (see `docker-compose.yml`), and
`deploy/docker-compose.server.yml` stands up the team server. To build the
image yourself instead of pulling — required for the GPU variant, which is not
published:
```bash
docker build -t mempalace . # CPU
docker build --build-arg EXTRAS="extract,spellcheck" -t mempalace .
docker build -f Dockerfile.gpu -t mempalace:gpu . # CUDA; run with --gpus all
```
The GPU image is x86_64-only: `onnxruntime-gpu` publishes no aarch64 Linux
wheels, so that last build fails on an ARM host (including Apple Silicon) with
a dependency-resolution error rather than an obvious one.
Note that a build from a clone uses whatever branch you checked out; `develop`
is the default branch, so pull the published image if you want the released
version.
## Storage backends
ChromaDB is the default and needs no configuration. MemPalace also ships a
pluggable backend contract, exercised across deliberately different substrates
so the contract is never accidentally shaped around one vendor. Every
non-default backend is opt-in.
| Backend | Mode | Install | Namespaces | Lexical | Configure with |
| ------- | ---- | ------- | :--------: | :-----: | -------------- |
| `chroma` _(default)_ | Local (embedded) | bundled | – | ✓ | – |
| `sqlite_exact` | Local (exact) | bundled | – | ✓ | – |
| `milvus` | Local (Lite) · Server opt-in | `mempalace[milvus]` | ✓ | ✓ | `MEMPALACE_MILVUS_URI` |
| `qdrant` | Server (REST) | bundled | ✓ | ✓ | `MEMPALACE_QDRANT_URL` |
| `pgvector` | Server (Postgres) | `mempalace[pgvector]` | ✓ | ✓ | `MEMPALACE_PGVECTOR_DSN` |
Select with `--backend <name>`, `MEMPALACE_BACKEND=<name>`, or
`"backend": "<name>"` in `config.json`. See
[Storage backends](/guide/configuration#storage-backends) for connection
variables, namespace behavior, and deployment notes.
## Quickstart
```bash
@@ -93,7 +184,8 @@ mempalace search "why did we switch to GraphQL"
mempalace wake-up
```
For Claude Code, Gemini CLI, MCP-compatible tools, and local models, see
For Claude Code, Gemini CLI, [Antigravity](https://mempalaceofficial.com/guide/antigravity.html),
MCP-compatible tools, and local models, see
[mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html).
---
@@ -161,8 +253,9 @@ Usage and tool reference:
## MCP server
29 MCP tools cover palace reads/writes, knowledge-graph operations,
cross-wing navigation, drawer management, and agent diaries. Installation
44 MCP tools cover palace reads/writes, knowledge-graph operations,
cross-wing navigation, drawer management, agent diaries, and agent
coordination (logstream events + artifact handoffs). Installation
and the full tool list:
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
@@ -175,8 +268,14 @@ system prompt:
## Auto-save hooks
Two Claude Code hooks save periodically and before context compression:
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
Auto-save hooks for **Claude Code, Codex CLI, and Cursor IDE** save
periodically and before context compression:
- Claude Code + Codex →
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html)
- Cursor IDE (adds session-start recall and a transcript snapshot before
compaction) →
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html)
If you are installing under time pressure, start with the
[Claude Code retention setup checklist](https://mempalaceofficial.com/guide/claude-code-retention.html):
@@ -194,6 +293,7 @@ verbatim drawer per user/assistant message, idempotent and resume-safe.
- Python 3.9+
- A vector-store backend (ChromaDB by default)
- ~300 MB disk for the embedding model. Onboarding (`python -m mempalace.onboarding`) offers `embeddinggemma-300m` (multilingual, 100+ languages, recommended) or `all-MiniLM-L6-v2` (English-only, ~30 MB). See the docstring at [`mempalace/embedding.py`](mempalace/embedding.py) for details and migration notes.
- Optional — compute embeddings on a server instead of locally. Set `embedding_model: "openai-compat"` in `~/.mempalace/config.json` together with `embedding_api_url` / `embedding_api_model` (and `embedding_api_key` if the server needs auth) to use any OpenAI-compatible `/v1/embeddings` endpoint — LM Studio, llama.cpp, vLLM, Ollama's OpenAI shim, or a self-hosted server (e.g. a larger multilingual or GPU-served embedder). Each key is overridable via the matching `MEMPALACE_EMBEDDING_API_*` env var. When the endpoint is on your machine or LAN, no content leaves your network. Switching to it requires `mempalace repair rebuild-index` (different vector space).
No API key is required for the core benchmark path.
@@ -215,7 +315,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
MIT — see [LICENSE](LICENSE).
<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.3.6-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.7.0-4dc9f6?style=flat-square&labelColor=0a0e14
[release-link]: https://github.com/MemPalace/mempalace/releases
[python-shield]: https://img.shields.io/badge/python-3.9+-7dd8f8?style=flat-square&labelColor=0a0e14&logo=python&logoColor=7dd8f8
[python-link]: https://www.python.org/
+1 -1
View File
@@ -665,7 +665,7 @@ def run_benchmark(
palace_cache = json.load(f)
print(f" Palace cache: {len(palace_cache)} room assignments loaded")
rerank_label = f" + LLM re-rank ({llm_model.split('-')[1]})" if llm_rerank_enabled else ""
rerank_label = f" + LLM re-rank ({llm_model})" if llm_rerank_enabled else ""
print(f"\n{'=' * 60}")
print(" MemPal × LoCoMo Benchmark")
+3 -4
View File
@@ -3015,7 +3015,7 @@ def run_benchmark(
if unique_sessions and api_key and not skip_precompute:
print(
f" Diary ingest: pre-computing {len(unique_sessions)} sessions with {llm_model.split('-')[1]}..."
f" Diary ingest: pre-computing {len(unique_sessions)} sessions with {llm_model}..."
)
done = 0
cache_path = Path(diary_cache_file) if diary_cache_file else None
@@ -3051,9 +3051,8 @@ def run_benchmark(
print(f" Data: {Path(data_file).name}")
print(f" Questions: {len(data)}")
print(f" Granularity: {granularity}")
model_short = llm_model.split("-")[1] if "-" in llm_model else llm_model
rerank_label = f" + LLM re-rank ({model_short})" if llm_rerank_enabled else ""
diary_label = f" [diary ingest: {model_short}]" if mode == "diary" else ""
rerank_label = f" + LLM re-rank ({llm_model})" if llm_rerank_enabled else ""
diary_label = f" [diary ingest: {llm_model}]" if mode == "diary" else ""
print(f" Mode: {mode}{diary_label}{rerank_label}")
print(f"{'─' * 60}\n")
+7
View File
@@ -0,0 +1,7 @@
---
description: Show comprehensive MemPalace help — available skills, MCP tools, CLI commands, hooks, and architecture.
---
Invoke the `mempalace` skill from this plugin and run the `help` instructions, then follow them.
Concretely: run `mempalace instructions help` in a terminal, then carry out the steps it prints.
+12
View File
@@ -0,0 +1,12 @@
---
description: Set up MemPalace — install the package, initialize a palace, register the MCP server with Cursor, and verify everything works.
---
Invoke the `mempalace` skill from this plugin and run the `init` instructions, then follow them.
Concretely: run `mempalace instructions init` in a terminal, then carry out the steps it prints.
Cursor-specific extras after init:
1. The `mempalace-mcp` server is already auto-registered by this plugin — no manual `mcp.json` edit needed.
2. For automatic background saves and session-start memory recall, also run `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See `website/guide/cursor-hooks.md` for the walkthrough.
+7
View File
@@ -0,0 +1,7 @@
---
description: Mine projects and conversations into the MemPalace. Supports project files, conversation exports, and auto-classification.
---
Invoke the `mempalace` skill from this plugin and run the `mine` instructions, then follow them.
Concretely: run `mempalace instructions mine` in a terminal, then carry out the steps it prints.
+7
View File
@@ -0,0 +1,7 @@
---
description: Search your memories across the MemPalace using semantic search with wing/room filtering.
---
Invoke the `mempalace` skill from this plugin and run the `search` instructions, then follow them.
Concretely: run `mempalace instructions search` in a terminal, then carry out the steps it prints. The MCP tool `mempalace_search` is also available directly from this Cursor session.
+7
View File
@@ -0,0 +1,7 @@
---
description: Show the current state of your memory palace — wings, rooms, drawer counts, and suggestions.
---
Invoke the `mempalace` skill from this plugin and run the `status` instructions, then follow them.
Concretely: run `mempalace instructions status` in a terminal, then carry out the steps it prints.
+71
View File
@@ -0,0 +1,71 @@
# MemPalace remote team server — MCP over HTTP, backed by a central Qdrant.
#
# One command stands up a shared memory service a whole team's AI clients
# connect to. Embeddings are still produced locally inside the mempalace
# container; only your own Qdrant ever receives the vectors and text.
#
# 1. cp deploy/server.env.example deploy/.env && edit deploy/.env
# (at minimum set MEMPALACE_MCP_HTTP_TOKEN to a long random secret)
# 2. docker compose -f deploy/docker-compose.server.yml --env-file deploy/.env up -d
# 3. connect a client (see the Remote / Team Server guide):
# claude mcp add --transport http mempalace http://YOUR_HOST:8765/mcp \
# --header "Authorization: Bearer $MEMPALACE_MCP_HTTP_TOKEN"
#
# SECURITY: this exposes plaintext HTTP on :8765. For anything beyond a trusted
# private network, put a TLS-terminating reverse proxy in front (nginx/Caddy/
# Traefik) and only expose the proxy. The bearer token is mandatory for the
# network-exposed (0.0.0.0) bind.
services:
qdrant:
image: qdrant/qdrant:latest
restart: unless-stopped
volumes:
- qdrant-storage:/qdrant/storage
# Not published to the host: only the mempalace service reaches it over the
# internal compose network. Uncomment to inspect Qdrant directly.
# ports:
# - "6333:6333"
mempalace:
image: ghcr.io/mempalace/mempalace:latest
restart: unless-stopped
depends_on:
- qdrant
command:
- serve
- --host
- "0.0.0.0"
- --port
- "8765"
- --backend
- qdrant
# Uncomment to expose recall without write access to most clients:
# - --read-only
environment:
# Required for the network-exposed bind. Set in deploy/.env.
MEMPALACE_MCP_HTTP_TOKEN: ${MEMPALACE_MCP_HTTP_TOKEN:?set MEMPALACE_MCP_HTTP_TOKEN in deploy/.env}
MEMPALACE_QDRANT_URL: http://qdrant:6333
MEMPALACE_QDRANT_API_KEY: ${MEMPALACE_QDRANT_API_KEY:-}
# Set to cuda/dml/coreml on an accelerated host (see Dockerfile.gpu).
MEMPALACE_EMBEDDING_DEVICE: ${MEMPALACE_EMBEDDING_DEVICE:-auto}
ports:
- "8765:8765"
volumes:
- mempalace-data:/data
healthcheck:
# The image has no curl; use Python (always present). /healthz needs no auth.
# If you enable TLS on the server itself, switch this to https + ssl context.
test:
- CMD
- python
- -c
- "import urllib.request,sys; sys.exit(0) if urllib.request.urlopen('http://127.0.0.1:8765/healthz').read().strip()==b'ok' else sys.exit(1)"
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
volumes:
qdrant-storage:
mempalace-data:
+51
View File
@@ -0,0 +1,51 @@
# MemPalace remote MCP server — systemd unit template.
#
# Install:
# sudo useradd --system --home /var/lib/mempalace --shell /usr/sbin/nologin mempalace
# sudo install -d -o mempalace -g mempalace -m 750 /var/lib/mempalace /etc/mempalace
# sudo cp deploy/server.env.example /etc/mempalace/server.env # then edit + chmod 600
# sudo install -m 600 -o mempalace -g mempalace /etc/mempalace/server.env /etc/mempalace/server.env
# # install mempalace into a venv on PATH, or adjust ExecStart to its absolute path
# sudo cp deploy/mempalace-server.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now mempalace-server
#
# This binds 0.0.0.0:8765 and requires MEMPALACE_MCP_HTTP_TOKEN (set in the
# EnvironmentFile). Front it with a TLS-terminating reverse proxy, or set
# MEMPALACE_MCP_TLS_CERT / _KEY in the EnvironmentFile for native TLS.
[Unit]
Description=MemPalace remote MCP server
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=mempalace
Group=mempalace
EnvironmentFile=/etc/mempalace/server.env
ExecStart=mempalace serve --host 0.0.0.0 --port 8765
# The idle watchdog exits with status 0, which on-failure reads as an
# intentional stop, so the server would stay down. server.env disables the
# watchdog; Restart=always covers a deployment that has not set it.
Restart=always
RestartSec=2
# --- Hardening ---------------------------------------------------------------
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=false
# The palace and any local state live here; everything else is read-only.
ReadWritePaths=/var/lib/mempalace
StateDirectory=mempalace
[Install]
WantedBy=multi-user.target
+35
View File
@@ -0,0 +1,35 @@
# MemPalace remote server environment.
# Copy to deploy/.env (compose) or /etc/mempalace/server.env (systemd) and edit.
# Keep this file readable only by the service account: chmod 600.
# --- Required for a network-exposed (0.0.0.0) bind ------------------------------
# Clients send: Authorization: Bearer <this value>. Generate a strong secret:
# openssl rand -hex 32
MEMPALACE_MCP_HTTP_TOKEN=
# --- Storage backend ------------------------------------------------------------
# The team server should use a networked backend so the palace is shared.
MEMPALACE_BACKEND=qdrant
MEMPALACE_QDRANT_URL=http://qdrant:6333
# MEMPALACE_QDRANT_API_KEY=
# --- Embedding ------------------------------------------------------------------
# auto | cpu | cuda | dml | coreml. Use cuda on a GPU host (needs the GPU image).
MEMPALACE_EMBEDDING_DEVICE=auto
# --- Optional: native TLS (otherwise terminate TLS at a reverse proxy) ----------
# Point these at a PEM cert/key the service account can read. When set, serve
# speaks HTTPS directly and clients connect to https://...
# MEMPALACE_MCP_TLS_CERT=/etc/mempalace/tls/cert.pem
# MEMPALACE_MCP_TLS_KEY=/etc/mempalace/tls/key.pem
# --- Server lifecycle -----------------------------------------------------------
# The MCP server exits by itself after this many idle hours (default 8) to free
# the ChromaDB/HNSW file handles that per-session stdio servers leave behind.
# A dedicated always-on server does not need that, so 0 disables it. Note that
# /healthz traffic does not count as activity; only MCP requests reset the timer.
MEMPALACE_MCP_IDLE_HOURS=0
# --- Palace location (systemd / bare-metal) -------------------------------------
# In Docker the palace lives on the mempalace-data volume by default.
# MEMPALACE_PALACE_PATH=/var/lib/mempalace/palace
+35
View File
@@ -0,0 +1,35 @@
# MemPalace via Docker Compose.
#
# The MCP server speaks JSON-RPC over stdio, so the `mcp` service is meant to
# be driven interactively (`docker compose run`), not left running detached:
#
# docker compose build
# docker compose run --rm mcp # MCP server over stdio
# docker compose run --rm mcp cli search "GraphQL" # one-off CLI command
#
# The named volume `mempalace-data` is mounted at /data and holds the palace,
# config, and the cached embedding model across runs.
services:
mcp:
build:
context: .
dockerfile: Dockerfile
image: mempalace:local
# stdio transport: keep STDIN open and allocate no TTY (raw JSON-RPC).
stdin_open: true
tty: false
volumes:
- mempalace-data:/data
# Everything defaults correctly from HOME=/data (palace ->
# /data/.mempalace/palace, config -> /data/.mempalace, model cache ->
# /data/.cache), so no environment block is needed. Uncomment the key
# *together with* an entry under it for non-default locations — a bare
# `environment:` with only comments beneath parses as null, and Compose
# rejects the whole file with "services.mcp.environment must be a mapping".
# environment:
# MEMPALACE_EMBEDDING_MODEL: embeddinggemma # multilingual (default: minilm)
# MEMPALACE_PALACE_PATH: /data/custom/palace
volumes:
mempalace-data:
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env sh
# Flexible entrypoint: pick the MCP server or the CLI from the first argument.
#
# docker run -i mempalace -> MCP server over stdio (default)
# docker run -i mempalace mcp -> MCP server over stdio (explicit)
# docker run mempalace cli search "query" -> CLI passthrough (explicit)
# docker run mempalace search "query" -> CLI passthrough (implicit)
#
# `mcp` and `cli` are dispatch keywords; anything else is forwarded to the
# `mempalace` CLI verbatim so subcommands like `mine`, `search`, `wake-up`
# work without ceremony.
set -e
case "${1:-mcp}" in
mcp)
if [ "$#" -gt 0 ]; then
shift
fi
exec mempalace-mcp "$@"
;;
cli)
if [ "$#" -gt 0 ]; then
shift
fi
exec mempalace "$@"
;;
*)
exec mempalace "$@"
;;
esac
+56
View File
@@ -29,3 +29,59 @@ If `pyproject.toml` has no match, **stop** — the entry point is missing and
any fresh `pip install` will ship a broken plugin config. Investigate whether
the release branch was cut before
[#340](https://github.com/MemPalace/mempalace/pull/340) landed on `develop`.
## Publishing to PyPI
Releases publish automatically via the
[`publish.yml`](../.github/workflows/publish.yml) workflow, using PyPI
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC). There
is **no API token** stored anywhere — GitHub mints a short-lived identity at
upload time. The workflow fires when a **GitHub Release is published**, builds
the sdist + wheel, and pauses for manual approval on the `pypi` environment
before uploading.
### One-time setup (owners only)
Done once per project; both steps require PyPI owner / GitHub admin rights.
1. **PyPI trusted publisher** — on PyPI, go to **Manage project `mempalace`
→ Publishing → Add a trusted publisher** and enter exactly:
| Field | Value |
| --- | --- |
| Owner | `MemPalace` |
| Repository name | `mempalace` |
| Workflow filename | `publish.yml` |
| Environment name | `pypi` |
2. **GitHub environment** — in the repo, **Settings → Environments → New
environment** named `pypi`. Add yourself (and any other release approvers)
under **Required reviewers**. This is the manual gate the workflow waits on
before the upload step runs.
### Cutting a release
1. Bump the version in **all five** sources on `develop` so `version-guard.yml`
stays green (it is the single source of truth at `mempalace/version.py`,
mirrored in `pyproject.toml`, `.claude-plugin/marketplace.json`,
`.claude-plugin/plugin.json`, and `.codex-plugin/plugin.json`).
2. Land everything for the release on `develop`, then merge `develop → main`.
Releases publish **only from `main`** — the workflow refuses any tag whose
commit is not an ancestor of `main`. Don't commit the bump directly to
`main`: it bypasses branch protection and leaves `develop` behind.
3. Run the **entry-point alignment check** above.
4. On GitHub, **Releases → Draft a new release**:
- **Target:** `main`
- **Tag:** `vX.Y.Z` (must equal `mempalace/version.py`; the workflow and
`version-guard.yml` both reject a mismatch)
- Write the release notes, then **Publish release**.
5. The `publish.yml` run validates the tag (on `main`, matches the manifest),
builds, and then waits for approval on the `pypi` environment. Approve it to
upload to PyPI. Watch the run land the new version on
<https://pypi.org/project/mempalace/>.
To stage a release candidate without shipping to end users, tag a semver
pre-release (`vX.Y.Z-rc1`) — `version-guard.yml` skips the strict manifest
match for pre-release tags. (Note: a published GitHub Release still triggers
`publish.yml`; use a **draft** release, or a plain pushed tag, for dry runs you
don't want uploaded.)
+45
View File
@@ -0,0 +1,45 @@
# Authored date (`authored_at`)
Conversation transcripts carry a per-line ISO-8601 `timestamp` (both Claude Code and
Codex JSONL). The miner records the most recent one per file as the drawer's
**`authored_at`** — when the content was actually written.
This is distinct from the ingest date:
| Field | Meaning |
|-------|---------|
| `filed_at` / result `created_at` | When the drawer was **mined** (written to the palace). A bulk re-mine collapses these to a single instant. |
| `authored_at` | When the underlying content was **written**, recovered from the transcript timestamps. Survives re-mining. |
`authored_at` is surfaced in search results (and shown in the CLI `search` output), and is
used as a deterministic tie-break in hybrid ranking: candidates with identical scores order
with the more recently authored drawer first. Drawers without per-line timestamps (e.g.
markdown) fall back to `filed_at`.
## Backfilling existing memory
New mines populate `authored_at` automatically. Drawers mined before this feature only have
`filed_at`. Re-mining does **not** fix them — the scanner skips files already mined at the
current `NORMALIZE_VERSION`. Two options:
1. **In-place backfill (recommended — no re-embedding).** `scripts/backfill_authored_at.py`
reads each convos drawer's source transcript and updates only the `authored_at` metadata.
Idempotent and safe to re-run; embeddings are untouched.
```bash
python scripts/backfill_authored_at.py \
--palace ~/.mempalace/palace \
--sessions ~/.claude --sessions ~/.codex # dry run
python scripts/backfill_authored_at.py \
--palace ~/.mempalace/palace \
--sessions ~/.claude --sessions ~/.codex --apply # write
```
For the Docker MCP image, mount the volume and session dirs read-only — see the header of
`scripts/backfill_authored_at.py` for the exact `docker run` invocation.
> Back up first: `tar czf palace-backup.tgz -C <palace-dir> .` (or snapshot the
> `mempalace-data` volume).
2. **Drop and recreate.** Delete the affected drawers and re-mine the transcripts; the fresh
mine stamps `authored_at`. Simpler, but re-embeds everything.
+112
View File
@@ -0,0 +1,112 @@
# Hook write routing
Hook-triggered writes use the shared write-routing policy introduced for the
Tier 3 rollout tracked in #1963.
## Scope
This applies to every routine write initiated by the Python hook layer:
- Stop-hook diary checkpoints;
- transcript/conversation ingest;
- project auto-ingest;
- SessionEnd final flushes;
- PreCompact transcript ingest;
- PreCompact synchronous project mining.
It does not change CLI write routing. CLI adoption is a separate follow-up.
## Policy behavior
### `direct`
Hooks use the existing direct in-process or subprocess paths.
The daemon is not probed.
### `prefer`
Hooks use the daemon when it is already healthy.
If the daemon is unavailable, hooks retain the historical direct fallback.
### `require`
Hooks use the daemon when it is already healthy.
If the daemon is unavailable:
- no in-process ChromaDB write runs;
- no direct `mempalace mine` subprocess is started;
- no daemon is cold-started from the hook;
- the hook log records the skipped operation;
- the hook returns a visible `systemMessage`;
- the Stop save marker is not advanced, allowing a later retry.
## Why hooks do not start the daemon
Hooks operate under strict latency budgets. Starting a Python daemon and its
storage dependencies from a Stop or SessionEnd hook can exceed that budget.
A supervised installation using `require` must start the daemon earlier, for
example at login, plugin initialization, or session setup:
mempalace daemon start
SessionStart performs a fast health probe in `require` mode and warns early if
the required daemon is unavailable.
## One decision per hook event
A Stop or SessionEnd event may perform several writes:
1. diary checkpoint;
2. transcript ingest;
3. project auto-ingest.
The route is resolved once and stored in a context-local value for the whole
write burst. This avoids repeated health probes and prevents different writes
from selecting inconsistent routes during the same event.
## Submission ambiguity
Once a daemon submission is attempted, an error never triggers direct
fallback. The daemon may have accepted the job before the client observed the
failure; retrying directly could duplicate content.
## Invalid configuration
An explicitly invalid routing policy fails closed: hook writes are blocked and
no direct ChromaDB fallback is attempted.
An unrelated configuration read/runtime failure preserves the historical
direct-save behavior so a final checkpoint is not lost because of an
independent configuration failure.
## Backward compatibility
The default policy remains `direct`.
Legacy settings remain supported through the shared policy resolver:
- `MEMPALACE_HOOKS_DAEMON=true` maps to `prefer`;
- `hooks.daemon: true` maps to `prefer`;
- false values map to `direct`.
## Configuration examples
Prefer the daemon but permit direct fallback:
MEMPALACE_HOOK_WRITE_ROUTING=prefer
Require the daemon and prohibit direct writers:
MEMPALACE_HOOK_WRITE_ROUTING=require
Configuration file:
{
"write_routing": {
"hooks": "require"
}
}
+63
View File
@@ -0,0 +1,63 @@
# Recovery: legacy wing names split after the normalization change
**Companion to #1675.** `normalize_wing_name` now strips leading and trailing
separators, so a path-encoded project dir like `-home-user-proj` derives the
wing `home_user_proj` instead of `_home_user_proj`. Palaces mined before that
change filed drawers under the old, separator-padded name. New mining and diary
writes land on the new name, so the two no longer meet — the history is
**split**, not lost. `mempalace migrate-wings` re-unites them.
## Symptom
After upgrading, a project that used to surface its memories returns less than
expected, and `mempalace status` shows two wings for one project — e.g. both
`_home_user_proj` (old drawers) and `home_user_proj` (newly mined). MCP writes
to the padded wing may also have been rejected, since `sanitize_name` does not
accept a leading underscore.
## Recovery
Preview first — this never modifies anything:
```bash
mempalace migrate-wings --dry-run
mempalace migrate-wings --dry-run --palace /path/to/palace
```
The plan lists each rename and flags collisions that will **merge** into an
existing wing:
```
Wing-name migration plan:
'_home_user_proj' -> 'home_user_proj': 1284 drawer(s), 96 closet(s) (MERGE into existing wing)
```
Apply it:
```bash
mempalace migrate-wings # prompts for confirmation
mempalace migrate-wings --yes # no prompt
```
## What it does
- Re-keys the `wing` **metadata field** on drawers and closets to the normalized
form, merging collisions into the existing wing.
- Re-keys the `topics_by_wing` registry (merging topic lists on collision).
## What it leaves alone
- **Drawer/closet IDs** are untouched. The wing in an ID (`drawer_<wing>_…`) is
an opaque prefix that is never decoded back into a wing, so leaving it keeps
closet `→drawer_id` pointers valid and lets future mining still skip
already-mined files (no duplicates). The verbatim drawer content is never
read or rewritten.
- **Tunnels** already normalize wing names at read time, so they resolve under
the new name without a rewrite.
## Notes
- **Idempotent.** A second run reports "nothing to migrate" and changes nothing.
- **Backend-agnostic.** Works on any configured storage backend.
- Run it once per palace after upgrading. New palaces are born with normalized
wing names and never need it.
@@ -0,0 +1,670 @@
# RFC 001 — Storage Backend Plugin Specification
- **Status:** Accepted (2026-06-07)
- **Tracking issue:** [#737](https://github.com/MemPalace/mempalace/issues/737)
- **Supersedes:** The informal seam introduced by [#413](https://github.com/MemPalace/mempalace/pull/413)
- **Related:** [#266](https://github.com/MemPalace/mempalace/issues/266), [#574](https://github.com/MemPalace/mempalace/pull/574), [#643](https://github.com/MemPalace/mempalace/pull/643), [#665](https://github.com/MemPalace/mempalace/pull/665), [#697](https://github.com/MemPalace/mempalace/pull/697), [#700](https://github.com/MemPalace/mempalace/pull/700), [#381](https://github.com/MemPalace/mempalace/pull/381), [#1679](https://github.com/MemPalace/mempalace/pull/1679)
- **Spec version:** `1.0`
> **Implementation status (2026-08-11).** The §1–2 contract surface (`PalaceRef`,
> typed results, `BaseBackend` / `BaseCollection`, capability tokens, and most of
> the §10 seam cleanup) landed ahead of this merge via [#1679](https://github.com/MemPalace/mempalace/pull/1679),
> which ships in-tree `pgvector`, `qdrant`, and `sqlite_exact` backends. The three
> areas originally deferred as follow-ups have since landed: embedder-identity
> (§1.5 / §5, [#1731](https://github.com/MemPalace/mempalace/pull/1731) /
> [#1734](https://github.com/MemPalace/mempalace/pull/1734)), maintenance hooks
> (§7.3, [#1732](https://github.com/MemPalace/mempalace/pull/1732)), and searcher
> metric-awareness (§10, [#1727](https://github.com/MemPalace/mempalace/pull/1727)).
> Accepting this RFC pins the contract; remaining gaps are evolutionary, not
> blocking.
## Summary
A formal contract for MemPalace storage backends so third parties can ship `pip install mempalace-<name>` packages that drop into the core without patches. The spec defines the collection interface, the backend lifecycle, registration via Python entry points, configuration shape, a required test contract, and a migration path between backends.
It also sets up MemPalace to run as a long-lived daemon that manages many palaces, where different palaces may route to different backends.
## Motivation
Six backend PRs are currently in flight. Each one solves the same problem six different ways — different method signatures, different registration mechanisms, different embedder ownership, incompatible where-clause dialects, no shared test suite. The ad-hoc `BaseCollection` ABC merged in #413 was deliberately minimal and deferred every non-obvious decision. This RFC closes the open decisions so backend authors can build to a stable contract.
## Goals
1. A backend ships as a standalone Python package; installing it is sufficient to use it.
2. All callers in MemPalace core go through the collection interface. No direct `chromadb` imports outside `mempalace/backends/chroma.py`.
3. Backends are interchangeable: every backend passes the same shared test suite, and `mempalace migrate` supports lossless movement between them when source/target capabilities allow, with explicit re-embedding as the fallback (§8.2).
4. The model scales from single-user local (one backend, one palace, no config) to a daemon serving many palaces with heterogeneous backends.
5. Chroma's current dict-shaped return values are not the long-term contract. Typed results are spec v1.
## Non-goals
- Defining the embedder pipeline in detail. The embedder is a separate contract this spec depends on but does not specify.
- Defining the sync subsystem. This spec only declares the capability flag and the minimal hook a sync subsystem will read.
- Specifying wire protocol for a future networked daemon. That is a separate RFC.
---
## 1. Collection contract
### 1.1 Required methods
All backends implement `BaseCollection` with kwargs-only signatures:
```python
class BaseCollection(ABC):
@abstractmethod
def add(
self,
*,
documents: list[str],
ids: list[str],
metadatas: list[dict] | None = None,
embeddings: list[list[float]] | None = None,
) -> None: ...
@abstractmethod
def upsert(
self,
*,
documents: list[str],
ids: list[str],
metadatas: list[dict] | None = None,
embeddings: list[list[float]] | None = None,
) -> None: ...
@abstractmethod
def query(
self,
*,
query_texts: list[str] | None = None,
query_embeddings: list[list[float]] | None = None,
n_results: int = 10,
where: dict | None = None,
where_document: dict | None = None,
include: list[str] | None = None,
) -> QueryResult: ...
@abstractmethod
def get(
self,
*,
ids: list[str] | None = None,
where: dict | None = None,
where_document: dict | None = None,
limit: int | None = None,
offset: int | None = None,
include: list[str] | None = None,
) -> GetResult: ...
@abstractmethod
def delete(
self,
*,
ids: list[str] | None = None,
where: dict | None = None,
) -> None: ...
@abstractmethod
def count(self) -> int: ...
```
### 1.2 Optional methods (default implementations on the ABC)
```python
def estimated_count(self) -> int:
return self.count()
def close(self) -> None:
return None
def health(self) -> HealthStatus:
return HealthStatus.ok()
def update(
self,
*,
ids: list[str],
documents: list[str] | None = None,
metadatas: list[dict] | None = None,
embeddings: list[list[float]] | None = None,
) -> None:
"""Partial update of existing rows. At least one of documents/metadatas/embeddings must be non-None.
Default implementation: get(ids=...), merge the provided fields, upsert. Non-atomic
and does two round-trips. Backends advertising `supports_update` MUST override with
an atomic, single-round-trip implementation.
"""
... # default impl in the ABC
```
Backends with cheap approximate counters override `estimated_count`. Backends that hold connections must override `close`. Backends with native partial-update primitives (Postgres `UPDATE`, Lance `merge_insert`) override `update` and advertise `supports_update`; the token signals "atomic + single round-trip," not "supports partial updates at all" — the default implementation already supports them, just non-atomically.
### 1.3 Typed results (replaces Chroma dict shape)
```python
@dataclass(frozen=True)
class QueryResult:
ids: list[list[str]] # outer = queries, inner = hits
documents: list[list[str]]
metadatas: list[list[dict]]
distances: list[list[float]]
embeddings: list[list[list[float]]] | None = None
@dataclass(frozen=True)
class GetResult:
ids: list[str]
documents: list[str]
metadatas: list[dict]
embeddings: list[list[float]] | None = None
```
On empty results: return a result object with empty inner lists, never raise. Specifically, an empty query returns `QueryResult(ids=[[]], documents=[[]], metadatas=[[]], distances=[[]])` — the outer dimension is the number of query vectors issued; the inner dimension is hits per query and may be zero.
`include` controls which fields are populated. Fields not in `include` are populated with empty lists of the correct outer shape; they are never `None` (except `embeddings`, which is `None` when not requested).
### 1.4 Where-clause dialect
**Required operators:** `$eq`, `$ne`, `$in`, `$nin`, `$and`, `$or`, `$contains`.
Backends that do not support full-text natively MUST still implement `$contains` via payload string match — correctness is required; performance is not. `supports_contains_fast` (§2.1) is the only performance floor the spec promises. Without it, callers and benchmarks MUST assume `$contains` is O(n). This is an intentional split: `$contains` is a correctness requirement, `contains_fast` is the performance boundary, and the gap between scan and indexed FTS is too large for the spec to paper over.
**Unknown operators:** backends MUST raise `UnsupportedFilterError`. Silent dropping is forbidden — it produces incorrect results.
**Optional operators:** `$gt`, `$gte`, `$lt`, `$lte`. Backends either implement them or reject with `UnsupportedFilterError`. Advertised via capabilities.
### 1.5 Embeddings
#### Signature compliance (all backends)
All backends MUST accept a pre-computed `embeddings=` argument on `add` / `upsert` without raising. This is signature compliance only — it does not guarantee the vectors are persisted (see passthrough below). Capability token: `supports_embeddings_in`.
Backends MUST NOT hardcode embedding models or dimensions. Model selection is the embedder's responsibility (§4).
#### Passthrough vs re-embed (separate guarantee)
Accepting the argument is not the same as honoring it. Two distinct semantics, distinguished by capability:
- **`supports_embeddings_passthrough`** — when `embeddings=` is provided, the backend MUST persist those vectors as-is and MUST NOT re-embed from text. This is the stronger guarantee lossless migration depends on.
- **No `supports_embeddings_passthrough`** — the backend always re-embeds from text at write time. Provided `embeddings=` is accepted (signature compliance) but discarded. Migration *to* such a backend is re-embedding, not lossless transfer.
`supports_migration_export` (source-side bulk read) MUST be paired with `supports_embeddings_passthrough` (target-side lossless write) for a migration to be labeled lossless. The `mempalace migrate` CLI refuses to run between backends where the target lacks `supports_embeddings_passthrough` unless `--accept-re-embed` is passed, which records re-embedding in the target palace's migration log.
#### Dimension check (all backends, required)
Backends MUST validate embedding dimension on first write to a new collection and on open of an existing collection, and MUST raise `DimensionMismatchError` on mismatch. Silent acceptance of mismatched dimensions produces unrecoverable corruption.
#### Model identity check (all backends, three-state)
Dimension matching is necessary but not sufficient. Swapping to a different model that happens to share a dimension (e.g., both 384-d) silently degrades retrieval without tripping `DimensionMismatchError`. Backends MUST persist `embedder.model_name` alongside the collection on first write and MUST check it on subsequent open. Three outcomes:
| State | Condition | Required behavior |
|---|---|---|
| `known_match` | Stored name equals current `embedder.model_name` | Proceed normally. |
| `known_mismatch` | Stored name exists and differs from current | Raise `EmbedderIdentityMismatchError`. Override only via explicit CLI `--force-model-swap`, which writes the swap to the palace's migration log and updates the stored identity. |
| `unknown` | No model name recorded (legacy collection, pre-v1 palace) | Do not hard-fail — emit a `EmbedderIdentityUnknownWarning` on first open. The resolved identity is recorded on the next successful write, reindex, or migration, transitioning the palace to `known_match` going forward. CLI exposes `mempalace palace set-embedder --model NAME` for explicit resolution. |
The `unknown` state exists because existing palaces from #413 and earlier have no recorded identity; hard-failing them on upgrade would be hostile. Once recorded, subsequent opens are strict.
An injected embedder that exposes no usable `model_name` (empty or `None`) resolves to `unknown` rather than being a hard error — the backend persists no identity, emits `EmbedderIdentityUnknownWarning`, and records identity on the first open against an embedder that *does* report a name. A nameless embedder is therefore a degraded-but-valid mode, not a rejection; operators promote it with `mempalace palace set-embedder --model NAME`.
> **Follow-up dependency.** The `MUST persist embedder.model_name` rule above is only satisfiable once the `Embedder` protocol (§5) is a normative contract — a backend cannot persist an identity it is never handed. §5 now pins the minimal protocol (`model_name`, `dimension`, `embed`) as sufficient for this section; the embedder-identity enforcement is tracked as follow-up implementation work (the current in-tree backends defer it). See §5.
#### `server_embedder` backends are not exempt
A backend advertising `server_embedder` (§2.1) provides its own embedder and MAY ignore the `embedder=` kwarg passed to `get_collection`. That does **not** exempt it from the dimension and identity rules above. Such backends MUST:
- Expose an effective `model_name: str` and `dimension: int` describing the embedder actually in use (via `BaseCollection.effective_embedder_identity() -> EmbedderIdentity`).
- Persist that effective identity on first write and validate it on open, per the three-state rules above.
- Raise `DimensionMismatchError` and `EmbedderIdentityMismatchError` on conflicts between the effective identity and any injected `embedder` (if one was passed) or between the stored identity and the current effective identity.
`server_embedder` documents where the embedding happens; it never suspends the safety contract. A backend that cannot report its effective embedder identity does not qualify for the `server_embedder` capability.
---
## 2. Backend contract
### 2.1 Identity and capabilities
```python
class BaseBackend(ABC):
name: ClassVar[str] # "chroma", "postgres", "qdrant", ...
spec_version: ClassVar[str] = "1.0" # which spec version this backend targets
capabilities: ClassVar[frozenset[str]]
distance_metric: ClassVar[str] = "cosine" # "cosine" | "l2" | "ip" (inner product)
```
`distance_metric` declares the space the backend's `distances` are reported in.
It is **not** a capability token (it is a single value, not a boolean), so it is a
class attribute. Core search code MUST convert a backend's reported distance to a
similarity using this declaration rather than assuming cosine — see §10, which
adds `searcher.py` to the cleanup precisely because `_hybrid_rank` currently
hard-codes `max(0, 1 - distance)` (cosine-only). All in-tree backends are `cosine`
today, so the assumption is latent, not yet wrong; the declaration makes a
non-cosine backend (e.g. a dot-product store) correct rather than silently
mis-ranked.
Defined capability tokens (v1):
| Token | Meaning |
|---|---|
| `supports_embeddings_in` | Accepts pre-computed `embeddings=` without raising (signature compliance; MUST be true for all backends) |
| `supports_embeddings_passthrough` | Persists provided `embeddings=` as-is without re-embedding (required for lossless migration target) |
| `supports_embeddings_out` | Returns embeddings when `include=["embeddings"]` is requested |
| `supports_estimated_count` | `estimated_count()` is meaningfully cheaper than `count()` |
| `supports_update` | `update()` is atomic and single-round-trip (vs the ABC default of get+merge+upsert) |
| `supports_metadata_filters` | Implements the required where-clause subset (§1.4) |
| `supports_range_filters` | Implements `$gt` / `$gte` / `$lt` / `$lte` |
| `supports_contains_fast` | `$contains` is indexed (vs scan-based) |
| `supports_server_side_indexes` | Exposes index creation / maintenance to operators |
| `supports_migration_export` | Implements a bulk read path suitable for `mempalace migrate` |
| `supports_change_feed` | Exposes `changes_since(cursor)` for the sync subsystem |
| `supports_sync` | Implies `supports_change_feed` plus idempotent upserts under conflicts |
| `requires_external_service` | Needs a running server (e.g., Postgres, hosted Qdrant) |
| `local_mode` | Persists to `palace.local_path` |
| `server_mode` | Connects to an external server; `palace.namespace` is used |
| `server_embedder` | Backend provides its own embedder (may ignore injected one) |
| `supports_namespace_isolation` | Enforces `PalaceRef.namespace` as a hard isolation boundary (§4.4). Multi-tenant deployments MAY rely on it for tenant isolation; backends without it MUST NOT be relied on for that. |
A backend may advertise both `local_mode` and `server_mode` (e.g., Chroma with either `PersistentClient` or `HttpClient`).
Capability tokens are free-form strings, not an enum — third-party backends may declare novel capabilities for their ecosystem. Core MemPalace only inspects the tokens listed above.
### 2.2 Palace references
A backend serves palaces, not raw filesystem paths. This is the central change from #413.
```python
@dataclass(frozen=True)
class PalaceRef:
id: str # stable identity, used as cache key
local_path: str | None = None # filesystem root, if this palace is local
namespace: str | None = None # server-side namespace/prefix, if applicable
```
Rules:
- `id` is always present. It is the key the backend uses to cache open handles.
- Local-only backends read `local_path`. If `local_path is None` they raise `PalaceNotFoundError`.
- Server-only backends read `namespace`. If `namespace is None` they derive one deterministically from `id`.
- Mixed-mode backends may use both (e.g., a local cache alongside a server store).
### 2.3 Methods
```python
class BaseBackend(ABC):
@abstractmethod
def get_collection(
self,
*,
palace: PalaceRef,
collection_name: str,
create: bool,
embedder: Embedder | None = None,
options: dict | None = None,
) -> BaseCollection: ...
def close_palace(self, palace: PalaceRef) -> None:
"""Evict a single palace's cached handles. Default: no-op."""
return None
def close(self) -> None:
"""Shut down the entire backend instance. Default: no-op."""
return None
def health(self, palace: PalaceRef | None = None) -> HealthStatus:
"""Return health. With palace=None, probe the backend itself."""
return HealthStatus.ok()
```
### 2.4 Semantics of `create`
- `create=False` on a nonexistent palace MUST raise `PalaceNotFoundError` (subclass of `FileNotFoundError` for backwards compatibility with the #413 seam).
- `create=True` MUST be idempotent — calling it repeatedly with the same arguments produces the same state and does not corrupt existing data.
- `create=True` on local backends creates the directory with `0700` permissions (matches the existing Chroma behavior).
**Multiple collections per palace.** `get_collection` is keyed by `collection_name`, and a palace MAY hold more than one collection. Backends MUST support N collections per palace, addressed by distinct `collection_name` values, with the §2.5 isolation guarantee applying per `(palace.id, collection_name)`. The "palace" is not 1:1 with a collection: production already splits verbatim drawers from short, query-term-saturated session-recovery checkpoints into sibling collections (`mempalace_drawers` vs `mempalace_session_recovery`) so the latter can't dominate vector top-N. Backends like Postgres (schema/table naming) and Qdrant (collection naming) handle this trivially; a backend author MUST NOT assume one collection per palace and design themselves into a corner. No signature changes — this is already implicit in `collection_name`; it is stated here so it is a contract fact, not a convention.
### 2.5 Concurrency
A backend instance is long-lived and serves many palaces. Backends MUST be thread-safe for concurrent `get_collection` calls across different `PalaceRef.id` values. Collection handles for the same `(palace.id, collection_name)` MAY be cached internally and returned on subsequent calls.
Backends MAY assume a single thread accesses a given `BaseCollection` instance at a time. MemPalace core serializes access per palace; backend authors are not required to make individual collections thread-safe.
### 2.6 Lifecycle
1. `__init__`: lightweight. No I/O, no network connections. A backend instance may be constructed and never used.
2. First call to `get_collection`: may open connections, create schemas, etc. All I/O is lazy.
3. `close_palace(palace)`: releases cached handles for one palace. Safe to call on a palace that was never opened.
4. `close()`: releases all resources. After `close()`, further calls MUST raise `BackendClosedError`.
There is no explicit `connect()` — it is always implicit and lazy, matching current Chroma behavior.
---
## 3. Registration and discovery
### 3.1 Entry points (primary mechanism)
Third-party backends ship as installable packages:
```toml
# pyproject.toml of mempalace-postgres
[project.entry-points."mempalace.backends"]
postgres = "mempalace_postgres:PostgresBackend"
```
MemPalace discovers backends at process start via `importlib.metadata.entry_points(group="mempalace.backends")`. No patches to the core are required.
### 3.2 In-tree registry (secondary)
For tests and local development:
```python
from mempalace.backends.registry import register
register("my-experimental-backend", MyBackend)
```
Entry-point discovery and explicit `register()` populate the same registry. Explicit registration wins on name conflict.
### 3.3 Selection priority
When resolving a palace's backend, priority (highest first):
1. Explicit `backend=` kwarg to `Palace(...)` or CLI `--backend`
2. Per-palace `backend` key in config (see §4)
3. `MEMPALACE_BACKEND` environment variable
4. Auto-detect from on-disk artifacts: `chroma.sqlite3` → `chroma`, `*.lance` → `lance`, etc. Backends declare detection hints via an optional `BaseBackend.detect(path: str) -> bool` classmethod.
5. Default: `chroma`.
**Auto-detection is strictly a migration/upgrade compatibility path, not a general selection mechanism.** It exists so existing palaces from v3.x keep opening without forced config migration. For *new* palaces, explicit configuration or CLI flag always wins — creating a palace without a resolved backend from (1)–(3) falls through to default (5), never to detection (4). Auto-detection fires only when a local path is presented AND no earlier rule has chosen a backend AND the path already contains backend-identifiable artifacts.
Note the interaction with a globally-set `MEMPALACE_BACKEND`: rule (3) sits above detection (4), so a user who exports `MEMPALACE_BACKEND=postgres` and then opens a palace containing on-disk Chroma artifacts gets postgres — the env var wins and detection is skipped. That is intended (explicit configuration overrides detection), but it means **setting `MEMPALACE_BACKEND` globally overrides existing-palace auto-detection; users opening pre-existing palaces of mixed backends should leave it unset** and rely on per-palace config or detection.
---
## 4. Configuration
### 4.1 Shape
```json
{
"backends": {
"chroma": { "type": "chroma" },
"pg_prod": {
"type": "postgres",
"dsn": "postgresql://...",
"pool_size": 10
}
},
"palaces": {
"work": {
"backend": "pg_prod",
"namespace": "work"
},
"personal": {
"backend": "chroma",
"local_path": "~/.mempalace/personal"
}
},
"embedder": {
"type": "onnx",
"model": "all-MiniLM-L6-v2"
}
}
```
Single-user local mode: all of this is optional. The absence of a config file yields one Chroma backend, one palace at the default path, with the default embedder.
### 4.2 Environment variables
- `MEMPALACE_BACKEND` — shortcut for the default backend type when there is no config.
- `MEMPALACE_<NAME>_*` — per-backend secrets and connection info (e.g., `MEMPALACE_POSTGRES_DSN`, `MEMPALACE_QDRANT_URL`, `MEMPALACE_QDRANT_API_KEY`).
- `<NAME>` is the backend's **type** name (the `type` field in §4.1 — `postgres`, `qdrant`), uppercased, not the per-instance config key. So the `pg_prod` instance in §4.1 reads `MEMPALACE_POSTGRES_*`, not `MEMPALACE_PG_PROD_*` — instances of the same type share one env namespace, and connection-specific values that differ per instance (distinct DSNs) belong in the config file's per-backend block, not in env. Hyphens in a type name are normalized to underscores for the env prefix (`my-backend` → `MEMPALACE_MY_BACKEND_*`).
- Secrets MUST be readable from env vars; config files are for structure, env vars for credentials.
### 4.3 Backend-specific options
The `options` kwarg to `get_collection` is a free-form dict. Each backend documents its accepted keys. Unknown keys MUST be ignored (forward compatibility), but the backend MAY log a warning.
### 4.4 Multi-tenancy (absorbs #697)
Per-tenant collection-name prefixing is not a backend concern. It is handled by the resolver layer above backends: `PalaceRef.namespace` carries the tenant identifier. The `collection_prefix` concept from #697 dissolves into this model.
**Isolation contract.** `PalaceRef.id` is the *required* isolation key for every backend: within a single backend instance, a record written for one `id` MUST NOT be returned, modified, or deleted by an operation issued for a different `id`. Cross-palace access is a spec violation. This is the non-negotiable blanket MUST; multi-tenant deployments may cite it as their primary partition boundary even when they do not use `namespace`.
`namespace` is *additional* partitioning. A backend that advertises `supports_namespace_isolation` (§2.1) MUST extend the same guarantee to namespaces:
> A record written under one `namespace` MUST NOT be returned, modified, or deleted by an operation issued under a different `namespace` within the same backend instance. Cross-namespace access is a spec violation, not a caller misconfiguration.
This is what hosted multi-tenant deployments cite as the basis for *namespace*-level tenant isolation. Authorization (which namespaces a given request may touch) stays on the deployment side; the backend's job is to guarantee no bleed *within* the instance once the namespace is fixed.
Making namespace isolation a declared capability rather than a blanket `MUST` is deliberate: path-rooted local backends (e.g. `chroma`, `sqlite_exact`) already isolate by `local_path` / `id`, and forcing them to re-implement a second axis would be ceremony with no security gain.
**Conformance arms.** Self-attestation is not enough. The isolation suite (`tests/_backend_conformance.py` / `assert_partition_isolation`) has two distinct arms:
1. **Cross-`id` isolation** — required of every backend. Two `PalaceRef`s that differ only in `id` MUST NOT see each other's records.
2. **Same-`id` / different-`namespace` isolation** — required of every backend that advertises `supports_namespace_isolation`. Two refs that share `id` (and, when applicable, `local_path`) but differ in `namespace` MUST NOT see each other's records. Distinct from arm 1; a backend that only passes arm 1 does not get to claim the capability.
**No silent drop.** A backend that does **not** advertise `supports_namespace_isolation` MUST NOT silently accept and ignore a populated `namespace` — that is a latent cross-namespace leak, same spirit as `UnsupportedFilterError` for unknown operators. Such backends MUST either raise (e.g. `UnsupportedCapabilityError`) when `PalaceRef.namespace` is non-`None`, or honor the namespace and advertise the capability. Callers targeting path-rooted backends MUST leave `namespace` as `None`.
---
## 5. Embedder contract (minimal, normative here)
§1.5 makes persisting and checking `embedder.model_name` a hard `MUST`. A backend cannot satisfy that against an embedder that has no identity to read, so the minimal protocol below is **normative for this spec** — not deferred. A fuller Embedder RFC (batching, async, pooling, multi-vector) is tracked separately, but it is *additive*: §1.5 conformance depends only on the three members here.
```python
class Embedder(Protocol):
model_name: str # stable identity persisted and checked per §1.5
dimension: int # validated per §1.5 dimension check
def embed(self, texts: list[str]) -> list[list[float]]: ...
```
Backends receive an `Embedder` via `get_collection(embedder=...)`. Backends with the `server_embedder` capability MAY ignore the injected embedder but MUST still expose an effective `model_name` / `dimension` (§1.5). An embedder whose `model_name` is empty or `None` is handled as the §1.5 `unknown` state, not a hard error.
> **Follow-up.** The full Embedder RFC is the only external contract §1.5 leans on; it is tracked as a hard, blocking dependency of the §1.5 *implementation* (not of this spec's acceptance — the minimal protocol above closes the contract gap). Tracking issue: see §13. The current in-tree backends defer embedder-identity enforcement until that work lands.
---
## 6. Sync (capability declaration only)
The sync subsystem is out of scope for this spec. What this spec defines:
- `supports_sync` capability flag (§2.1) — a backend advertising it agrees to implement idempotent upserts under conflict and to expose change data.
- Optional method on `BaseCollection`:
```python
def changes_since(self, cursor: SyncCursor) -> Iterator[Change]: ...
```
- Backends without `supports_change_feed` / `supports_sync` are rejected by the sync subsystem at bind time.
Local single-user deployments never load the sync subsystem; non-sync-capable backends cost them nothing.
---
## 7. Testing contract
### 7.1 The abstract suite
MemPalace ships `mempalace.backends.testing.AbstractBackendContractSuite` — a pytest mixin. Every backend package ships a concrete subclass:
```python
from mempalace.backends.testing import AbstractBackendContractSuite
class TestPostgresBackend(AbstractBackendContractSuite):
@pytest.fixture
def backend(self, tmp_path):
return PostgresBackend(dsn=os.environ["TEST_PG_DSN"])
```
The suite covers:
- Round-trip for every required method
- Empty-result shape (outer dimension preserved, inner lists empty)
- `create=False` on missing palace raises `PalaceNotFoundError`
- `create=True` is idempotent
- Full required where-clause subset including `$contains`
- Unknown operator raises `UnsupportedFilterError`
- Dimension-mismatch detection
- Unicode text and unicode IDs
- Large batch writes (10k+ items)
- Delete-then-query consistency
- `close()` releases handles and further calls raise `BackendClosedError`
- Concurrent `get_collection` across different palaces is safe
- Isolation arm 1: cross-`PalaceRef.id` (every backend)
- Isolation arm 2: same-`id` / different-`namespace` (only when `supports_namespace_isolation` is advertised; self-attestation without this arm is a conformance failure)
- Non-advertising backends raise on a populated `namespace` rather than silently accepting it (§4.4)
### 7.2 Parametrized core suite
The existing MemPalace test suite is parametrized over all registered backends when `MEMPALACE_TEST_ALL_BACKENDS=1` is set in the environment. This is the "strongest parity claim" — if a backend passes the full core suite, it is drop-in compatible. This is expensive; local development defaults to Chroma only, CI runs all backends on a scheduled job.
### 7.3 Benchmark methodology hooks
Backend-to-backend comparisons are meaningless without accounting for per-backend maintenance state. Postgres with stale planner stats behaves very differently from Postgres post-`VACUUM ANALYZE`; HNSW-based stores behave differently before and after index compaction.
Backends MAY implement `maintenance_state()` returning a structured dict describing the current state (e.g., `{"autovacuum_age_seconds": 42, "last_analyze": "...", "index_build_complete": true}`), and `run_maintenance(kind: str)` to trigger supported kinds. Both are optional.
Supported maintenance kinds MUST be advertised via a class-level frozenset:
```python
class BaseBackend(ABC):
maintenance_kinds: ClassVar[frozenset[str]] = frozenset()
```
The spec reserves the kind names `"analyze"` (update planner/query statistics), `"compact"` (reclaim space, rewrite storage), and `"reindex"` (rebuild secondary indexes). Backends MAY add their own kinds; the reserved names MUST mean what the spec says if advertised. A backend that has no analogue for a reserved kind MUST omit it from `maintenance_kinds` rather than declaring it as a no-op — otherwise a benchmark harness sees `"analyze"` advertised and assumes it did what the spec says when the implementation did nothing.
`run_maintenance(kind)` MUST raise `UnsupportedMaintenanceKindError` when called with a kind not in `maintenance_kinds`. Advertising a kind without implementing it is a conformance failure.
**`run_maintenance` is observable, not fire-and-forget.** This resolves the §12 open question, and it is driven by a production failure: on a lazy-index path, multiple daemon writers crossing the index-build threshold in the same window each issued the build, stacked an `ACCESS EXCLUSIVE` lock, and blocked writes for the whole build (fixed in-backend with a session-level advisory lock). A pure fire-and-forget call reproduces that race — if the call cannot report "already running," concurrent callers re-trigger the build. Therefore:
- `run_maintenance(kind)` MUST be safe to call concurrently. A backend MUST serialize same-kind maintenance internally (advisory lock, build flag, or equivalent) so a second caller does not start a duplicate operation.
- It returns a structured `MaintenanceResult` (e.g. `{"kind": str, "status": "ran" | "already_running" | "noop", "stats": {...}}`) rather than `None`. `stats` is free-form per kind (rows analyzed, bytes reclaimed, fragments merged). `already_running` is how a concurrent caller learns it must not re-trigger.
This makes the maintenance hook the operator-safe path the lazy-index concurrency wedge requires, and gives the benchmark harness (above) machine-readable phase data.
The benchmark harness under [benchmarks/](../../benchmarks/) records `maintenance_state()` alongside every latency/recall measurement it publishes. Published numbers MUST include three phases: immediately after bulk load, after the backend's native background maintenance has caught up, and after `run_maintenance(kind)` has been called for each kind in `maintenance_kinds`. Harnesses rely on this advertisement to decide what to call — they MUST NOT assume kind names. This prevents comparing an un-`ANALYZE`d Postgres to a settled Chroma and calling the former slow.
### 7.4 ID stability for non-string-ID backends
Backends requiring UUID IDs (Qdrant) use a canonical namespace:
```python
NAMESPACE_MEMPALACE = uuid.UUID("c06c3fc7-5c14-4dc4-84c2-24a5f72d8dc1")
backend_id = uuid.uuid5(NAMESPACE_MEMPALACE, original_id)
```
The namespace UUID is fixed at spec v1 adoption and recorded here — once, for all time. This value is the one the in-tree `qdrant` backend already shipped with in #1679, so existing Qdrant palaces' point IDs already derive from it; promoting the shipped constant to canonical (rather than minting a fresh one) avoids re-deriving any deployed palace's IDs. New UUID-ID backends MUST use this exact namespace. This resolves the #700 vs #381 divergence.
---
## 8. Migration
### 8.1 The CLI
```
mempalace migrate --palace PATH --from chroma --to postgres
mempalace migrate --all --to lance
```
Implementation is backend-agnostic: reads from source via `BaseCollection.get(include=["documents", "metadatas", "embeddings"])`, writes to target via `BaseCollection.upsert(...)` with the original embeddings. No backend-specific migration code.
### 8.2 Lossless vs re-embed
Migration is labeled **lossless** only when:
- The source advertises `supports_migration_export` (bulk read includes embeddings), AND
- The target advertises `supports_embeddings_passthrough` (persists provided embeddings as-is), AND
- Source and target agree on `embedder.model_name` (or `--force-model-swap` is explicit).
If the target lacks `supports_embeddings_passthrough`, `mempalace migrate` refuses to run. Passing `--accept-re-embed` overrides — the migration proceeds but re-embeds from document text at write time, and the migration record labels the result as re-embedded rather than lossless. Retrieval quality may shift.
A backend that persists the exact float32 vector in its own durable store (rather than re-embedding from text on read) satisfies **both** sides of the lossless pairing: it qualifies for `supports_migration_export` as a source (it can hand back the verbatim vector it stored) and for `supports_embeddings_passthrough` as a target (it persists provided vectors as-is). Such "rank-from-stored-vectors" backends are first-class migration endpoints in both directions — the pairing in this section is about the two *capabilities* being present, not about any particular index structure. An exact-vector store therefore makes `chroma → <store>` and `<store> → chroma` lossless under §8 in both directions, given model-identity agreement.
### 8.3 Safety
- Source is never modified. Migration is read-only against the source backend.
- Target palace must not already exist unless `--overwrite` is passed.
- A successful migration writes a `.mempalace-migration.json` record into the target palace containing: source backend name, source path/ref, timestamp, row count, `lossless: true|false`, source and target `embedder.model_name`, and whether `--force-model-swap` or `--accept-re-embed` was used.
### 8.4 Verification
After migration, run `mempalace verify --palace PATH --against SOURCE_PATH --source-backend chroma`. This samples N rows and confirms round-trip parity (ids match, documents match, embedding cosine similarity ≥ 0.999 when the migration was lossless; a looser document-overlap check when re-embedded).
---
## 9. Versioning and compatibility
- `BaseBackend.spec_version` declares which spec version a backend implements.
- MemPalace refuses to load a backend declaring a different major version. The failure is loud and names the mismatch, e.g.:
```python
raise BackendVersionMismatchError(
f"backend {name!r} targets spec {backend.spec_version!r}; "
f"this MemPalace implements major version {CORE_MAJOR!r}. "
f"Install a build of {name!r} that targets spec {CORE_MAJOR}.x."
)
```
- Minor versions are additive (new optional methods, new capability tokens). Backends declaring an older minor continue to work.
- This is spec v1.0.
---
## 10. Cleanup prerequisite (mostly landed via #1679)
**Status update (2026-06-07).** The bulk of this cleanup landed in [#1679](https://github.com/MemPalace/mempalace/pull/1679): the direct `chromadb` *client* imports across `repair.py`, `dedup.py`, `cli.py`, `mcp_server.py`, and `migrate.py` were routed through `BaseCollection`, and the dict-to-typed-result migration (§1.3) shipped. What remains is narrow:
- **Residual exception-class imports.** `mcp_server.py` and `repair.py` still `from chromadb.errors import NotFoundError` to catch Chroma's not-found error. These are catch-site couplings, not client construction; they should resolve to a backend-neutral `PalaceNotFoundError` / collection-not-found exception from the contract (§2.4) so non-Chroma palaces raise the same type.
- **`mcp_server._get_client()` caching.** Caches a `PersistentClient` at module scope and invalidates it on `chroma.sqlite3` inode or mtime changes (merged via [#757](https://github.com/MemPalace/mempalace/pull/757)). Both the cache and the stat-based freshness check are Chroma-specific. They should migrate into `ChromaBackend.get_collection()` (§2.5, handle caching) and `ChromaBackend.close_palace()` (§2.6, explicit flush) — other backends do not have a single on-disk SQLite file to stat. The `mempalace_reconnect` MCP tool then becomes a thin wrapper around `backend.close_palace(palace_ref)`.
**`searcher.py` is the highest-leakage module still coupled, and #1679 did not cover it** (flagged by two backend authors — kostadis-ntnx on this RFC, jphein on #1679). Two concrete couplings:
- `_hybrid_rank()` hard-codes `vec_sim = max(0, 1 - distance)`, i.e. it assumes cosine distance ∈ [0, 2]. For a backend whose metric isn't cosine (dot-product, L2) the ranking is silently wrong. Fix: make the conversion metric-aware off the backend-declared `distance_metric` (§2.1) rather than hard-coding cosine. All in-tree backends are cosine today, so this is latent — but it is a contract gap, not a coincidence to keep relying on.
- The BM25 survival fallback opens `chroma.sqlite3` directly and queries Chroma's FTS5 `embedding_fulltext_search` shadow table. On any non-Chroma palace this path is dead. Fix: route lexical fallback through `BaseCollection` (§1.4 `$contains` / a lexical capability), or gate it on a backend-declared capability so backends whose index self-heals never request it.
This `searcher.py` work is tracked as follow-up implementation (see §13) — it is the remaining piece that promotes a non-Chroma backend from "stores and retrieves" to a first-class search peer.
---
## 11. Impact on in-flight PRs
**Update (2026-06-07).** The first wave of in-tree backends — `pgvector`, `qdrant`, `sqlite_exact` — landed via [#1679](https://github.com/MemPalace/mempalace/pull/1679) implementing the §1–2 contract surface directly, ahead of this RFC's merge. That reshapes the table below: the community PRs now rebase against both the spec *and* the shipped in-tree backends, and the prediction that `collection_prefix` would dissolve into `PalaceRef.namespace` (#697) held.
| PR | Status | Effort to align |
|---|---|---|
| [#574](https://github.com/MemPalace/mempalace/pull/574) LanceDB | Open | Closest to final shape. Needs `PalaceRef` and typed results (now both shipped in-tree — rebase against the merged ABC). |
| [#665](https://github.com/MemPalace/mempalace/pull/665) Postgres (`pg_sorted_heap`) | Open | The in-tree `pgvector` backend (#1679) now occupies the basic Postgres slot; #665 rebases as the optional `pg_sorted_heap` performance variant. Decouple embedder; adopt the merged `PalaceRef`. Its session-level advisory-lock fix for the lazy-index concurrency wedge is the production basis for §7.3's observable `run_maintenance`. |
| [#700](https://github.com/MemPalace/mempalace/pull/700) Qdrant | Open | Largely superseded by the in-tree `qdrant` backend (#1679), which already uses the §7.4 canonical namespace. Reconcile or close in favor of the merged backend. |
| [#381](https://github.com/MemPalace/mempalace/pull/381) Qdrant (older) | Open | Same as #700; subclass the merged `BaseCollection` rather than a bare `Protocol`, or close as superseded. |
| [#643](https://github.com/MemPalace/mempalace/pull/643) PalaceStore | Closed | POC; the parametrized-test approach it explored became the standard (§7.2). |
| [#697](https://github.com/MemPalace/mempalace/pull/697) Chroma HttpClient + prefix | Closed | `collection_prefix` dissolved into `PalaceRef.namespace` + `supports_namespace_isolation` (§4.4), as predicted. |
---
## 12. Resolved decisions
The three questions that were open at draft are resolved for v1, informed by the backend-author reviews:
- **`changes_since` accepts a collection filter — yes.** It takes an optional `collection_name` so a sync caller can request changes for one collection (LanceDB and others already track changes per table; filtering by collection is natural). Additive on the §6 optional hook.
- **Per-palace capabilities — no; capabilities stay static/class-level.** Making `supports_*` palace-dependent turns every capability check into "maybe — depends," pushing state tracking onto every caller. A backend that builds indexes lazily either guarantees the index exists when the capability is needed (and advertises it) or doesn't advertise it. The one isolation-by-deployment case (`supports_contains_fast` where an FTS index may or may not exist) is handled by the backend either guaranteeing the floor or omitting the token — not by per-palace variance. (Note: `supports_namespace_isolation` is still a static, class-level token — it declares what the backend *enforces*, not a per-palace fact.)
- **`run_maintenance(kind)` returns a structured, observable result — resolved in §7.3.** It is not fire-and-forget: it returns `MaintenanceResult` and must serialize concurrent same-kind calls (the production lazy-index concurrency wedge is why). See §7.3.
No remaining blockers. Genuinely additive future items (new optional methods, new tokens) land under the minor-version rule (§9).
---
## 13. Rollout
The original draft sequenced cleanup → spec → ChromaBackend → in-flight rebase → migrate CLI. In practice the contract surface and the first backends shipped together in [#1679](https://github.com/MemPalace/mempalace/pull/1679) ahead of this merge, so the remaining sequence is what's left, not the whole list.
**Done (via #1679):**
1. ✅ §10 seam cleanup (client imports routed through `BaseCollection`; typed-result migration §1.3).
2. ✅ `BaseBackend` / `BaseCollection`, `PalaceRef`, capability tokens, entry-point/registry discovery.
3. ✅ In-tree `pgvector`, `qdrant`, `sqlite_exact` backends + `tests/test_backend_conformance.py`.
4. ✅ Canonical §7.4 namespace pinned to the shipped value.
**Remaining (tracked as follow-up issues):**
5. **Embedder-identity contract (§1.5 / §5)** — [#1724](https://github.com/MemPalace/mempalace/issues/1724). Land the minimal `Embedder` protocol as a real injected dependency; persist + check `model_name` per the three-state model. *Hard dependency for §1.5 conformance.*
6. **Maintenance hooks (§7.3)** — [#1725](https://github.com/MemPalace/mempalace/issues/1725). Implement `maintenance_kinds` / `maintenance_state()` / observable `run_maintenance()` in the in-tree backends; wire the §7.3 advisory-lock serialization (`pg_sorted_heap` / #665 has the reference fix).
7. **`searcher.py` metric-awareness + lexical fallback (§10)** — [#1726](https://github.com/MemPalace/mempalace/issues/1726). Make `_hybrid_rank` read `distance_metric`; route the BM25 fallback through `BaseCollection` instead of `chroma.sqlite3`.
8. Residual §10 items: backend-neutral not-found exception; migrate `mcp_server._get_client()` caching into `ChromaBackend`.
9. Finish the `mempalace migrate` CLI (§8) and the parametrized core suite gate (§7.2, `MEMPALACE_TEST_ALL_BACKENDS=1` in CI).
10. Rebase / reconcile the remaining community backend PRs (§11) against the merged contract.
11. Update [ROADMAP.md](../../ROADMAP.md) with spec v1.0 adoption under v4.0.0-alpha.
@@ -0,0 +1,505 @@
# RFC 003: Agent Logstream Coordination
Status: Implemented (phases 1-5). Phase 5 SSE shipped as GET /logstream/stream on the hub HTTP transport (bearer-authenticated, event_list filter set, since_event_id/Last-Event-ID resume, 15s heartbeats, bounded clients); logstream tools additionally dispatch outside the global HTTP request lock so long-polls cannot starve the hub. Server-side cursor storage and drawer compaction remain future work.
Owner: Claude Fable 5
Created: 2026-07-01
Branch: `feat/shared-brain-dogfood`
## Summary
MemPalace should become a local-first coordination substrate for agents, not only a long-term recall system. Agents running on different machines should be able to delegate work, wait for replies, exchange patches or other artifacts, and subscribe to realtime coordination messages through the shared MemPalace hub.
This RFC proposes **MemPalace Logstream**: a small append-only event layer served by the existing MemPalace MCP HTTP hub. It preserves the core MemPalace promise of durable, local, exact storage while adding coordination primitives agents can use without Igor manually relaying messages between them.
## Motivation
The shared-brain dogfood exposed a real workflow gap:
1. Mac Codex wrote a delegation packet into `shared_agent_brain/delegation`.
2. Windows Codex found it, did the work, and wrote back status.
3. Windows Codex forgot to push the code.
4. Mac Codex could read the status but could not receive the actual patch through a formal channel.
The memory layer worked. The coordination layer was implicit and fragile.
We need a first-class way for agents to:
- create durable work packets;
- subscribe or wait for matching replies;
- submit patch/file artifacts before they are committed;
- acknowledge, apply, reject, or supersede handoffs;
- keep enough structured metadata to avoid ambiguity across machines, branches, and worktrees.
## Non-Goals
- Do not build a cloud service.
- Do not require external APIs, hosted queues, Redis, Postgres, or SaaS infra.
- Do not replace Git for final source control.
- Do not make agents apply patches silently without explicit caller intent.
- Do not weaken verbatim drawer storage. Event payloads and artifacts must remain exact.
- Do not add telemetry or phone-home behavior.
## Design Principles
- **Local-first**: the event bus runs inside the existing MemPalace hub and is reachable over loopback, LAN, or tailnet according to the user's deployment.
- **Append-only by default**: events are immutable. Corrections are new events that reference prior events.
- **Exact payloads**: event bodies and artifacts are stored verbatim.
- **Structured envelopes**: agents should not infer core routing fields from prose.
- **Durable before realtime**: every realtime message must also be recoverable after reconnect.
- **Small useful v1**: favor long-poll and polling over a large streaming subsystem if that ships faster.
- **No hidden authority**: patch application and destructive actions must be explicit local operations.
## Core Concepts
### Event
An event is a structured coordination message. It has routing metadata plus an optional verbatim body.
Required fields:
- `id`: stable event id, generated by the server.
- `type`: event type, such as `task.request`, `task.reply`, `patch.ready`, `patch.applied`.
- `stream`: logical stream name. Suggested format: `project/<project-name>` or `shared_agent_brain`.
- `room`: sub-channel inside a stream, such as `delegation`, `patches`, `reviews`, `status`.
- `from_agent`: writer identity, supplied by caller.
- `created_at`: server timestamp.
Common optional fields:
- `to_agent`: target agent or `*`.
- `correlation_id`: task or conversation id tying request/reply events together.
- `branch`: Git branch, when relevant.
- `base_commit`: Git commit the work started from.
- `status`: `open`, `claimed`, `ready`, `applied`, `blocked`, `failed`, `superseded`.
- `artifact_ids`: patch/file artifact references.
- `body`: verbatim human-readable content.
### Artifact
An artifact is exact content attached to an event. Examples:
- unified diff patches;
- generated files;
- test logs;
- benchmark reports;
- screenshots encoded by reference, not necessarily inline;
- structured JSON payloads.
Artifacts must have:
- `id`;
- `kind`: `patch`, `file`, `log`, `json`, `note`;
- `sha256`;
- `size_bytes`;
- `content`;
- `created_at`;
- `created_by`.
### Cursor
Each agent may maintain a cursor per stream so it can ask, "what happened since I last checked?"
Cursor state can be server-side later. For v1, it is acceptable for the client to pass `since_event_id` or `since_created_at`.
## Event Envelope
Example event:
```json
{
"id": "evt_01J...",
"type": "patch.ready",
"stream": "project/mempalace",
"room": "patches",
"from_agent": "windows-codex",
"to_agent": "mac-codex",
"correlation_id": "task_01J...",
"branch": "feat/shared-brain-dogfood",
"base_commit": "2668053",
"status": "ready",
"artifact_ids": ["art_01J..."],
"body": "Search ranking patch is ready. Tests passed on Windows.",
"created_at": "2026-07-01T20:00:00Z"
}
```
Example patch artifact:
```json
{
"id": "art_01J...",
"kind": "patch",
"sha256": "abc123...",
"size_bytes": 18422,
"created_by": "windows-codex",
"created_at": "2026-07-01T20:00:00Z",
"content": "diff --git a/mempalace/searcher.py b/mempalace/searcher.py\n..."
}
```
## Storage Model
Use SQLite in the active palace directory:
- `logstream.sqlite3`
Tables:
```sql
CREATE TABLE events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
stream TEXT NOT NULL,
room TEXT NOT NULL,
from_agent TEXT NOT NULL,
to_agent TEXT,
correlation_id TEXT,
branch TEXT,
base_commit TEXT,
status TEXT,
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX events_stream_created_idx ON events(stream, created_at);
CREATE INDEX events_correlation_idx ON events(correlation_id, created_at);
CREATE INDEX events_to_agent_idx ON events(to_agent, created_at);
CREATE INDEX events_type_idx ON events(type, created_at);
CREATE TABLE artifacts (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
sha256 TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
content TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX artifacts_sha256_idx ON artifacts(sha256);
CREATE TABLE event_artifacts (
event_id TEXT NOT NULL,
artifact_id TEXT NOT NULL,
PRIMARY KEY (event_id, artifact_id)
);
```
Rationale:
- Keep coordination separate from Chroma/vector search for reliability and latency.
- Avoid forcing realtime coordination through semantic search.
- Make append/list/wait cheap and deterministic.
- Optionally mirror summaries into normal drawers later for recall, but do not depend on it.
## MCP Tool Surface
### `mempalace_event_append`
Append one event.
Input:
```json
{
"type": "task.request",
"stream": "project/mempalace",
"room": "delegation",
"from_agent": "mac-codex",
"to_agent": "windows-codex",
"correlation_id": "task_...",
"branch": "feat/shared-brain-dogfood",
"base_commit": "2668053",
"status": "open",
"body": "Please fix search echo ranking.",
"metadata": {}
}
```
Returns:
```json
{
"success": true,
"event": { "...": "..." }
}
```
### `mempalace_event_list`
List events with structured filters.
Filters:
- `stream`
- `room`
- `type`
- `to_agent`
- `from_agent`
- `correlation_id`
- `since_event_id`
- `since_created_at`
- `limit`
Default limit: 50.
### `mempalace_event_wait`
Block until a matching event exists or timeout expires.
Input:
```json
{
"stream": "project/mempalace",
"to_agent": "mac-codex",
"correlation_id": "task_...",
"type": "patch.ready",
"since_event_id": "evt_...",
"timeout_ms": 60000
}
```
Implementation for v1:
- simple polling loop inside the MCP request;
- sleep interval 250-1000 ms with jitter;
- max timeout 5 minutes;
- return `{ "timed_out": true, "events": [] }` rather than error on timeout.
`mempalace_event_wait` remains the polling MCP tool. Clients that can hold an
HTTP stream should use `GET /logstream/stream` SSE for live tailing.
### `mempalace_event_ack`
Append an acknowledgement event.
Input:
```json
{
"event_id": "evt_...",
"from_agent": "mac-codex",
"status": "applied",
"body": "Patch applied and tests passed."
}
```
This should create a new `event.ack` event with `correlation_id` copied from the target event.
### `mempalace_artifact_put`
Store exact artifact content.
Input:
```json
{
"kind": "patch",
"created_by": "windows-codex",
"content": "diff --git ...",
"metadata": {
"branch": "feat/shared-brain-dogfood",
"base_commit": "2668053"
}
}
```
Returns `id`, `sha256`, `size_bytes`.
### `mempalace_artifact_get`
Fetch an artifact by id. Returns exact content and metadata.
### `mempalace_patch_submit`
Convenience wrapper:
1. store patch artifact;
2. append `patch.ready` event referencing the artifact;
3. return both ids.
This is optional for v1 if `artifact_put` + `event_append` are enough.
## HTTP Endpoint Surface
MCP tools are sufficient for v1. Add HTTP streaming later.
Future endpoints:
- `GET /logstream/events?stream=...&since=...`
- `GET /logstream/stream?stream=...` using Server-Sent Events.
Do not add unauthenticated logstream endpoints. They expose work metadata and patch contents.
## Agent Workflow Examples
### Delegation With Wait
Mac Codex:
1. `mempalace_event_append(type=task.request, to_agent=windows-codex, correlation_id=task_123)`
2. `mempalace_event_wait(correlation_id=task_123, type=patch.ready, to_agent=mac-codex, timeout_ms=300000)`
3. `mempalace_artifact_get(artifact_id)`
4. Apply patch locally.
5. Run tests.
6. `mempalace_event_ack(status=applied)`.
Windows Codex:
1. `mempalace_event_wait(to_agent=windows-codex, type=task.request, timeout_ms=300000)`
2. Do work.
3. `mempalace_patch_submit(type=patch.ready, to_agent=mac-codex, correlation_id=task_123)`.
### Status-Only Handoff
If an agent cannot produce a patch, it can still append `task.reply` or `task.blocked` with exact notes and evidence.
## Implementation Plan
### Phase 1: Durable Logstream Core
Add:
- `mempalace/logstream.py`
- SQLite schema creation/migration inside the active palace dir.
- Pure Python API:
- `append_event(...)`
- `list_events(...)`
- `wait_events(...)`
- `put_artifact(...)`
- `get_artifact(...)`
- `ack_event(...)`
Requirements:
- no Chroma dependency;
- no vector index open;
- safe under concurrent HTTP requests;
- append-only events;
- validate/sanitize string fields with existing config helpers where appropriate;
- cap body/artifact sizes with explicit errors.
Suggested limits:
- event body: 256 KiB default;
- artifact content: 4 MiB default;
- wait timeout: 300 seconds max.
### Phase 2: MCP Tools
Add tools in `mempalace/mcp_server.py`:
- `mempalace_event_append`
- `mempalace_event_list`
- `mempalace_event_wait`
- `mempalace_event_ack`
- `mempalace_artifact_put`
- `mempalace_artifact_get`
- optionally `mempalace_patch_submit`
Read-only mode:
- `event_list`, `event_wait`, and `artifact_get` are allowed.
- `event_append`, `event_ack`, `artifact_put`, and `patch_submit` are mutating tools and must be hidden/refused in read-only mode.
SQLite integrity gate:
- Logstream should not depend on Chroma SQLite integrity.
- However, if the active palace path is unsafe/missing, fail clearly.
### Phase 3: CLI Utilities
Add:
- `mempalace logstream append`
- `mempalace logstream list`
- `mempalace logstream wait`
- `mempalace artifact put`
- `mempalace artifact get`
Keep CLI output scriptable with `--json`.
### Phase 4: Docs and Skills
Docs:
- `website/concepts/agent-logstream.md`
- `website/reference/mcp-tools.md` entries.
- Update `website/guide/remote-server.md` to mention agent coordination over the hub.
Agent skills/prompts:
- Update MemPalace recall/search skills to mention logstream for active coordination.
- Add a brief "when delegating work, use event_append/event_wait" protocol.
### Phase 5: Realtime Stream
Only after Phase 1-4 are solid:
- add SSE endpoint;
- per-agent cursor helpers;
- maybe background compaction of old event payloads into normal drawers for long-term recall.
## Test Plan
Unit tests:
- schema initializes in empty palace dir;
- append/list round trip;
- filters by stream, room, type, to_agent, correlation_id;
- wait returns immediately when event already exists;
- wait times out cleanly;
- artifact put/get preserves exact bytes/text;
- artifact hash is stable;
- ack creates new event and does not mutate target event;
- read-only mode hides/refuses mutating tools;
- size limits reject oversized event bodies/artifacts.
Integration tests:
- two threads: one waits, one appends, waiter returns matching event;
- patch submit stores artifact and event atomically enough that readers never see a dangling artifact id;
- MCP HTTP server can append from one client and wait/list from another;
- no auth headers/request bodies appear in `/statusz`.
Dogfood test:
1. Mac agent creates task request for Windows.
2. Windows agent waits for it.
3. Windows agent submits patch artifact.
4. Mac agent waits, gets patch, verifies hash, applies patch manually, runs tests.
5. Mac agent acknowledges `applied`.
## Open Questions
- Should logstream events also be mirrored into Chroma drawers for semantic recall, or should structured search remain the primary access path?
- Should artifact content support binary data in v1, or should v1 be UTF-8 text only?
- Should agent identities be free-form strings initially, or tied to configured local agent profiles?
- Should `event_wait` be allowed over stdio MCP clients that may have shorter host timeouts?
- Should events have retention/archival controls, or stay forever by default like memory?
## Claude Fable 5 Instructions
Please implement Phase 1 and Phase 2 first. Keep the patch narrow and avoid unrelated refactors.
Start by creating `mempalace/logstream.py` with the durable SQLite core and focused tests. Then expose MCP tools in `mempalace/mcp_server.py` with read-only and mutating-tool semantics. Only add CLI/docs after the core and MCP tools are green.
Before finishing:
- run focused logstream tests;
- run relevant MCP server tests;
- run `ruff check` on touched Python files;
- write a MemPalace delegation reply with the implementation status, changed files, and exact verification commands.
## Implementation Notes
Windows dogfood verification by `windows-codex` on 2026-07-02:
- Branch/base: `feat/shared-brain-dogfood` at `1ff3125`.
- OS: Microsoft Windows 11 Pro Insider Preview 10.0.29576 build 29576 64-bit.
- Python: 3.12.11 via `uv run python`.
- Command: `uv run pytest tests/test_logstream.py tests/test_mcp_logstream.py tests/test_cli_logstream.py -q`.
- Result: 72 passed in 2.93s.
+630
View File
@@ -0,0 +1,630 @@
# RFC 004: The Replicated Palace
Status: Draft complete — all sections drafted (storage: mac-claude; transport/lifecycle/appendix: windows-claude); awaiting Igor's review
Shipped so far: step 0 (logstream multi-master replication) and step 1 (memory read replicas — snapshot pull, local fold, distributed embedding), plus the transport seam and the estate endpoint. Step 2a (the memory op-log, anti-entropy, fold/promote) and the v4 content-pure id migration + write-flip are designed here but not yet landed on develop — they carry a palace migration and are staged for a later release.
Owners: mac-claude (storage layers, §6–§9), windows-claude (transport & lifecycle, §5 and Appendix A), decided by Igor
Created: 2026-07-02
Branch: `feat/shared-brain-dogfood`
Prior art: RFC 003 (logstream), the `rfc004_replicated_palace_position` correlation thread (position debate, verbatim in the logstream)
## Summary
Each human has ONE palace — an extension of their brain — replicated in full
across every machine they own. Agents always talk to the MemPalace service on
`127.0.0.1`; services converge with each other over an encrypted mesh. The
hub stops being a dependency and becomes a role (rendezvous, and the home of
*shared* palaces). The design is judged against offline operation as the
default posture, not as an edge case.
One sentence: **N equal replicas of the facts, each with locally-derived
indexes and a local writer, converging through provenance-stamped ops over
the mesh — with origin as the home-of-record for source-bound maintenance.**
## Motivation
The shared-brain dogfood proved the hub topology works — and watched it fail:
when the hub machine slept, every other machine lost recall, capture, and
coordination simultaneously. A brain does not stop remembering because
another brain is asleep. Concretely, today a remote machine without the hub
has **no palace at all**.
The mission statement ("memory is identity") implies the requirement
directly: you do not rent your identity, and you do not park it on a single
machine.
## Requirements
The availability invariant, and the offline requirements distilled from the
fleet (logstream correlation `rfc004_replicated_palace_position`):
- **R0 — Mission invariants hold everywhere**: verbatim always, local-first,
zero external API for core operations, hooks < 500 ms, startup injection
< 100 ms. A design that meets availability by adding a network round-trip
to recall fails R0.
- **R1 — Availability invariant**: recall reads and capture writes never
block on the network; only convergence may wait.
- **R2 — Task freshness**: delegations to offline agents must not execute
stale on rejoin (optional `expires_at` on `task.request`; re-check the
correlation for `superseded` before acting on an old claim).
- **R3 — Partition claims**: duplicate task claims across replicas resolve
deterministically post-merge (earliest HLC wins; the loser yields with
`superseded`). Append-only makes double work safe-but-wasteful, never
corrupting.
- **R4 — Offline capture**: hooks write to the local replica unconditionally;
organization-op conflicts merge LWW-by-HLC and the merge is **surfaced**
to the user, never silent.
- **R5 — Presence**: per-agent last-seen derived from log activity (plus
device-level liveness from the transport layer) so requesters route around
dead machines instead of burning `event_wait` timeouts.
- **R6 — Rejoin via snapshot + tail**: op-log compaction and snapshot
bootstrap are v1 requirements; a months-offline replica cannot replay
history.
- **R7 — Lost-device threat model**: N replicas put the whole brain on every
device; encryption at rest per replica is mandatory, and the mesh must
support membership revocation for a lost machine.
- **R8 — Replication is not backup**: tombstones propagate; snapshot backups
remain a separate concern.
## Non-Goals
- **Replacing the hub for shared palaces.** Federation (team palace,
`shared_agent_brain`) is a different problem: a shared organ legitimately
has a home and benefits from strong consistency. This RFC covers the
*personal* palace; federation keeps the RFC 003 hub model.
- **Cloud as a system of record** — constitutionally excluded. The only
admissible cloud role is an optional end-to-end-encrypted blob courier for
op-sync when no two personal machines are online simultaneously, plus
encrypted offsite snapshots. Zero knowledge; never queryable server-side.
- **Thin/partial replicas (phone-class devices) in v1.** They remain remote
clients of a nearby full replica; partial replication is future work.
- **Multi-user merge and authorization semantics.** One human, N devices.
Trust boundary = palace boundary. Mesh membership is full-replica trust,
not family/team scoped read or write permission.
## Architecture Overview
```
Machine A (mac) Machine B (windows) Machine C (laptop)
┌─────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ agents → 127.0.0.1 │ │ agents → 127.0.0.1 │ │ agents → local │
│ ┌─────────────────┐ │ ops │ ┌─────────────────┐ │ ops │ ┌──────────────┐ │
│ │ mempalace svc │◀┼───────▶│ │ mempalace svc │◀┼─────▶│ │ mempalace svc│ │
│ │ op-log (SoT) │ │ mesh │ │ op-log (SoT) │ │ mesh │ │ op-log (SoT)│ │
│ │ derived index │ │ │ │ derived index │ │ │ │ derived index│ │
│ └─────────────────┘ │ │ └─────────────────┘ │ │ └──────────────┘ │
└─────────────────────┘ └─────────────────────┘ └──────────────────┘
encrypted mesh transport (Layer 1) · anti-entropy op sync (Layer 2)
```
Three layers, separable by design:
1. **Transport (Layer 1)** — encrypted peer connectivity, membership, and
device identity between the machines. Owner: windows-claude (§5).
2. **Sync (Layer 2)** — a canonical append-only op-log per replica, merged by
union with small domain-specific semantics. Owner: mac-claude (§6).
3. **Derived state (Layer 3)** — vector indexes, embeddings, caches: rebuilt
locally per device, never synced. Owner: mac-claude (§7).
The decisive property: **sync the facts, derive the senses.** Ops are
kilobytes; HNSW graphs are gigabytes. Every machine remembers everything;
each machine senses with its own hardware.
## Alternatives Considered
| Dimension | Replicated mesh | Self-hosted central server | Cloud backend |
|---|---|---|---|
| Offline / partition | Full function, converge later | Dead when server/link down | Dead without internet |
| Recall latency | Local, sub-ms–ms | LAN ms / tailnet 10–30 ms | 50–150 ms+ |
| Privacy | Never leaves your devices | Your hardware, one exposed box | Provider sees plaintext or E2EE cripples search |
| Durability | N live replicas (+R8 backups) | One box | Best-in-class |
| Ops burden on user | ~zero if software earns it | Forever (patching, TLS, backups) | ~zero |
| Engineering complexity | High, paid once by us | ~zero (exists today) | Zero for user, trust cost |
| Consistency | Eventual + merge semantics | Strong, trivially | Strong |
| Teams / sharing | Wrong tool | Natural | Natural |
| Exit / lock-in | SQLite files you hold | Files you hold | Provider's mercy |
Verdicts: the mesh is the only option satisfying R0+R1 for the personal
palace (a cloud round-trip spends the entire hook budget on network; central
fails the availability invariant we watched fail in production). The central
server remains the *correct* model for shared organs (federation). Cloud is
admissible only as the E2EE courier of the Non-Goals section.
## §5. Layer 1: Transport
Layer 1 exists so Layer 2 never has to think about networks, keys, or which
machines are awake. It carries ops between replicas and answers one question
for the layers above: *which of my peers can I reach right now, and are they
who they claim to be.* Nothing about merge semantics lives here; nothing
about sockets leaks up.
### 5.1 The seam (what Layer 2 is allowed to assume)
Layer 2 sees peers only through this interface — the entire contract:
```
Transport {
self(): ReplicaId // stable, = mesh node identity
peers(): Map<ReplicaId, Presence> // membership + liveness snapshot
onPresenceChange(cb) // SWIM/heartbeat deltas → R5
request(peer, path, body): Response // one authenticated round-trip
openStream(peer, path): EventStream // long-lived, resumable by cursor
onInbound(path, handler) // serve anti-entropy pulls
}
```
Two channel shapes cover every need: **request** for anti-entropy pulls
(`GET /sync/ops?origin=X&after=N`, §6.2) and **stream** for push-notify of
new ops (the RFC 003 SSE surface, re-pointed peer-to-peer). Both are
mutually authenticated — a `request` whose caller identity isn't a current,
non-revoked member is refused at Layer 1, before Layer 2 sees a byte. That
refusal is the replication ACL (R7) and it is the *only* authorization Layer
2 relies on: an op that arrived is an op from a trusted replica.
This is deliberately a v1 device-trust boundary, not a user/role permission
model. Admitting a replica means admitting it to the entire personal palace,
verbatim content included. Delegated, family, team, or other partial-access
workflows need a separate palace-level capability layer; they must not be
modeled as "just add this device to the replication mesh."
`ReplicaId` is the transport's node identity — no separate replica registry
to drift. Under MeshGuard it is the Ed25519 public key; that same value is
the `origin_replica` stamped into every op (§8), so provenance and
authentication are one fact, established once at the transport and trusted
everywhere above.
### 5.2 Peer addressing & rendezvous
Replicas are named by identity, never by address — a laptop's IP changes
between café and home; its `ReplicaId` does not. Address discovery is the
transport's job:
- **MeshGuard / Tailscale**: the mesh's own coordination plane maps identity
→ current endpoint(s); we never hardcode IPs. A replica "moves" and peers
re-resolve transparently.
- **Bare LAN** (no mesh): mDNS/`_mempalace._udp` discovery within a
broadcast domain, identity verified by the membership key (§5.5) — an
address hint is never a trust grant.
- **No global directory.** Rendezvous is peer-to-peer within the mesh
membership set. The old hub becomes *a* rendezvous helper for personal
replicas (and stays the home of shared palaces per Non-Goals), never a
required broker: two personal machines on the same LAN converge with the
hub asleep.
### 5.3 NAT traversal posture
Convergence must survive both machines being behind NAT on different
networks (R1 across the internet, not just the LAN):
1. **Direct** when a route exists (same LAN, or one side reachable).
2. **Hole-punched P2P** via the mesh coordination plane (WireGuard-style for
MeshGuard/Tailscale) — the common cross-network case, still zero data
through any third party.
3. **Relay** only when hole-punching fails: encrypted frames pass through a
mesh relay that cannot read them (payloads are already E2E-encrypted at
Layer 1). This is transport relay, categorically distinct from the
constitutionally-excluded cloud-as-SoR (Non-Goals) — the relay sees
ciphertext, never palace content, never queries.
4. **Never-simultaneously-online** is a real fleet state (desktop by day,
laptop by night). Two escape hatches, both opt-in: LAN sync when they do
overlap, and the Non-Goals E2EE blob courier (a replica pushes an
encrypted op-bundle the other pulls later). The courier is a §5 consumer
of the same op ranges, not a new path.
### 5.4 Connection lifecycle
- **Discover → authenticate → sync-on-connect → tail.** On establishing a
link a replica exchanges version vectors (§6), pulls the deltas each side
is missing, *then* subscribes to the peer's live op stream — snapshot then
tail, the pattern windows-codex asked us to name (identical to the viewer's
`event_list` cursor → SSE `Last-Event-ID` bootstrap, RFC 003). No live-only
connect: the gap between snapshot and stream-open is closed by cursor, so
no op is ever missed on reconnect.
- **Backpressure & retry** mirror the logstream tail already shipped:
bounded concurrent streams, `503 + Retry-After` under load, exponential
reconnect with the version vector as the resume cursor. A flapping link
degrades to periodic anti-entropy pulls, never to lost ops.
- **Idempotent by construction.** Re-delivering an op is a no-op (union
merge, §6.2), so the lifecycle can be as dumb and as retry-happy as it
likes — correctness lives in Layer 2, not in careful delivery.
### 5.5 Replica join / leave / revoke ceremony
Membership *is* trust; there is no other authorization in the system.
- **Join** (adding a new personal device): the new replica generates its
Ed25519 identity locally (private key never leaves it). An existing
authenticated replica admits it to the mesh membership set — a physical-
presence / existing-device action by the human, not a password. First sync
is a full snapshot + tail (§6, R6). Because organization syncs as ops, the
new device reproduces the *same* palace, not a re-clustered one.
- **Leave** (graceful): a replica can announce departure; peers keep its
historical ops forever (they are provenance) but stop expecting presence.
- **Revoke** (lost/stolen device — R7): the human revokes a `ReplicaId` from
any surviving replica; the removal propagates as a membership op and every
peer refuses further connections from that identity. Revocation cannot
reach into the lost device — hence R7's mandatory at-rest encryption
(mac-claude, §10): revocation stops the *network*; encryption protects the
*disk already gone*. Ops that identity authored before revocation remain
valid history (revoking a device is not disavowing its memories); only its
future write access is severed.
### 5.6 Key custody
- **Device key**: Ed25519 private key per replica, generated on-device,
non-exportable, OS keystore where available (Keychain / DPAPI / kernel
keyring). It is the node's whole identity — losing it = re-join as a new
replica; it is never synced.
- **Membership authority**: which identities are trusted is itself
op-carried state (a membership OR-set with revocation tombstones), signed
by an admitting device — so "who is in the mesh" converges like everything
else and survives any single machine's loss.
- **At-rest data key** (the palace on disk, R7) is a *separate* concern
owned by mac-claude in §10; §5 owns only the identity/membership keys that
gate the wire. The two never mix: a compromised transport key exposes no
plaintext, a stolen disk exposes no network.
### 5.7 Transport fallback matrix
Per-link, best available wins; the seam (§5.1) makes the choice invisible
to Layer 2.
| Situation | Transport | Notes |
|---|---|---|
| Same LAN, mesh up | MeshGuard direct | Lowest latency; hub not involved |
| Cross-network, both online | MeshGuard hole-punched (Tailscale bridge if MeshGuard not yet integrated) | E2E, P2P |
| Hole-punch fails | Encrypted mesh relay | Ciphertext only; distinct from cloud-SoR |
| Mesh unavailable, same LAN | Bare LAN + mDNS, membership-key auth | Degraded discovery, full trust |
| Never simultaneously online | E2EE blob courier (Non-Goals) | Async op-bundle exchange |
| MeshGuard pre-integration | Tailscale (today's dogfood transport) | Ship Sequencing step 0–1 before MeshGuard lands |
MeshGuard is the target; **Tailscale is the shipping fallback that unblocks
Sequencing steps 0–1 today** (it already carries this very logstream). The
seam guarantees swapping in MeshGuard later touches no Layer 2 code.
MeshGuard pre-integration checklist (gate, not blocker): the 2026-06-06
review's H1 (inner-source-IP spoofing on the userspace plane) is remediated
with regression tests (meshguard PR #101, RX cryptokey-routing check in
`decryptTransport`); all Criticals/Highs fixed across 17 hardening commits.
Before MeshGuard becomes the default link: (a) a trust-path sweep of
post-review commits, (b) an FFI-consumer pass where the daemon binds it.
Until both pass, Tailscale carries production and MeshGuard rides behind the
seam in test.
### 5.8 Failure detection → presence (R5)
Presence has two sources; the transport fuses them so the layers above ask
one question:
- **Device liveness** (Layer 1): SWIM-style failure detection from the mesh
(MeshGuard membership; heartbeat pings on bare LAN). Answers "is the
machine reachable *now*," sub-second, and drives reconnect.
- **Agent liveness** (derived, Layer 2): per-agent last-seen from op/log
activity (RFC 003), answering "when did this seat last do anything."
`Presence = { reachable: bool (device), lastSeen: hlc (agent), replicaId }`.
Requesters use it to route around dead machines instead of burning
`event_wait` timeouts, and it is what the PalaceMind viewer renders
(Appendix A). This is also the substrate for **R2 (task freshness)** and
**R3 (partition claims)** on the wire:
- **R2**: a `task.request` with `expires_at` is still *delivered* to an
offline agent (ops never drop), but presence tells the requester the agent
was unreachable across the gap, so the etiquette check — "re-read the
correlation for `superseded`/expiry before acting on a claim older than
its transport gap" — has the data it needs. Layer 1 supplies the gap;
Layer 2 supplies the rule.
- **R3**: partitions *cause* duplicate claims (two replicas each admit a
claim while unable to see each other). Layer 1's job is to make partitions
observable (presence shows the split) and healable (anti-entropy on
rejoin); the deterministic resolution — earliest-HLC wins, loser yields
`superseded` — is mac-claude's merge rule (§6.2). Presence makes the
window small; the merge rule makes the outcome safe.
## §6. Layer 2: The Canonical Op-Log
### 6.1 The op envelope
Every mutation of the palace becomes an immutable op:
```json
{
"op_id": "op_<origin>_<counter>",
"origin_replica": "<transport identity of the writing machine>",
"author_agent": "mac-claude",
"hlc": "0189f3a2-0007-mac",
"authored_at": "2026-07-02T21:14:09Z",
"kind": "drawer.add | drawer.revise | drawer.tombstone | org.file | org.move | org.tunnel.add | org.tunnel.remove | kg.assert | kg.close | kg.entity.upsert | registry.entity.upsert | event.append | artifact.put | ...",
"payload": { "...kind-specific, verbatim content inline or by sha256..." }
}
```
- `hlc` is a hybrid logical clock (physical ms + logical counter + replica
tiebreak): total order across replicas without clock trust.
- Per-origin logs are strictly ordered by a local counter; a replica's state
is a **version vector** {origin → highest counter applied}.
- Storage: `oplog.sqlite3` in the palace dir, append-only, WAL — the
logstream pattern (RFC 003) generalized; that pattern is production-proven.
### 6.2 Merge semantics (complete list — nothing else exists)
| State | Op kinds | Merge rule |
|---|---|---|
| Drawer content | add / revise / tombstone | Grow-only set of content-addressed revisions; head = latest by HLC; tombstone hides, never deletes (verbatim survives) |
| Artifacts | artifact.put | True G-set, union by sha256 — conflicts impossible |
| Organization | org.file / org.move / org.tunnel.* | LWW-by-HLC register per drawer (placement) / OR-set (tunnels); merges surfaced to user (R4) |
| Knowledge graph | kg.assert / kg.close / kg.entity.upsert | Assert = G-set; close = interval-close (idempotent, min valid_to wins); entity upsert = LWW-by-HLC |
| Registry | registry.entity.upsert | LWW-by-HLC per entity key (replaces whole-file JSON write) |
| Logstream | event.append | Append-only union; cross-replica order by HLC; per-origin `seq` preserved; consumer contract additive (`origin_replica`, `hlc` are new fields) |
| Diary | drawer.add in diary rooms | Same as drawer content (already append-only) |
`org.tunnel.remove` is the remove half of the tunnel OR-set: it hides the edge
from the current organization view, not from history. If MemPalace adopts a
first-class dormant-tunnel product state, ship it as an explicit state op
(`org.tunnel.set_state(active|dormant)` or equivalent) rather than overloading
remove with dormancy semantics.
Anti-entropy: peers exchange version vectors and pull missing per-origin
ranges (`GET /sync/ops?origin=X&after=N`), push-notified over the existing
SSE channel. No broker, no framework: automerge/yjs are document-CRDTs
(wrong shape, heavy deps); cr-sqlite is a native extension whose generic
table-CRDTs know nothing of id purity or verbatim; file-level sync of live
SQLite corrupts. The merge logic above is ~hundreds of lines we fully own.
Future domain-specific state needs the same explicit treatment before it
enters Layer 2. Closet/card semantics, succession edges, contradiction
intervals, or other semantic structures are not automatically drawer
placement registers. Each adopted state must name its own op kinds, conflict
surface, and any merge-exempt or intentionally non-LWW transitions; otherwise
replicas can silently erase meaning while still "converging."
### 6.3 Id purity (prerequisite, not footnote)
Verified against the code (2026-07-02): today's drawer identities are NOT
content-addressed. Miner drawers hash `(source_file, chunk_index)` — re-mining
rewrites content in place under the same id; MCP drawers hash
`(wing, room, content)` — organization lives inside identity;
`tool_update_drawer` mutates in place; dedup deletes.
**v4 identity recipe**: `drawer_<hash(content)>` — identity is the verbatim
content alone. Organization (wing/room) becomes op-carried metadata
(`org.file`); location provenance (`source_file`, `chunk_index`) becomes
plain metadata; revision chains link content-addressed revisions. `ids.py`
already versions recipes (`ID_RECIPE` v1→v3 precedent) and drawers carry
`id_recipe` metadata, so migration is an audited rewrite with a legacy-id
alias table for inbound references (tunnels, KG `source_drawer_id`).
### 6.4 Mutable-state inventory → op mapping (verified, with file refs)
| Today (mutable) | Where | Becomes |
|---|---|---|
| `tool_update_drawer` in-place update/upsert | mcp_server.py | `drawer.revise` (new content-addressed revision) |
| Miner re-mine upsert over same id | miner.py:1336,1478 | `drawer.revise` at origin replica only (§8) |
| `delete_drawer` / `delete_by_source` / dedup batch delete | dedup.py:127 | `drawer.tombstone` (hide, never destroy) |
| Entity registry whole-file `json.dumps` | entity_registry.py:328 | `registry.entity.upsert` op stream |
| `hallways.json` whole-file rewrite | hallways.py:140 | `org.tunnel.add/remove` OR-set ops |
| KG `invalidate` UPDATE of valid_to; entities INSERT OR REPLACE | knowledge_graph.py | `kg.close` interval op; `kg.entity.upsert` |
| repair / migrate / dedup wholesale rewrites | repair.py, migrate.py | replica-local maintenance of derived state (never synced) |
Hardest today, cleanest after: the two whole-file JSONs (currently pure
last-writer-wins with silent loss) gain real merge semantics for free.
## §7. Layer 3: Derived State
The vector store stops being the system of record — that single change
dissolves the fleet-level writer lease, the stdio proxy's reason to exist,
and the cross-machine integrity gates. Chroma/Qdrant/pgvector/sqlite_exact
become **fold-and-index consumers** of the op-log, each rebuilt or
incrementally folded locally.
- Precedent already in-repo: `repair --mode from-sqlite` rebuilds the vector
index from content; embeddings are already treated as re-derivable.
- Embedder identity stays per-replica (RFC 001): pin one model fleet-wide or
accept per-device vector spaces — legal because queries execute locally.
- **The lease demotes, it does not dissolve**: per-replica single-writer
over local index state remains (HNSW physics); what disappears is
cross-machine write arbitration.
- Organization is NOT derived state. Filing decisions sync as ops (§6.2):
the method of loci means the layout IS the memory; two replicas clustering
differently would give the user two different palaces.
## §8. Provenance & Source-Bound Maintenance
Every op carries `(origin_replica, author_agent, hlc, authored_at)` —
simultaneously the sync unit, the conflict tiebreak, the audit trail, and
the answer to "which machine/agent did this memory come from."
Local references are replica-local: a drawer mined from `P:\...` on Windows
references a path that exists only there. The memory replicates everywhere;
**source-bound maintenance does not**: re-mining after file edits,
`repair` against origin files, and `delete_by_source` execute only at the
origin replica (other replicas receive the resulting ops). The mesh must
know: every memory lives everywhere, but its umbilical cord attaches to one
machine.
## §9. Sequencing
0. **Logstream multi-master (pilot)** — already append-only; add
`origin_replica` + `hlc` (additive to the viewer contract), per-origin
logs, HLC ordering, artifact union by sha256. Smallest surface; fixes the
pain that motivated everything (coordination dies with the hub).
1. **Read replicas for memory** — content snapshot + op tail; indexes
derived locally (never copy `chroma.sqlite3` — that replicates Chroma's
fragility to N machines). Cheap availability for recall; builds the R6
snapshot machinery.
2. **Canonical op-log + v4 id migration** — §6 in full; backends demoted to
derived consumers. Decided 2026-07-02 (Igor): ships as **2a**
(drawers + KG ops — the waiting customers: mining promotion and the
multi-writer foundation) followed by **2b** (registry + hallways/tunnels
op conversion); superseded revisions are kept **forever** (verbatim
maximalism — search surfaces head revisions only; no GC path exists);
the v4 migration runs **staged on a palace copy first**, validated, then
live with a timestamped backup and a brief read-only window; the mac
origin runs a **dual-write shadow period** (Chroma writes + op emission,
divergence detectable) before cutover. Step 3's write-flip on remote
replicas begins only **after the local-capture promotion validates**
end-to-end. Two commitments this step MUST honor:
- **Local-capture promotion.** A step-1 replica may mine machine-local
data (projects, conversations) into its own palace before step 2
exists — such drawers carry no `replica_origin` stamp, so read-replica
reconciliation cannot touch them. Step 2 ships a one-time promotion
pass: every unstamped local drawer becomes a `drawer.add` op under the
replica's identity and flows to the whole mesh. Capture-now is
forward-compatible by contract, not by luck; nothing mined early is
ever re-mined or lost.
- **v4 ids are the cross-machine dedup.** Content-pure identity means
the same content mined on two machines yields the same drawer id; the
grow-only merge collapses duplicates into one drawer with multiple
provenance records. The migration is the dedup mechanism — no separate
dedup pass across origins.
3. **Full multi-writer** — every replica captures locally, all converge.
Each step ships value alone; none blocks on Layer 1 choice (seam, §5).
## §10. Security — shared ownership
Threat model: N replicas mean the entire brain — every drawer, verbatim —
sits on every device. Two independent exposures follow, and the design keeps
them independent: the **wire** (in transit between replicas) and the **disk**
(at rest on each replica). A compromise of one must not yield the other.
### 10.1 Transport-side (windows-claude)
- **In transit**: every op crosses the mesh E2E-encrypted (MeshGuard /
WireGuard-class); relays and couriers (§5.3) see ciphertext only. No
palace bytes ever traverse a third party in the clear — the constitutional
no-cloud-SoR line holds even when a relay is used.
- **Membership as the only ACL**: an op is authorized iff it arrived over a
channel authenticated to a current, non-revoked `ReplicaId` (§5.1). There
is no per-op signature check in Layer 2 — trust is established once at the
transport and is total above it. Membership itself is convergent,
revocation-aware state (§5.6).
- **Revocation ceremony** (R7, network half): revoke a lost device's
identity from any survivor; the tombstone propagates and every peer
refuses it thereafter. This severs *future* access only — see 10.3.
- **Identity-key custody**: per-device Ed25519 key, on-device, non-exportable
(§5.6). Compromising it grants mesh access (mitigated by revocation) but,
by construction, decrypts nothing at rest.
### 10.2 At-rest side (mac-claude)
Two tiers, both grounded in one rule: **data keys are per-replica and never
traverse the mesh.** Ops arrive over the wire (10.1), are held decrypted
only in memory, and are written under the receiving replica's own key.
Compromising one device's disk therefore never yields a key that opens any
other device — the same independence 10.3 demands between wire and disk
holds between disks.
- **Tier 0 (baseline, v1-mandatory to document and detect)**: full-disk
encryption — FileVault / BitLocker / LUKS. Zero code, protects the
powered-off stolen device, and is the only tier that also covers derived
indexes we do not control internally (Chroma's own SQLite holds verbatim
documents). Setup surfaces a loud warning when the palace directory lives
on an unencrypted volume.
- **Tier 1 (target): per-palace data key over the canonical stores.** A
symmetric data key encrypts `oplog.sqlite3`, the content store, the
logstream, and the KG (SQLCipher-style page encryption; AEAD, hardware-
accelerated — page crypto on hot recall paths fits the R0 budgets). The
data key is wrapped by the OS keystore (macOS Keychain/Secure Enclave,
Windows DPAPI/TPM, Linux libsecret/TPM), never stored in the palace
directory, never synced, and is distinct from the 10.1 identity key by
construction — neither derives from the other.
- **Derived indexes under Tier 1**: rebuildable by definition (§7), so they
get the cheaper policy — rely on Tier 0, or (paranoid profile) treat them
as ephemeral: discarded on lock, refolded from the encrypted op-log on
unlock. Never the canonical stores' key.
- **Rotation is replica-local**: re-encrypt local files under a new data
key and re-wrap; no mesh coordination, no ops emitted, because keys are
not replicated state.
- **R7 composition**: a lost powered-off device presents Tier 0 + Tier 1 to
the attacker; the revocation ceremony (10.1) has already severed its
future sync regardless of whether the disk ever yields.
### 10.3 The seam between them
Revocation stops the network; encryption protects the disk that is already
gone. A stolen, powered-off device is defended only by 10.2; a live device
still on the mesh is defended by 10.2 (unlocked-disk exposure) *and* 10.1
(revocation cuts its future sync). Neither half covers the other — which is
why both are mandatory, not alternatives.
Explicitly: **replication ≠ backup (R8)**. Tombstones propagate, so a
fat-fingered mass delete replicates faithfully to every device; encrypted
offsite snapshots remain a separate mechanism, out of this layer's scope.
## Open Questions
- HLC skew bounds and how loudly to surface clock anomalies.
- Op-log compaction policy (R6): checkpoint cadence for snapshot bootstrap.
(Content-revision retention is settled: superseded revisions are kept
forever; compaction only concerns op-replay bootstrap cost, never
content.)
- Does the E2EE cloud courier ship in v1 or wait for demand?
- Partial replicas for phone-class devices (deferred; Non-Goals).
- Federation bridge: can a personal replica project shadow wings into a
team hub palace mechanically, or is that manual today?
## Appendix A: PalaceMind
The desktop app is where replication stops being infrastructure and becomes
something the human can see and trust. Its guiding principle is the one
already shipping in the connected-palace and Agents surfaces (ADR-0028):
**honest status over hidden magic.** Four consumer surfaces.
### A.1 Replica status (generalizing Live/Polling)
The Agents viewer already renders a `Live · seq N` vs `Polling` honesty flag
off the logstream. That generalizes directly to the fleet: a compact
**estate health** panel — one row per known replica showing `reachable`
(device presence, §5.8), last-seen, and **version-vector drift** ("this
device is 14 ops behind mac"). Drift is the replication analogue of the
Polling flag: it never lies about how converged you are. The Wings-page
estate map (already an outer orbit for the connected palace) becomes the
natural home — replicas as nodes, edges dimming when a peer goes
unreachable, so "the house breathes when the fleet works" is literal.
### A.2 Merge surfacing (R4 — the load-bearing UX)
R4 forbids silent organization merges: when two replicas filed the same
drawer differently during a partition and LWW-by-HLC picks a winner, the
loser must be *shown*, not swallowed. PalaceMind owns that moment:
- A quiet, dismissible notice — "This capture was filed in *Meds* on your
laptop and *Billing* here; kept *Billing* (more recent). Move it?" — with
one-click accept-the-other. Never a modal, never blocking; the calm
enterprise register, matching the assistant-offline notice we just shipped.
- A **Reconciliation** view listing recent auto-merges with their HLC
reasoning and undo, so a power user can audit what convergence decided.
The op-log makes this free: every merge is two ops and a rule.
### A.3 Presence rendering (R5)
Presence (§5.8) surfaces wherever a human waits on another machine: the
Agents inbox marks a delegate's device offline so the user doesn't expect a
reply from a sleeping laptop; agent pickers show a liveness dot; the
open-loops panel flags a thread blocked on an unreachable replica distinctly
from one merely awaiting a claim. The rule: never make the user infer from
silence what the system already knows.
### A.4 Multi-origin logstream
The viewer must not fracture when ops carry `origin_replica` + `hlc`
(additive fields, per §6.2 — the contract stays compatible):
- **Order by HLC**, not per-origin `seq`, once events span replicas; keep
`seq` as the per-origin resume cursor.
- **Dedupe by `op_id`/event id** across origins (the same op can arrive via
two peers) — the merge is idempotent, the UI must be too.
- **Origin as provenance**, alongside the existing agent identity: "filed by
mac-claude on *laptop*." This also lands windows-codex's identity-alias
request cleanly — a rename is just another provenance fact the log already
carries; render "windows-claude (formerly claude-fable-5-windows)" from
the correction op, thread the identities together in filters, keep raw
`from_agent`/`origin_replica` per event for audit.
Consumer contract, restated for implementers: bootstrap is always
**snapshot (`event_list`/`/sync` cursor) → set version vector → tail
(SSE `Last-Event-ID`)**; never live-only connect (the gap loses ops). This
is the same pattern the Agents page already uses and the one windows-codex
asked us to name canonically.
+272
View File
@@ -0,0 +1,272 @@
# RFC 005: Agent Identity & Routing
Status: Draft — for Igor's review
Owner: mac-claude (backend/hub owner; identity is palace/fleet infrastructure, not a meshguard concern)
Created: 2026-07-04
Branch: `feat/shared-brain-dogfood`
Prior art: RFC 003 (logstream — the routing surface this refines), RFC 004 (the replicated palace — provides the stable replica identity a host label derives from), the `rfc005_agent_identity_routing` correlation thread
## Summary
An agent's coordination identity is the triple **`host:agent:project`** — the
granularity at which a working actor actually shares filesystem, local config,
and knowledge. Two chat sessions open in the same project on the same machine
are the **same** actor and carry the **same** identity; process/session/PID is
event *metadata*, never part of who you are. Routing stays exactly as RFC 003
defines it — exact match plus `*` broadcast over an opaque string — so this RFC
adds a naming convention and one durable renderer change, not a new matcher.
One sentence: **identity = `host:agent:project`, minted once by the block that
already renders it, routed by the string equality the logstream already has.**
## Motivation
The shared brain identifies each agent by a flat name — `mac-claude`,
`windows-claude`, `blade-claude`. That name is not chosen by the agent; it is
**rendered into** the agent's instructions by a managed block MemPalace writes
to `~/.claude/CLAUDE.md`:
```
<!-- mempalace-shared-brain:start -->
## MemPalace shared brain
You share a MemPalace hub with other agents. Your agent identity is
windows-claude — use it as from_agent/created_by ...
<!-- mempalace-shared-brain:end -->
```
The flat name conflates two things that have now come apart in the live fleet:
1. **Two sessions, one name.** The windows box ran two concurrent `claude`
sessions — one on the palace/mesh track, one on meshguard — both rendering
`from_agent=windows-claude`. Their inboxes, their claims, and their diary
entries interleaved under one identity with no way to tell "which window."
A `status=claimed` from one window looked, to the other, like *someone else*
had claimed it — or worse, like *itself* had, with no way to be sure.
2. **No project seam.** Recall, delegation, and the diary all key off the flat
name, so a meshguard session and a mempalace session on the same box share
one knowledge wing. Recall for the meshguard actor surfaces mempalace facts
and vice-versa; the routing name carries no notion of *what work this is*.
Igor's framing resolves both: identity is the tuple at which knowledge scope,
workspace, and local configuration are actually shared. Same box + same project
= same actor, however many windows are open. Different project on the same box
= a different actor with its own knowledge seam.
## Requirements
- **I1 — Identity is `host:agent:project`.** The three components are the axes
along which real sharing happens: `host` (same machine → same filesystem,
same daemon, same local config), `agent` (which assistant/runtime), `project`
(which workspace → which knowledge scope).
- **I2 — Same-project sessions share one identity.** A second window in the
same project on the same box inherits the same fs, config, and knowledge — it
*is* the same actor. Identity must not fragment per session, per window, or
per PID.
- **I3 — Session/PID is metadata, never identity.** Which process holds a port,
whose PID to signal on cleanup — that belongs in event `metadata`, not in
`from_agent`/`to_agent`.
- **I4 — Claims are held by the identity, not the session.** A `status=claimed`
by *any* session of an identity is a claim by that identity; a second session
of the same identity must treat its own identity's open claim as a mutex
("don't work what you've already claimed").
- **I5 — No new routing primitive required.** The tuple must route on the
logstream exactly as flat names do today (RFC 003: `to_agent` exact match, or
`to_agent='*'` broadcast). Hierarchical/glob routing is explicitly deferred
(see Decision 1).
- **I6 — Minted at the source, not hand-edited.** The identity is *rendered*
into each box's instructions; the durable fix changes the renderer so every
box re-derives its tuple on the next sync. No per-box hand-editing as the
steady state.
- **I7 — Backward compatible.** Existing flat names (`mac-claude`, …) remain
valid and keep routing. Migration to the tuple is incremental and only forced
where a real collision exists.
## Non-Goals
- **A lease/lock server.** Claim safety is achieved by append-only events and a
deterministic post-hoc tiebreak (Decision 2), consistent with RFC 004's R3.
Nothing here introduces a coordinator or a mutex service.
- **A new matcher.** Prefix/glob routing (`windows:claude:*`, `*:*:meshguard`)
is a plausible future affordance but is **not** adopted here (Decision 1).
- **Cross-user identity.** One human, N devices, per RFC 004. The tuple
distinguishes *this* human's actors, not multiple humans.
- **Changing how `from_agent`/`to_agent` are stored or validated.** Colons
already pass `_sanitize_routing`; the tuple is a legal value today.
## The Identity Tuple
```
host : agent : project
```
- **`host`** — a stable, human-readable label for the machine. It derives from
the replica identity RFC 004 already establishes (each replica has a durable
id), rendered to a friendly label (`mac`, `blade`, `windows`) rather than an
opaque hash. One host = one replica = one local daemon.
- **`agent`** — the assistant/runtime family (`claude`, `codex`, `hermes`, …).
This is the existing suffix of today's flat names, lifted out intact.
- **`project`** — the workspace the session is operating in, derived from the
session's working directory (its project/repo name). This is the new axis and
the one that carries the knowledge seam.
Today's flat names are exactly `host-agent` with the `project` axis missing.
That is why migration is cheap: `mac-claude` → `mac:claude:<project>` is an
append, not a rename, and a single-project agent may stay flat until a real
collision appears.
**Delimiter.** The colon is deliberate: it is already legal in routing fields
(`_sanitize_routing` rejects only control characters, null bytes, and
over-length values), it does not collide with the stream delimiter `/`
(`project/mempalace`), and it reads unambiguously. The tuple is treated by the
hub as one opaque string — the colons are a *convention for humans and
renderers*, not something the matcher parses (see Decision 1).
## Same-Session Equivalence (I2)
Two sessions with the same `host:agent:project` are one actor. Concretely:
- They watch the **same inbox** (`to_agent=<tuple>`, which also catches `*`).
- They write with the **same `from_agent`**.
- They share **one knowledge wing and one diary** (see Knowledge Partition).
- A task claimed by one is claimed by the identity (I4).
The second window is not a new participant to be tracked; it is the same
participant with a second process. When the fleet needs to know *which process*
(e.g. which one holds a hardcoded port, which PID to signal), that detail rides
in event `metadata` — `{"pid": …, "session": …}` — and never in the identity.
## Claim Safety Under a Shared Identity (I4, Decision 2)
A shared identity introduces one race: two sessions of the same identity (or
two replicas of the same identity, post-partition per RFC 004 R3) claim the
same task before either sees the other's claim. Resolution, no lease server:
1. **Natural mutex.** Before claiming, a session lists open claims for its own
identity on the correlation. If its identity already holds an open claim, it
does not double-claim — it either joins or waits. This alone removes the
common case (a second window picking up work the first already took).
2. **Deterministic tiebreak for the residual window.** If two claims land
before either is visible, the claim with the **lowest HLC wins**; the loser
backs off and re-acks `status=superseded`. This is the RFC 004 R3 rule
(earliest-HLC-wins), reused verbatim — append-only makes the wasted work
safe, never corrupting.
No new event type is required: `event.ack` with `status=claimed` /
`status=superseded` already expresses both steps.
## Knowledge Partition (I2, bonus)
Keying the palace wing and the diary by the full tuple partitions recall along
the same seam routing already uses: a `…:meshguard` actor's recall stops
surfacing `…:mempalace` facts. Today a single-agent box interleaves both
projects in one wing (e.g. `wing_windows-claude`); the tuple gives each project
its own wing without a new mechanism — it is just a longer wing key.
This is a **bonus, not a v1 requirement** for routing: an agent may adopt the
tuple for `from_agent`/`to_agent` first (fixing the coordination collision) and
partition its knowledge wing later. The two are independent adoptions.
## Decisions
Two choices live in the backend/hub and are resolved here so downstream
consumers (PalaceMind's viewer, the renderer, other agents) can build against a
fixed contract.
### Decision 1 — Matcher: **no change.** Route the tuple as an opaque string.
`to_agent` matching stays exactly as RFC 003 defines it:
```sql
(to_agent = ? OR to_agent = '*')
```
The tuple `host:agent:project` is matched by **string equality**; `*` remains
the only wildcard, meaning fleet-wide broadcast. Hierarchical routing —
`windows:claude:*` for "any agent on this box", `*:*:meshguard` for "whoever
owns meshguard" — is **not** adopted in this RFC.
*Rationale.* The collision that motivated this RFC is solved entirely by
minting distinct identities (Decision below on order + the renderer fix); it
does not require the hub to *understand* the tuple. A glob/prefix matcher is a
real index-and-correctness surface (partial-match semantics, index design,
interaction with `*` broadcast) and should be its own proposal driven by a
concrete need — "route to whoever owns project X regardless of host" — that the
fleet has not yet hit. Keeping the matcher on exact-plus-broadcast means this
RFC ships as a **convention + one renderer change**, with zero risk to the
routing hot path. If hierarchical routing is later wanted, it is additive: a new
optional match mode, not a migration.
### Decision 2 — Order: **`host:agent:project`** (host-first).
*Rationale.* Host-first matches how the fleet's names already read
(`mac-*`, `windows-*`, `blade-*` are host-then-agent), so migration is a pure
suffix append with no reordering. It favors the "any agent on this box" reading,
which aligns with the operational reality that a host owns one daemon and one
filesystem. Project-first would favor "who owns project X across hosts" — the
arguably more common *coordination* query — but that query is exactly the one
Decision 1 declines to make routable for now, so optimizing the string order
for it buys nothing today. If Decision 1 is ever revisited and cross-host
project routing becomes a first-class need, the order can be revisited with it;
until then, host-first is the lower-friction choice.
## The Durable Fix: mint the identity at the renderer (I6)
The identity is hardcoded because MemPalace **renders** it. The one-place fix is
to change the renderer/installer that writes the
`<!-- mempalace-shared-brain:start … end -->` block so it emits the tuple
instead of the flat name:
- **`host`** — derived from the local replica identity (RFC 004), mapped to a
friendly label.
- **`agent`** — the runtime family, as today.
- **`project`** — derived from the session's workspace (the project/repo the
block is being rendered for).
Then every box re-renders to its tuple on the next sync — no hand-editing. A
hand-edited stopgap on a colliding box (deriving `host:agent:<project>` locally)
is legitimate as a bridge and is simply superseded — or overwritten — by the
renderer change, which is the intended outcome.
**Compatibility.** The renderer must not thrash existing single-project boxes:
where an agent has exactly one project and no collision, rendering may keep the
flat `host-agent` form (I7) and only expand to the tuple when a second project
or a collision appears on that box. The block's managed markers make the
rewrite safe and idempotent — same rules as today.
## Sequencing
This RFC is **deferred behind the RFC 004 write-flip**: it touches no storage or
merge semantics, so it does not gate the flip, and the flip's cutover work takes
priority. Once that lands:
1. **Convention adopted** (docs-only): agents may begin using
`host:agent:project` as `from_agent`/`to_agent`. Routes today, no code
change (colons already valid; matcher unchanged).
2. **Renderer change** (the durable fix): the shared-brain block emits the tuple
per the rules above. Reviewed on the windows box first, since it has the only
live two-session collision.
3. **Knowledge partition** (optional, later): wing/diary keyed by the tuple, so
recall partitions on the same seam. Independent of steps 1–2.
## Open Questions
- **`project` derivation edge cases.** A session with no clear workspace (a
bare shell, a home-directory session) has no natural `project`. Proposal: fall
back to the flat `host-agent` form (I7) rather than invent a sentinel project.
- **Friendly host labels.** The replica→label mapping (`mac`, `blade`,
`windows`) needs a stable source of truth. Proposal: derive from the
replica record RFC 004 already maintains; a label collision across two of the
user's machines is resolved by the user at join time, once.
- **Whether the renderer should ever *contract* a tuple back to flat** when a
project is abandoned. Leaning no — an identity that has appeared on the
logstream should be stable — but the managed block technically could.
## Relationship to RFC 004
RFC 004 gives this RFC its `host` axis for free: a replicated palace already has
a durable per-replica identity, and one host is one replica is one local
daemon. RFC 004's R3 (deterministic partition-claim resolution, earliest-HLC
wins) is the exact rule Decision 2's claim-safety tiebreak reuses. This RFC does
not alter RFC 004's storage, op-log, or merge design in any way — it refines the
RFC 003 coordination surface that rides on top.
+156
View File
@@ -0,0 +1,156 @@
# Write routing policy
This document defines the shared policy used by the staged Tier 3 daemon
rollout tracked in #1963.
This foundation PR does not change existing hook or CLI routing. It provides
one tested policy model that later hook and CLI PRs can consume without
inventing different fallback rules.
## Policies
`direct`
Always execute through the existing direct local path.
`prefer`
Use an available daemon. A caller that is allowed to start the daemon may
do so. Otherwise, fall back to the direct path.
`require`
Use an available daemon. A caller that is allowed to start the daemon may
do so. If neither is possible, block the operation. Never fall back to a
direct ChromaDB writer.
## Concrete routing outcomes
The shared decision function returns one of:
- `direct`
- `daemon`
- `blocked`
It also reports whether the caller should auto-start the daemon and why the
route was selected.
Hooks generally pass `daemon_can_start=False` because hook execution has a
tight latency budget.
Interactive CLI commands can pass `daemon_can_start=True`.
## Configuration
Global environment policy:
MEMPALACE_WRITE_ROUTING=direct|prefer|require
Hook-specific environment policy:
MEMPALACE_HOOK_WRITE_ROUTING=direct|prefer|require
CLI-specific environment policy:
MEMPALACE_CLI_WRITE_ROUTING=direct|prefer|require
Configuration-file shape:
{
"write_routing": {
"default": "direct",
"hooks": "prefer",
"cli": "require"
}
}
## Precedence
For hooks:
1. `MEMPALACE_HOOK_WRITE_ROUTING`
2. `MEMPALACE_WRITE_ROUTING`
3. legacy `MEMPALACE_HOOKS_DAEMON`
4. `write_routing.hooks`
5. `write_routing.default`
6. legacy `hooks.daemon`
7. `direct`
For CLI writes:
1. `MEMPALACE_CLI_WRITE_ROUTING`
2. `MEMPALACE_WRITE_ROUTING`
3. `write_routing.cli`
4. `write_routing.default`
5. `direct`
## Backward compatibility
The existing `MEMPALACE_HOOKS_DAEMON` environment variable and
`hooks.daemon` config value remain supported.
Legacy true values map to `prefer`.
Legacy false values map to `direct`.
The existing `MempalaceConfig.hook_use_daemon` property is intentionally
unchanged in this PR. Hook and CLI behavior remains unchanged until their
policy-aware rollout PRs land.
## Invalid policy values
New policy settings accept only:
- `direct`
- `prefer`
- `require`
Invalid values fail with a source-specific error rather than silently falling
back. This is important because silently turning a misspelled `require` into a
direct write would violate the safety purpose of the policy.
## Local backend single-writer safety
File-backed backends such as `chroma`, `sqlite_exact`, and Milvus Lite support
exactly one writable process per palace. Serializing individual calls is not
enough because each long-lived process can retain SQLite/WAL, FTS, or vector
index state between calls.
- A writable daemon owns the palace writer lease for its full lifetime.
- Writable MCP HTTP acquires that lease before binding, holds it through the
full serving lifetime, and releases it after active requests stop.
- MCP stdio opens `sqlite_exact` read-only until it acquires the writer lease.
It may therefore coexist for reads; mutating tools refuse while another
process owns the lease and reopen writable storage after that owner exits.
- Read-only MCP HTTP may coexist with the writer.
- Read-only `sqlite_exact` clients use an immutable connection for a clean
checkpointed database, or `mode=ro` when an active writer's complete WAL
sidecar pair must remain visible. Both paths enable `query_only` and skip
schema, WAL, FTS, migration, and metadata initialization.
- Direct CLI and hook writes must not run beside a writable daemon or MCP HTTP
owner. Route them through the daemon with `require` when the daemon owns the
palace.
- Direct `sqlite_exact` collection mutations contend for the same palace lease,
and full LLM closet regeneration owns it before opening collections or
calling the configured model.
`MEMPALACE_MCP_ALLOW_PEER_WRITER` cannot bypass this protection for local
file-backed or unknown plugin backends. It is retained only for explicitly
remote service backends (`qdrant`, `pgvector`, and Milvus server/Zilliz Cloud)
that coordinate concurrent clients themselves. Milvus Lite remains protected
as local file-backed storage.
Do not delete or unlink a live palace lock to recover ownership. Stop the
owning process cleanly; the operating system releases its lock automatically.
If corruption is suspected, back up the palace and run integrity/repair
operations offline, with no writable service running.
## Follow-up PRs
Hook-triggered writes now consume this policy; see
`docs/hook-write-routing.md`.
The remaining rollout PR will apply the policy to routine CLI writes.
Maintenance operations such as repair, migration, and index rebuild are not
ordinary routed writes. They require a separate exclusive-maintenance policy.
+74
View File
@@ -0,0 +1,74 @@
# MemPalace — Antigravity examples
Two standalone configs for users who don't want to use the
`hooks/antigravity/install.sh` installer.
## Files
| File | Purpose |
|-------------------|-----------------------------------------------------------------------------------|
| `hooks.json` | Standalone `hooks.json` registering the Stop and PreInvocation hooks. |
| `mcp_config.json` | Standalone MCP entry registering the `mempalace-mcp` stdio server. |
## Wire up `hooks.json`
The example uses placeholder absolute paths (`/ABSOLUTE/PATH/TO/mempalace/...`).
You must rewrite both `command` fields to the actual absolute paths to
the hook scripts in your cloned repo, or to whichever location holds
them. Antigravity will not resolve relative paths reliably.
Then drop the file at one of:
- `~/.gemini/config/hooks.json` (global, applies to every workspace)
- `<workspace>/.agents/hooks.json` (workspace-scoped)
Restart Antigravity to pick the file up.
If you'd rather have the paths absolutized automatically, run the
installer:
```bash
bash hooks/antigravity/install.sh
```
That writes a fully rendered `hooks.json` to
`~/.gemini/config/plugins/mempalace/hooks.json`.
## Wire up `mcp_config.json`
The example registers the `mempalace-mcp` stdio server. Two options:
### Option A — merge into the user-level Antigravity MCP config
Antigravity's user-level MCP config lives at
`~/.gemini/antigravity/mcp_config.json`. Merge the `mcpServers.mempalace`
entry from this example into that file, then restart Antigravity.
### Option B — drop into a plugin directory
If you've created a custom plugin folder (per the [Antigravity plugins docs](https://antigravity.google/docs/plugins)),
copy this `mcp_config.json` directly into the plugin root:
```
<plugin-root>/mcp_config.json
```
Antigravity merges plugin-level MCP entries with the user-level config
on launch.
## Verify
After wiring up either or both:
```bash
mempalace-mcp --version # confirm binary is on PATH
ls ~/.mempalace/ # confirm palace exists (run `mempalace init` if not)
```
Restart Antigravity. The `mempalace` MCP server should appear in the
MCP store; the Stop and PreInvocation hooks fire automatically.
See [`hooks/antigravity/STDIN_SHAPE.md`](../../hooks/antigravity/STDIN_SHAPE.md)
for the exact wire format Antigravity uses, and
[`website/guide/antigravity.md`](../../website/guide/antigravity.md)
for the full user-facing guide.
+21
View File
@@ -0,0 +1,21 @@
{
"mempalace-save": {
"Stop": [
{
"type": "command",
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_save_hook_antigravity.sh",
"timeout": 30
}
]
},
"mempalace-wake": {
"PreInvocation": [
{
"type": "command",
"command": "/ABSOLUTE/PATH/TO/mempalace/hooks/antigravity/mempal_wake_hook_antigravity.sh",
"timeout": 5
}
]
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+110
View File
@@ -0,0 +1,110 @@
# Cursor IDE Hooks — Example `hooks.json` Files
Sample configurations for wiring the MemPalace Cursor hooks into the
Cursor IDE. These are **examples only** — they are intentionally not
placed at the repo root (`/.cursor/hooks.json`) because Cursor
auto-loads project-level hooks from any trusted workspace, and the
repo is regularly opened by contributors. We do not auto-arm hooks on
contributor checkout.
## Variants
### `hooks.json` — full (recommended)
Three hooks wired:
- **`sessionStart`** — calls `mempal_wake_hook_cursor.sh`, which returns
`additional_context` telling the agent to recall scoped to the wing
inferred from the workspace root. Cursor-only — Claude Code has no
equivalent.
- **`stop`** — calls `mempal_save_hook_cursor.sh`. Counts stop
invocations per conversation and emits a `followup_message` every
`MEMPAL_SAVE_INTERVAL` (default 15) telling the agent to file the
session into the palace and write a diary entry. `loop_limit: 1` is
defense-in-depth on top of our own loop-count check.
- **`preCompact`** — calls `mempal_precompact_hook_cursor.sh`. Runs
`mempalace mine` synchronously on the transcript before compaction
summarises it, then drops a marker so the next `stop` forces a save
followup.
### `hooks.minimal.json` — `stop` only
Lightest install. Wires just the save hook. Use this if you don't
want the sessionStart recall context or the preCompact transcript
snapshot.
## How to use
The `$HOME` placeholder is **not** expanded by Cursor — you must
substitute the absolute path before saving the file. Pick one:
### Option A — let `install.sh` do it
Project scope — writes `<repo>/.cursor/hooks.json`:
```bash
hooks/cursor/install.sh --scope project --target /path/to/your/repo
```
User scope — writes `~/.cursor/hooks.json`, applies to every Cursor workspace:
```bash
hooks/cursor/install.sh --scope user
```
The installer copies the hook scripts to `~/.mempalace/hooks/cursor/`,
substitutes the absolute paths, and merges the entries into your
existing `hooks.json` without clobbering unrelated hooks. See
`install.sh --help` for `--dry-run`, `--uninstall`, and `--variant`.
### Option B — copy + edit manually
1. Copy the chosen example to the target location:
- User scope: `~/.cursor/hooks.json`
- Project scope: `<your-repo>/.cursor/hooks.json`
2. Replace every `$HOME` with the absolute path to your home
directory (e.g., `/Users/you` or `/home/you`).
3. Make sure each hook script is executable
(`chmod +x ~/.mempalace/hooks/cursor/mempal_*_hook_cursor.sh`).
4. Restart Cursor, or wait for it to auto-reload the file.
## Why aren't these files at the repo root?
Cursor automatically loads `.cursor/hooks.json` from any trusted
workspace. Placing a real `hooks.json` at the repo root would arm
MemPalace's hooks on every contributor's machine the moment they open
the repo in Cursor — which would modify their conversation behaviour
without consent and write to `~/.mempalace/hook_state/` without
asking. Editor configuration is sacred; opt-in only.
If you actually want MemPalace's hooks armed when working on the
MemPalace repo itself, run:
```bash
hooks/cursor/install.sh --scope project --target .
```
That will write `./.cursor/hooks.json` for the repo workspace
specifically — but it is your decision, not ours, and the file is
listed in `.gitignore` paths Cursor users typically already exclude.
## Related: the Cursor plugin
The hooks here are **only one half** of MemPalace's Cursor integration. The other half is the [`.cursor-plugin/`](../../.cursor-plugin/) folder at the repo root, which packages MemPalace's MCP server, five slash commands, and the model-invocable `mempalace` skill as a regular Cursor plugin you can drop into `~/.cursor/plugins/local/mempalace`.
The two install paths are orthogonal — install whichever you want, in any order:
| You want | Install |
|---------------------------------------------------------------------------|----------------------------------------------------------|
| MCP tools (`mempalace_search`, `mempalace_add_drawer`, …) + slash commands | The plugin — see [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) |
| Auto-save every N turns + sessionStart memory recall | The hooks here — see Option A above |
| Both | Install the plugin AND run `hooks/cursor/install.sh` |
Hooks are deliberately **not** bundled into the plugin because Cursor's hooks system is configured per-user/per-project (in `~/.cursor/hooks.json` or `.cursor/hooks.json`), not per-plugin — so the installer here owns that file with idempotent merge semantics, while the plugin owns the MCP+commands+skill side.
## See also
- [`hooks/cursor/README.md`](../../hooks/cursor/README.md) — full reference for hooks
- [`hooks/cursor/STDIN_SHAPE.md`](../../hooks/cursor/STDIN_SHAPE.md) — per-event schema with citations
- [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) — rendered docs
- [`.cursor-plugin/README.md`](../../.cursor-plugin/README.md) — Cursor plugin (MCP + commands + skill)
+21
View File
@@ -0,0 +1,21 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_wake_hook_cursor.sh"
}
],
"stop": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
],
"preCompact": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_precompact_hook_cursor.sh"
}
]
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"hooks": {
"stop": [
{
"command": "$HOME/.mempalace/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
]
}
}
+58
View File
@@ -0,0 +1,58 @@
# Cursor Rules — MemPalace recall
Optional [Cursor rules](https://cursor.com/docs/rules) that make the
agent search MemPalace before answering questions about past work,
people, projects, or prior decisions.
These are for users who install MemPalace **without** the Cursor plugin
(or who want recall behaviour in a specific project). If you installed
the [Cursor plugin](../../../.cursor-plugin/README.md), it already ships
the `alwaysApply: false` rule at the plugin root — you do not need to
copy anything.
## Which file to use
| File | `alwaysApply` | Fires when | Use when |
|------|---------------|------------|----------|
| [`mempalace-recall.mdc`](mempalace-recall.mdc) | `false` | Cursor's matcher decides the turn is recall-relevant (from the rule `description`) | **Recommended.** Recall without paying for the rule on unrelated work. |
| [`mempalace-recall-always.mdc`](mempalace-recall-always.mdc) | `true` | Every conversation in scope, every turn | You want recall guaranteed in context and accept the cost. |
The always-on variant is heavier: it sits in context on every turn and
makes the agent more eager to call `mempalace_search`, which adds MCP
latency and works against MemPalace's "memory should feel instant"
budget. Prefer the `false` variant unless you specifically want recall
forced into every conversation. Pick **one** of the two — do not install
both.
## Install
User scope (every workspace) — copy into `~/.cursor/rules/`:
```bash
mkdir -p ~/.cursor/rules
cp examples/cursor/rules/mempalace-recall.mdc ~/.cursor/rules/
```
Project scope (this repo only) — copy into `.cursor/rules/`:
```bash
mkdir -p .cursor/rules
cp examples/cursor/rules/mempalace-recall.mdc .cursor/rules/
```
For the aggressive variant, copy `mempalace-recall-always.mdc` instead
(only one of the two). Then reload Cursor:
<kbd>Cmd</kbd>-<kbd>Shift</kbd>-<kbd>P</kbd> → **Developer: Reload Window**.
## How recall is delivered
Recall ships in three orthogonal layers — install any combination:
| Layer | What it does | Where |
|-------|--------------|-------|
| `sessionStart` hook | Injects wing-scoped recall context once per new chat | [`hooks/cursor/`](../../../hooks/cursor/) |
| `mempalace-recall` skill | Full search-before-answer protocol, model-invoked or attached | [`skills/mempalace-recall/`](../../../skills/mempalace-recall/) |
| Recall rule (these files) | Nudges search-before-answer on recall-relevant turns | here, or the plugin root `rules/` |
All three reference the same canonical protocol in
[`integrations/shared/recall-protocol.md`](../../../integrations/shared/recall-protocol.md).
@@ -0,0 +1,25 @@
---
description: Always-on MemPalace recall — search the palace before answering about past work, people, projects, or prior decisions.
alwaysApply: true
---
# MemPalace recall (always on)
This is the aggressive variant of the recall rule: `alwaysApply: true`
loads it into every conversation in scope, on every turn, regardless of
whether Cursor's matcher thinks recall is relevant.
Before answering anything that may already be in the user's memory
palace — past work, prior decisions, a person, a project, or "what did
we do / decide / discuss last time?" — search the palace first:
1. Call `mempalace_search` with a short keyword query. Use
`mempalace_kg_query` for relational or time-bound facts.
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
stored content.
3. If results are empty, say so — do not invent an answer. If the MCP
server is unavailable, surface the error; do not fall back to guessing.
Even with this rule always loaded, only actually call the tools when the
question touches memory. Do not search on pure greenfield work
(renaming a variable, fixing a typo).
@@ -0,0 +1,20 @@
---
description: When the user asks about past work, prior decisions, people, projects, or events that may be filed in MemPalace, call mempalace_search (or mempalace_kg_query for relational or time-bound facts) before answering from model memory. Return stored content verbatim; never guess when the palace might know.
alwaysApply: false
---
# MemPalace recall
Before answering anything that may already be in the user's memory
palace — past work, prior decisions, a person, a project, or "what did
we do / decide / discuss last time?" — search the palace first:
1. Call `mempalace_search` with a short keyword query. Use
`mempalace_kg_query` for relational or time-bound facts.
2. Quote the drawer's **verbatim** text. Never summarize or paraphrase
stored content.
3. If results are empty, say so — do not invent an answer. If the MCP
server is unavailable, surface the error; do not fall back to guessing.
Skip recall for pure greenfield work with no memory relevance (renaming
a variable, fixing a typo). Recall is question-driven, not reflexive.
@@ -0,0 +1,92 @@
# MX3 Public Shim Embeddings and Rerank
MemPalace can optionally route Chroma embeddings and post-retrieval rerank through the public
MX3 shim helpers.
This path is local by default. It sends the live search query and candidate hits to the configured
shim endpoint, so remote endpoints are blocked unless you explicitly opt in, and any remote
endpoint must use HTTPS.
Public repo:
- `https://github.com/grtninja/mx3-public-shim`
## Hardware required
This setup requires a MemryX MX3 accelerator in an M.2 slot behind the configured public-shim
endpoint.
## Install
```bash
pip install "mx3-public-shim[mempalace] @ git+https://github.com/grtninja/mx3-public-shim.git"
```
## Validate before enabling
Use this path only with a hardware-backed public-shim deployment.
```bash
python -m mx3_public_shim.doctor
```
MemPalace rejects the explicit `cpu_reference` fallback, but you should still validate the
configured endpoint before enabling it:
- the runtime must expose an embeddings provider
- the selected provider must not be `cpu_reference`
- the endpoint should be the local public shim unless you intentionally opt in to a remote HTTPS
target
On Windows, a healthy doctor report normally shows:
- `mx3_linux` unavailable because the direct official Python runtime is Linux-only
- `openai_compat` available and selected for `embeddings`
- `openai_compat` available and selected for `chat`
That means MemPalace is using the local public-shim OpenAI-compatible surface while the required
hardware lives behind that endpoint.
## Enable embeddings + rerank in MemPalace
```bash
export MEMPALACE_EMBEDDING_BACKEND=mx3_public_shim
export MEMPALACE_SEARCH_RERANKER=mx3_public_shim
export MEMPALACE_MX3_PUBLIC_SHIM_BASE_URL=http://127.0.0.1:9000/v1
export MEMPALACE_MX3_PUBLIC_SHIM_EMBEDDING_MODEL=text-embedding-nomic-embed-text-v1.5
export MEMPALACE_MX3_PUBLIC_SHIM_PROVIDER_ORDER=mx3_linux,openai_compat
export MEMPALACE_MX3_PUBLIC_SHIM_REQUIRE_HARDWARE=1
```
On Windows, the direct `mx3_linux` provider is not expected to resolve; the normal path is a local
public-shim OpenAI-compatible endpoint that is itself backed by the required hardware.
If your endpoint requires authentication, also set:
```bash
export MEMPALACE_MX3_PUBLIC_SHIM_API_KEY=...
```
`MEMPALACE_EMBEDDING_BACKEND` and `MEMPALACE_SEARCH_RERANKER` can be enabled independently. Use
both when you want MX3-backed Chroma embeddings plus post-retrieval rerank.
If you intentionally point `MEMPALACE_MX3_PUBLIC_SHIM_BASE_URL` at a non-localhost endpoint, also
set:
```bash
export MEMPALACE_MX3_PUBLIC_SHIM_ALLOW_REMOTE=1
```
## Optional example script
The companion example below demonstrates the narrow Chroma seam directly:
```bash
python examples/mx3_public_shim_embeddings_rerank.py
```
Then search normally:
```bash
mempalace search "auth decisions"
```
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Example: query a local public-shim embeddings endpoint and pass vectors into Chroma."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from urllib import request
try:
from chromadb import PersistentClient
except ImportError as exc: # pragma: no cover
raise SystemExit("Install chromadb before running this example.") from exc
BASE_URL = os.environ.get("MEMPALACE_MX3_PUBLIC_SHIM_BASE_URL", "http://127.0.0.1:9000/v1")
EMBEDDINGS_URL = BASE_URL.rstrip("/") + "/embeddings"
EMBED_MODEL = os.environ.get(
"MEMPALACE_MX3_PUBLIC_SHIM_EMBEDDING_MODEL",
"text-embedding-nomic-embed-text-v1.5",
)
def fetch_embeddings(texts: list[str]) -> list[list[float]]:
payload = json.dumps({"model": EMBED_MODEL, "input": texts}).encode("utf-8")
headers = {"Content-Type": "application/json"}
api_key = os.environ.get("MEMPALACE_MX3_PUBLIC_SHIM_API_KEY")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = request.Request(
EMBEDDINGS_URL,
data=payload,
headers=headers,
method="POST",
)
with request.urlopen(req, timeout=20) as response:
body = json.loads(response.read().decode("utf-8"))
data = body.get("data", [])
if len(data) != len(texts):
raise RuntimeError("Embedding response count did not match input count")
return [row["embedding"] for row in data]
def main() -> None:
documents = [
"A local retrieval queue keeps notes that still need verification.",
"The workstation stores a short MX3 setup checklist for repeatable bring-up.",
"This note is about watering tomatoes in the backyard.",
]
metadatas = [
{"wing": "verification"},
{"wing": "operations"},
{"wing": "personal"},
]
query = "How does the system track notes that still need verification?"
with tempfile.TemporaryDirectory() as tmpdir:
client = PersistentClient(path=str(Path(tmpdir)))
collection = client.get_or_create_collection("mempalace_drawers")
doc_vectors = fetch_embeddings(documents)
collection.add(
ids=["1", "2", "3"],
documents=documents,
embeddings=doc_vectors,
metadatas=metadatas,
)
query_vector = fetch_embeddings([query])[0]
result = collection.query(query_embeddings=[query_vector], n_results=3)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+47 -1
View File
@@ -2,6 +2,13 @@
These hook scripts make MemPalace save automatically. No manual "save" commands needed.
This file covers the **Claude Code** and **Codex CLI** hooks that live
flat under `hooks/`. For the **Cursor IDE** hooks, see
[`hooks/cursor/README.md`](cursor/README.md) or the rendered docs at
[`website/guide/cursor-hooks.md`](../website/guide/cursor-hooks.md). The
two are additive and share the same `~/.mempalace/hook_state/`
directory.
If you are trying to protect existing Claude Code transcripts immediately,
use the short checklist first: [`website/guide/claude-code-retention.md`](../website/guide/claude-code-retention.md).
It covers hook wiring, JSONL backup, and one-time backfill.
@@ -11,6 +18,7 @@ It covers hook wiring, JSONL backup, and one-time backfill.
| Hook | When It Fires | What Happens |
|------|--------------|-------------|
| **Save Hook** | Every 15 human messages | Auto-mines transcript (tool output included), then blocks the AI to save topics/decisions/quotes |
| **SessionEnd Hook** | Clean session exit | Backgrounds a final transcript mine (when a transcript exists) so short sessions aren't lost; returns immediately so teardown is never delayed. A lightweight diary checkpoint is written in the detached child. |
| **PreCompact Hook** | Right before context compaction | Auto-mines transcript, then emergency save — forces the AI to save EVERYTHING before losing context |
**Two-layer capture:** Hooks auto-mine the JSONL transcript directly into the palace (capturing raw tool output — Bash results, search findings, build errors). They also block the AI with a reason message telling it to save verbatim tool output and key context. Belt and suspenders — tool output gets stored even if the AI summarizes instead of quoting.
@@ -30,6 +38,13 @@ Add to `.claude/settings.local.json`:
"timeout": 30
}]
}],
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_session_end_hook.sh",
"timeout": 10
}]
}],
"PreCompact": [{
"hooks": [{
"type": "command",
@@ -41,11 +56,35 @@ Add to `.claude/settings.local.json`:
}
```
`SessionEnd` runs once on a clean exit and backgrounds its work, so it
returns instantly and stays within Claude Code's SessionEnd budget. Wired
through `settings.local.json` (above) the `timeout` can raise that budget;
the bundled plugin cannot, which is why the hook backgrounds rather than
mining in the foreground.
Make them executable:
```bash
chmod +x hooks/mempal_save_hook.sh hooks/mempal_precompact_hook.sh
chmod +x hooks/mempal_save_hook.sh hooks/mempal_session_end_hook.sh hooks/mempal_precompact_hook.sh
```
## Install — Antigravity (Google)
The Antigravity integration lives in its own subdirectory because the
wire format (camelCase JSON, `injectSteps[]` output) and event names
(`Stop`, `PreInvocation`) are Antigravity-specific. Use the dedicated
installer:
```bash
bash hooks/antigravity/install.sh
```
This installs to `~/.gemini/config/plugins/mempalace/`, registers the
MCP server, ships the `mempalace` skill, and wires the Stop +
PreInvocation hooks. See [`hooks/antigravity/README.md`](antigravity/README.md)
for the full guide and [`hooks/antigravity/INVESTIGATION.md`](antigravity/INVESTIGATION.md)
for the source-of-truth audit of which Antigravity surfaces the
integration uses.
## Install — Codex CLI (OpenAI)
Add to `.codex/hooks.json`:
@@ -65,6 +104,13 @@ Add to `.codex/hooks.json`:
}
```
**Other harnesses:** the clean-exit save runs through the harness-agnostic
`mempalace hook run --hook session-end` entry point. This release wires it
for Claude Code. Antigravity exposes no dedicated session-end event (its
lifecycle hooks are PreToolUse/PostToolUse/PreInvocation/PostInvocation/Stop,
and MemPalace already saves there via `Stop`); Cursor and Codex can adopt the
same entry point as a follow-up wherever their own session-end event is available.
## Configuration
Edit `mempal_save_hook.sh` to change:
+332
View File
@@ -0,0 +1,332 @@
# Antigravity IDE — Integration Surface Investigation
**Investigated**: 2026-05-27
**Author**: undeadindustries
**Scope**: What MemPalace can integrate with in Google's Antigravity IDE,
what we shipped, and what we deliberately did not ship.
This document is the source of truth for design decisions in the
`feat/antigravity-support` branch. It exists so a future maintainer can
re-derive every choice without re-reading the docs cold.
---
## 1. Surfaces verified against Google's official Antigravity docs
All quotes are pulled verbatim from Google's official Antigravity
documentation on 2026-05-27. URLs are the authoritative source; the
mirrored excerpts here are for reviewer convenience.
### 1.1. MCP — `https://antigravity.google/docs/mcp`
> The configuration file is located at `~/.gemini/antigravity/mcp_config.json`.
>
> The configuration file has a single `mcpServers` object where you
> define each server you want to connect to.
Schema (verified):
```json
{
"mcpServers": {
"<name>": {
"command": "...", // stdio
"args": [...],
"env": {...},
"cwd": "...",
"serverUrl": "...", // remote
"headers": {...},
"authProviderType": "google_credentials",
"oauth": {"clientId": "...", "clientSecret": "..."},
"disabled": false,
"disabledTools": [...]
}
}
}
```
Per-plugin form: `mcp_config.json` at the plugin root, same shape.
Antigravity merges plugin entries with the user's
`~/.gemini/antigravity/mcp_config.json` rather than clobbering.
**Cross-checked locally**: the user's existing
`~/.gemini/antigravity/mcp_config.json` already contains a working
`"mempalace": {"command": "/Users/robs/.local/bin/mempalace-mcp"}`
entry, proving the shape matches and the binary is on PATH.
### 1.2. Plugins — `https://antigravity.google/docs/plugins`
> A plugin is a directory containing a `plugin.json` file and optional
> subdirectories for different customization types:
>
> ```
> plugins/<plugin-name>/
> ├── plugin.json # Required marker file
> ├── mcp_config.json # Optional MCP server definitions
> ├── hooks.json # Optional hooks definition
> ├── skills/ # Optional skills
> │ └── <skill-name>/
> │ └── SKILL.md
> └── rules/ # Optional rules
> └── <rule-name>.md
> ```
Manifest schema (verified):
```json
{
"name": "my-custom-plugin"
}
```
> The name field is optional and defaults to the directory name if omitted.
Install locations (verified):
> - Workspace Level: Place your plugin folder inside a
> `.agents/plugins/` or `_agents/plugins/` directory at the root of
> your opened workspace.
> - Global Level: Place your plugin folder inside
> `~/.gemini/config/plugins/` in your user home directory.
We ship the global location as the canonical install path.
### 1.3. Skills — `https://antigravity.google/docs/skills`
> A skill is a folder containing a `SKILL.md` file with instructions
> that the agent can follow when working on specific tasks.
Frontmatter (verified):
| Field | Required | Description |
|---------------|----------|---------------------------------------------------------------------------------------------------|
| `name` | No | A unique identifier (lowercase, hyphens). Defaults to the folder name. |
| `description` | Yes | A clear description of what the skill does and when to use it. |
Standalone discovery paths (verified):
| Location | Scope |
|-------------------------------------|------------------------|
| `<workspace>/.agents/skills/` | Workspace-specific |
| `~/.gemini/antigravity/skills/` | Global, all workspaces |
In-plugin discovery: `<plugin>/skills/<skill-name>/SKILL.md`.
We ship the in-plugin form so a single install registers MCP, skill, and
hooks together.
### 1.4. Hooks — `https://antigravity.google/docs/hooks?app=antigravity`
> Hooks allow you to run custom scripts or shell commands at specific
> points during Antigravity's execution loop.
`hooks.json` lives at one of:
- `~/.gemini/config/hooks.json` (global)
- `<workspace>/.agents/hooks.json` (workspace)
- `<plugin-root>/hooks.json` (per-plugin) — what we ship
Top-level schema (verified):
```json
{
"<hook-name>": {
"enabled": true,
"PreToolUse": [{ "matcher": "...", "hooks": [{...}] }],
"PostToolUse": [{ "matcher": "...", "hooks": [{...}] }],
"PreInvocation": [{ ... }],
"PostInvocation": [{ ... }],
"Stop": [{ ... }]
}
}
```
For `PreInvocation` / `PostInvocation` / `Stop`, items are flat handler
objects; the `matcher` wrapper is only used for `PreToolUse` /
`PostToolUse`.
Handler object (verified):
| Field | Required | Description |
|-----------|----------|---------------------------------------------------|
| `type` | No | Currently only `"command"` is supported. Default. |
| `command` | Yes | The shell command to execute. |
| `timeout` | No | Timeout in seconds. Defaults to 30. |
#### STDIN/STDOUT contract (verified)
> Hooks receive input via stdin as JSON and should return output via
> stdout as JSON. Field names use camelCase.
Common stdin fields (every event):
| Field | Type | Description |
|-------------------------|-----------------|-------------------------------------------------------|
| `conversationId` | string | The unique UUID of the active agent conversation. |
| `workspacePaths` | array<string> | Absolute directory paths of the user's workspaces. |
| `transcriptPath` | string | Absolute path to the persistent `transcript.jsonl`. |
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
`Stop` event additional fields:
| Field | Type | Description |
|---------------------|---------|----------------------------------------------------------------------------|
| `executionNum` | integer | Sequence number of the execution attempt. |
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
| `error` | string | Optional error message. |
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks are done. |
`Stop` event stdout (verified):
| Field | Type | Description |
|------------|--------|-------------------------------------------------------------------------------------------------------|
| `decision` | string | **Required.** Set to `"continue"` to FORCE the agent to keep running. Anything else allows the stop. |
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message. |
**CRITICAL**: emitting `{"decision": "continue"}` from a save hook would
turn it into an infinite agent-loop trigger. The MemPalace save hook
MUST emit `{}` on every code path. There is an explicit refusal in
`mempal_save_hook_antigravity.sh` to ever print the literal word
`"continue"` from a decision field.
`PreInvocation` event additional fields:
| Field | Type | Description |
|-------------------|---------|----------------------------------------------------------|
| `invocationNum` | integer | Sequence number of the current model invocation. |
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
`PreInvocation` event stdout (verified):
| Field | Type | Description |
|---------------|----------------|-----------------------------------------------------------------------------------------------|
| `injectSteps` | array<object> | Optional. Steps injected before the model is called. Each step has one of: |
| | | `{ "toolCall": {...} }` / `{ "userMessage": "..." }` / `{ "ephemeralMessage": "..." }` |
We use `ephemeralMessage` for the wake-up injection: the message is
visible to the model on this turn but does not persist to the
transcript, so we do not pollute future model calls with the same
injection.
`PreInvocation` fires before EVERY model invocation, not only at session
start. We gate to `invocationNum == 1` to mimic Cursor's `sessionStart`
semantics — exactly one wake injection per conversation.
### 1.5. Permissions — `https://antigravity.google/docs/permissions`
Permissions are user-side, configured via Allow / Deny / Ask lists.
Plugins do **not** declare permissions in `plugin.json`. The
third-party "antigravity-plugins" community skill at
`~/.gemini/skills/antigravity-plugins/SKILL.md` documents a
`"permissions": [...]` field in `plugin.json`; that field is fabricated
and does not appear in any real Google-shipped plugin (`firebase`,
`google-antigravity-sdk`, `chrome-devtools-plugin`,
`modern-web-guidance-plugin`) inspected at
`~/.gemini/config/plugins/`.
We ship a minimal `plugin.json` of `{"name": "mempalace"}`.
---
## 2. Surfaces shipped
| Surface | What we ship |
|---------------------------|---------------------------------------------------------------------------|
| Plugin manifest | `.antigravity-plugin/plugin.json` — minimal, verified shape |
| MCP auto-registration | `.antigravity-plugin/mcp_config.json` — registers `mempalace-mcp` stdio |
| Skill | `.antigravity-plugin/skills/mempalace/SKILL.md` — real file, frontmatter |
| `Stop` hook | `hooks/antigravity/mempal_save_hook_antigravity.sh` — counter + auto-mine |
| `PreInvocation` hook | `hooks/antigravity/mempal_wake_hook_antigravity.sh` — wake injection |
| Installer | `hooks/antigravity/install.sh` — idempotent, basename-match uninstall |
| User-facing docs | `website/guide/antigravity.md` + sidebar wiring |
| Examples | `examples/antigravity/{hooks.json,mcp_config.json,README.md}` |
| Tests | 3 test files mirroring the cursor blueprint |
---
## 3. Surfaces deliberately not shipped
### 3.1. `PreCompact` equivalent — NOT SHIPPED
Antigravity's external `hooks.json` does **not** expose a context-compaction event. The Python SDK has an in-process `@hooks.on_compaction`
decorator (see `~/.gemini/config/plugins/google-antigravity-sdk/examples/getting_started/hooks.md`),
but that fires inside a Python `LocalAgentConfig`-built agent, not the
IDE itself. There is no way to subscribe to compaction from a
plugin's `hooks.json`.
UX consequence: long conversations can auto-compact without a save
checkpoint. The `Stop` hook still catches the conversation when the
user actually ends the turn, so the worst case is that some mid-turn
state is lost on auto-compaction. Verbatim transcript ingestion via
the `Stop` path covers the long-term recall use case.
### 3.2. Slash-commands / `commands/` — NOT SHIPPED
Antigravity has no `commands/` plugin component. The Cursor and Codex
integrations both ship five quick-reference commands
(`mempalace-help`, `-init`, `-mine`, `-search`, `-status`) that point
at `mempalace instructions <cmd>`. Those have been folded into the
`SKILL.md` `## Common operations` section so the agent gets the same
quick-reference content via Antigravity's progressive-disclosure skill
loading. No new files, no rule-noise, same discoverability.
### 3.3. `rules/` — NOT SHIPPED
`rules/<name>.md` files are evaluated as constraints on the agent's
behavior. Shipping rules from MemPalace risks colliding with the
user's existing project rules (e.g. `.agents/rules/*.md` files the
user has already authored). Users who want strict MemPalace-related
rules can drop them into their own `<workspace>/.agents/rules/`
directory; we do not impose them.
### 3.4. Workspace-level `.agents/plugins/mempalace/` install — NOT SHIPPED BY DEFAULT
The installer writes to the global location at
`~/.gemini/config/plugins/mempalace/`. Workspace-scoped installs are
documented in `hooks/antigravity/README.md` for users who want to
limit MemPalace to one workspace; they can `cp -r .antigravity-plugin
<workspace>/.agents/plugins/mempalace`. We do not install there
automatically because the canonical UX is global.
### 3.5. `permissions` field in `plugin.json` — NOT SHIPPED
Antigravity permissions are user-side (`Allow` / `Deny` / `Ask`
lists). Plugin manifests do not declare permissions. The
`"permissions": [...]` field documented in the third-party
"antigravity-plugins" community skill is fabricated; no
Google-shipped plugin uses it.
### 3.6. `PreToolUse` / `PostToolUse` hooks — NOT SHIPPED
These would let MemPalace observe every tool call (e.g.
auto-extract entities after each `write_to_file`). Out of scope
for v1; the hook surface is real and could be added in a future
PR if there is demand. Documenting the omission here so a future
contributor doesn't conclude the hooks weren't supported.
---
## 4. Cross-checks against the user's running Antigravity
The user (`robs@`) has Antigravity 2.0 installed. The following live
artifacts on this machine corroborate the published docs:
| Path | Confirms |
|------------------------------------------------------------------------|----------------------------------------------------------------|
| `~/.gemini/antigravity/mcp_config.json` | MCP config path + standard `mcpServers` shape |
| `~/.gemini/antigravity/skills/<skill-name>/SKILL.md` (multiple) | Global skill discovery path |
| `~/.gemini/config/plugins/firebase/plugin.json` | Real `plugin.json` shape (no `permissions` field) |
| `~/.gemini/config/plugins/chrome-devtools-plugin/skills/.../SKILL.md` | In-plugin skill discovery `<plugin>/skills/<name>/SKILL.md` |
| `~/.gemini/config/plugins/google-antigravity-sdk/examples/.../hooks.md` | SDK-side compaction hook is in-process Python only |
| Existing `mempalace` entry in `mcp_config.json` | `mempalace-mcp` already running and discoverable |
---
## 5. Reference URLs (all 2026-05-27)
- `https://antigravity.google/docs/plugins`
- `https://antigravity.google/docs/hooks?app=antigravity`
- `https://antigravity.google/docs/skills`
- `https://antigravity.google/docs/mcp`
- `https://antigravity.google/docs/permissions`
- `https://antigravity.google/docs/subagents`
- `https://antigravity.google/blog/introducing-google-antigravity-sdk`
+172
View File
@@ -0,0 +1,172 @@
# MemPalace — Antigravity hook scripts
Lifecycle hooks for the [Antigravity IDE](https://antigravity.google/).
This is the third sibling of the Claude Code and Codex integrations
(see `hooks/mempal_save_hook.sh` and `.codex-plugin/hooks/`). The
overall shape is the same — a Stop event triggers a background save,
a startup-time event injects memory into the agent — but the wire
format and STDOUT contract are Antigravity-specific (see
[STDIN_SHAPE.md](STDIN_SHAPE.md)).
## Quick start
From the repo root:
```bash
bash hooks/antigravity/install.sh
```
This installs the plugin to `~/.gemini/config/plugins/mempalace/`.
Restart Antigravity and the MCP server, skill, and hooks all register
automatically.
To dry-run first:
```bash
bash hooks/antigravity/install.sh --dry-run
```
To uninstall:
```bash
bash hooks/antigravity/install.sh --uninstall
```
## What gets installed
```
~/.gemini/config/plugins/mempalace/
├── plugin.json # marker manifest
├── mcp_config.json # registers mempalace-mcp
├── hooks.json # rendered from hooks.json.tmpl
├── README.md
├── skills/
│ └── mempalace/
│ └── SKILL.md
└── hooks/
├── lib/
│ └── common.sh
├── mempal_save_hook_antigravity.sh # Stop event handler
└── mempal_wake_hook_antigravity.sh # PreInvocation handler
```
`hooks.json` carries absolute paths to the two hook scripts (resolved
from `__PLUGIN_DIR__` at install time).
## What the hooks do
### `mempal_save_hook_antigravity.sh` (Stop event)
Fires every time the agent's execution loop terminates. Increments a
per-conversation counter; every `MEMPAL_SAVE_INTERVAL` fires (default
15), spawns `mempalace mine <transcript-dir> --mode convos --wing
<inferred>` in the background. The hook itself returns `{}` to stdout
in under a few milliseconds — the actual mining runs detached and
does not block the user.
Defers when:
- `fullyIdle == false` (background tasks still running)
- `terminationReason == "error"` (transcript may be corrupt)
- A previous save for this conversation is still running
- Any kill switch is set
### `mempal_wake_hook_antigravity.sh` (PreInvocation event, gated)
Fires before every model invocation. Gated to `invocationNum == 1`
(first invocation of the conversation only) — beyond that we'd be
re-injecting on every turn. Calls `mempalace wake-up --wing <inferred>`
with a 500ms hard timeout and emits the verbatim output as an
`ephemeralMessage` so the agent sees relevant memory on its first
response without polluting the persistent transcript.
Skips when:
- `invocationNum != 1`
- Already woke this conversation (atomic `mkdir` loop guard)
- `mempalace wake-up` exits non-zero, times out, or produces empty output
- Any kill switch is set
## Kill switches
Any one of these disables both hooks (silent passthrough, exit 0):
| Knob | Value |
|------------------------------------------|--------------------------------|
| `MEMPAL_DISABLE_HOOK` | `1`, `true`, `yes` |
| `MEMPALACE_HOOKS_AUTO_SAVE` | `false`, `0`, `no` |
| `~/.mempalace/config.json` | `{"hooks": {"auto_save": false}}` |
| Removing `~/.mempalace/` entirely | (palace nuke) |
## Workspace-scoped install (advanced)
If you want MemPalace to load only inside a specific workspace,
manually copy the rendered plugin into your workspace's `.agents/plugins/`:
```bash
bash hooks/antigravity/install.sh --install-dir /tmp/render-stage
mkdir -p <workspace>/.agents/plugins/
cp -r /tmp/render-stage <workspace>/.agents/plugins/mempalace
rm -rf /tmp/render-stage
```
The global install at `~/.gemini/config/plugins/mempalace/` is the
canonical UX and what we recommend.
## Troubleshooting
### Hooks aren't firing
1. Confirm Antigravity sees the plugin: open the IDE, navigate to the
Customizations page; `mempalace` should appear in the global plugins
list.
2. Check `~/.mempalace/hook_state/antigravity_hook.log` — every fire
logs a line. No log lines = the hook is not being invoked.
3. Verify `mempalace-mcp` is on `$PATH`: `mempalace-mcp --version`.
4. Inspect the rendered `hooks.json` paths point at executable files:
`bash -n ~/.gemini/config/plugins/mempalace/hooks/*.sh`.
### Save fires but no mining happens
1. Look for the most recent `[event=stop]` lines in
`antigravity_hook.log` — `count` and `interval` should both be
visible. Mining only triggers when `count % interval == 0`.
2. Ensure a Python that can import `mempalace` is reachable. The hook
runs `"$MEMPAL_PYTHON_BIN" -m mempalace`, where `MEMPAL_PYTHON_BIN`
is resolved (in order) from `$MEMPAL_PYTHON`, the
`mempalace-mcp` / `mempalace` console-script shebang on `$PATH`,
then `python3`. A failed probe logs:
```
ERROR: mempalace is not runnable via <python> -m mempalace; install mempalace or set MEMPAL_PYTHON
```
On a GUI-launched Antigravity the harness `PATH` may differ from
your shell `PATH`; if the shebang heuristic can't find the right
interpreter, export `MEMPAL_PYTHON=/abs/path/python` (e.g.
`"$(uv tool dir)/mempalace/bin/python"`) and restart.
### Wake injection isn't appearing
1. The wake hook only injects on `invocationNum == 1`. Subsequent
invocations are gated.
2. The atomic `mkdir` marker
`~/.mempalace/hook_state/antigravity_woke_<conversationId>` exists
after a successful injection. Remove it to re-inject (rare).
3. `mempalace wake-up --wing <inferred>` may be returning empty output
if the wing doesn't exist yet. Run `mempalace status` to verify
wing presence.
## See also
- [INVESTIGATION.md](INVESTIGATION.md) — every Antigravity surface we
investigated, with verbatim quotes and source URLs.
- [STDIN_SHAPE.md](STDIN_SHAPE.md) — the exact wire format
Antigravity uses, with worked examples.
- [../mempal_save_hook.sh](../mempal_save_hook.sh) — Claude Code
equivalent.
- [../../.codex-plugin/hooks/](../../.codex-plugin/hooks/) — Codex
equivalent.
- [../../website/guide/antigravity.md](../../website/guide/antigravity.md)
— full user-facing guide.
+206
View File
@@ -0,0 +1,206 @@
# Antigravity hook STDIN / STDOUT contract
This file documents the exact wire format the Antigravity IDE uses
when invoking the MemPalace hook scripts. All fields are verbatim
from Google's official Antigravity hooks documentation
(`https://antigravity.google/docs/hooks?app=antigravity`, accessed
2026-05-27). See [INVESTIGATION.md](INVESTIGATION.md) for the
provenance audit.
## Wire format
Hooks receive **JSON on stdin** and must emit **JSON on stdout**.
Field names are **camelCase**.
Hook execution timeout defaults to 30 seconds. The MemPalace plugin
sets the Stop hook timeout to 30s and the PreInvocation hook timeout
to 5s in the rendered `hooks.json`.
## Common stdin fields (every event)
| Field | Type | Notes |
|-------------------------|-----------------|--------------------------------------------------------|
| `conversationId` | string | UUID of the active agent conversation. |
| `workspacePaths` | array<string> | Absolute workspace dirs. **First element is canonical**. |
| `transcriptPath` | string | Absolute path to `transcript.jsonl`. |
| `artifactDirectoryPath` | string | Path to conversation artifacts and screenshots. |
## Stop event
### Stdin (additional fields)
| Field | Type | Notes |
|---------------------|---------|--------------------------------------------------------------------------------------|
| `executionNum` | integer | Sequence number of the execution attempt for this conversation. |
| `terminationReason` | string | `"model_stop"`, `"max_steps_exceeded"`, `"error"`, etc. |
| `error` | string | Optional. Set when termination was caused by a system error. |
| `fullyIdle` | boolean | **Required.** True iff all background commands and async tasks have completed. |
### Stdout
| Field | Type | Notes |
|------------|--------|--------------------------------------------------------------------------------------------------------|
| `decision` | string | If `"continue"`, **forces** the agent to keep running. Anything else allows the stop. |
| `reason` | string | Optional. If `decision == "continue"`, injected as a system message into the conversation. |
**MemPalace policy**: the save hook ALWAYS emits `{}` and exits 0. It
NEVER emits `{"decision": "continue"}` — that would force an infinite
agent loop. There is an explicit refusal in
`mempal_save_hook_antigravity.sh` to ever construct a stdout JSON
object containing the literal word `"continue"` in a decision field.
### MemPalace gating
The save hook short-circuits with `{}` (no save triggered) when ANY
of the following hold:
1. `MEMPAL_DISABLE_HOOK=1` (or `true`/`yes`) is set.
2. `MEMPALACE_HOOKS_AUTO_SAVE=false` (or `0`/`no`) is set.
3. `~/.mempalace/config.json` has `hooks.auto_save: false`.
4. `~/.mempalace/` directory does not exist (user nuked the palace).
5. Stdin is malformed or empty (sentinel-guarded parse failure).
6. `fullyIdle == false` (background tasks still running; defer save).
7. `terminationReason == "error"` (transcript may be corrupt).
8. `transcriptPath` validation fails (not a `.json`/`.jsonl`, or `..` traversal).
9. The transcript file does not exist on disk.
10. The save counter has not yet hit `count % MEMPAL_SAVE_INTERVAL == 0`.
11. A pending save is still running for this conversation (less than 1 hour old).
12. The `mempalace` CLI is not on `$PATH`.
When the modulo gate is hit and validation passes, the hook spawns
`mempalace mine <transcript-dir> --mode convos --wing <inferred>` in
the background and returns `{}` immediately.
## PreInvocation event
### Stdin (additional fields)
| Field | Type | Notes |
|-------------------|---------|------------------------------------------------------------------|
| `invocationNum` | integer | Sequence number of the current model invocation (1-based). |
| `initialNumSteps` | integer | Number of steps currently in the trajectory. |
### Stdout
| Field | Type | Notes |
|---------------|----------------|--------------------------------------------------------------------------------------------------------|
| `injectSteps` | array<object> | Optional. Steps to inject before the model is called. Each step has one of: `{"toolCall": {...}}`, `{"userMessage": "..."}`, `{"ephemeralMessage": "..."}` |
The `ephemeralMessage` form is what the MemPalace wake hook emits — it
delivers the wake-up text to the model on this turn but does not
persist into the transcript, so subsequent invocations don't see a
duplicate.
### MemPalace gating
The wake hook short-circuits with `{}` (no injection) when ANY of:
1. Any kill switch trips (same five conditions as the save hook).
2. `invocationNum != 1` — we only inject on the first model call of
each conversation, mimicking Cursor's `sessionStart` semantics.
3. The atomic `mkdir`-based loop guard is already taken (this
conversation already received a wake injection).
4. `mempalace wake-up --wing <inferred>` exits non-zero, times out
(500ms hard cap), or produces empty output.
When the gates pass and `mempalace wake-up` returns text, the hook
emits:
```json
{
"injectSteps": [
{ "ephemeralMessage": "<verbatim wake-up output>" }
]
}
```
The wake hook NEVER emits a `decision` field — that field belongs to
the Stop event. There is a final guard against any stdout that
contains a `decision` key.
## Worked example: Stop event
### Input
```json
{
"executionNum": 1,
"terminationReason": "model_stop",
"error": "",
"fullyIdle": true,
"conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587",
"workspacePaths": ["/home/me/projects/mempalace"],
"transcriptPath": "/home/me/projects/mempalace/.gemini/jetski/transcript.jsonl",
"artifactDirectoryPath": "/home/me/projects/mempalace/.gemini/jetski/artifacts"
}
```
### Output (always)
```json
{}
```
(Side effects: counter `~/.mempalace/hook_state/antigravity_save_count_<id>` is
incremented; if the modulo gate fires, a background `mempalace mine`
subprocess is spawned with the transcript directory and the inferred
wing `wing_mempalace`.)
## Worked example: PreInvocation, first invocation
### Input
```json
{
"invocationNum": 1,
"initialNumSteps": 0,
"conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587",
"workspacePaths": ["/home/me/projects/mempalace"],
"transcriptPath": "/home/me/projects/mempalace/.gemini/jetski/transcript.jsonl",
"artifactDirectoryPath": "/home/me/projects/mempalace/.gemini/jetski/artifacts"
}
```
### Output (when the palace has memory for `wing_mempalace`)
```json
{
"injectSteps": [
{
"ephemeralMessage": "<exact verbatim text from `mempalace wake-up --wing wing_mempalace`>"
}
]
}
```
### Output (when invocationNum != 1, or any gate trips)
```json
{}
```
## State files
All hook state lives under `~/.mempalace/hook_state/` (overridable
via `$MEMPAL_STATE_DIR`) and is namespaced with the `antigravity_`
prefix to coexist with Claude Code, Cursor, and Codex hook state in
the same directory.
| File | Purpose |
|-----------------------------------------------|-----------------------------------------------|
| `antigravity_hook.log` | All hook activity, ISO8601Z timestamps. |
| `antigravity_save_count_<conversationId>` | Per-conversation Stop counter. |
| `antigravity_pending_<conversationId>` | Marker file for in-flight save subprocess. |
| `antigravity_woke_<conversationId>` (dir) | Atomic mkdir marker for wake injection. |
| `antigravity_last_input.log` | 4 KB cap, mode 0600, set on parse failure. |
| `antigravity_last_python_err.log` | Python stderr from the JSON parser, mode 0600.|
## Environment variables
| Variable | Default | Purpose |
|--------------------------------|----------------------|-----------------------------------------------------------|
| `MEMPAL_PYTHON` | `$(command -v python3)` | Override the Python interpreter used by the hooks. |
| `MEMPAL_STATE_DIR` | `~/.mempalace/hook_state` | Override the hook state directory. |
| `MEMPAL_SAVE_INTERVAL` | `15` | Save every Nth Stop fire. Floored to >= 1 (no /0). |
| `MEMPAL_DISABLE_HOOK` | unset | Set to `1` / `true` / `yes` to disable both hooks. |
| `MEMPALACE_HOOKS_AUTO_SAVE` | unset | Set to `false` / `0` / `no` to disable both hooks. |
+357
View File
@@ -0,0 +1,357 @@
#!/bin/bash
# MEMPALACE ANTIGRAVITY INSTALLER
#
# Idempotent installer for the Antigravity plugin. Copies
# .antigravity-plugin/* and hooks/antigravity/{lib,*.sh} into the
# install directory (default ~/.gemini/config/plugins/mempalace/),
# renders hooks.json.tmpl into hooks.json with absolute paths, and
# leaves the result in a state Antigravity will discover on next
# launch.
#
# === Usage ===
#
# bash hooks/antigravity/install.sh # install with defaults
# bash hooks/antigravity/install.sh --dry-run # show what would happen
# bash hooks/antigravity/install.sh --uninstall # remove plugin
# bash hooks/antigravity/install.sh --install-dir <p> # custom install dir
# bash hooks/antigravity/install.sh --log-level debug # noisier output
#
# === Idempotency ===
#
# Re-running the installer produces a byte-identical install dir.
# Files are only written when their content differs from what is
# already on disk (cmp gate). The user's ~/.gemini/config/plugins/
# directory is never touched outside the mempalace/ subdirectory.
#
# === Uninstall safety ===
#
# Uninstall removes the install dir entirely IFF it is the
# mempalace/ plugin directory. We match by basename of the install
# dir, never by substring search, so a user who has a sibling plugin
# at ~/.gemini/config/plugins/mempalace-foo/ is unaffected.
#
# === set -e ===
#
# Installer can use `set -e` (constraint #2 only forbids it in the
# hook scripts themselves). On any error we exit non-zero so a CI run
# fails loudly.
set -e
set -u
# ── Repo root resolution ─────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
PLUGIN_SRC="$REPO_ROOT/.antigravity-plugin"
HOOKS_SRC="$REPO_ROOT/hooks/antigravity"
# ── Defaults ─────────────────────────────────────────────────────────
INSTALL_DIR_DEFAULT="$HOME/.gemini/config/plugins/mempalace"
INSTALL_DIR=""
DRY_RUN=0
UNINSTALL=0
LOG_LEVEL="info"
# ── Args ─────────────────────────────────────────────────────────────
print_usage() {
cat <<'USAGE'
Usage: install.sh [--install-dir DIR] [--dry-run] [--uninstall] [--log-level LEVEL]
Options:
--install-dir DIR Plugin install directory.
Default: ~/.gemini/config/plugins/mempalace
--dry-run Show what would happen without writing anything.
--uninstall Remove the installed plugin.
--log-level LEVEL debug | info | warn | error. Default: info.
-h, --help Show this help.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--install-dir)
INSTALL_DIR="${2:-}"
shift 2
;;
--install-dir=*)
INSTALL_DIR="${1#*=}"
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--uninstall)
UNINSTALL=1
shift
;;
--log-level)
LOG_LEVEL="${2:-info}"
shift 2
;;
--log-level=*)
LOG_LEVEL="${1#*=}"
shift
;;
-h|--help)
print_usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
print_usage >&2
exit 2
;;
esac
done
if [ -z "$INSTALL_DIR" ]; then
INSTALL_DIR="$INSTALL_DIR_DEFAULT"
fi
# ── Absolutize the install dir ───────────────────────────────────────
#
# The cursor PR review caught that a relative --install-dir would get
# baked into hooks.json verbatim, leaving paths like
# `./plugins/.../mempal_save_hook_antigravity.sh` that Antigravity
# can't resolve at runtime. Absolutize before writing anything.
mempal_absolutize() {
local p="$1"
case "$p" in
/*) printf '%s' "$p" ;;
~*) printf '%s' "${p/#\~/$HOME}" ;;
*)
# Resolve relative to the user's $PWD at invocation time.
# We never `cd` in the main shell of this installer, so
# $PWD is already the user's invocation directory — no
# subshell cd dance needed.
local base="${PWD}"
printf '%s/%s' "$base" "$p"
;;
esac
}
INSTALL_DIR="$(mempal_absolutize "$INSTALL_DIR")"
# Squash any `//` or `./` or `name/..` artefacts using Python's
# os.path.normpath; falls back to the raw value if Python is missing
# (which would be very unusual on macOS / Linux).
if command -v python3 >/dev/null 2>&1; then
INSTALL_DIR="$(python3 -c 'import os,sys; print(os.path.normpath(sys.argv[1]))' "$INSTALL_DIR")"
fi
# ── Logging ──────────────────────────────────────────────────────────
log() {
local lvl="$1"; shift
local msg="$*"
case "$lvl" in
debug)
[ "$LOG_LEVEL" = "debug" ] && echo "[install] DEBUG: $msg"
return 0
;;
info)
case "$LOG_LEVEL" in
debug|info) echo "[install] $msg" ;;
esac
;;
warn)
case "$LOG_LEVEL" in
debug|info|warn) echo "[install] WARN: $msg" >&2 ;;
esac
;;
error)
echo "[install] ERROR: $msg" >&2
;;
esac
}
# ── Action helpers (dry-run aware) ──────────────────────────────────
run() {
if [ "$DRY_RUN" -eq 1 ]; then
echo "[install] DRY-RUN: $*"
return 0
fi
"$@"
}
# ── Render template ──────────────────────────────────────────────────
#
# Substitutes __PLUGIN_DIR__ in $src into $dst with $INSTALL_DIR.
# Emits the rendered file to a temp path first, then promotes it iff
# the content differs from what's already at $dst. The cmp gate is
# what makes the installer idempotent: a no-op re-run produces no
# disk writes (and the test suite asserts byte-equality).
render_template() {
local src="$1"
local dst="$2"
if [ ! -f "$src" ]; then
log error "template not found: $src"
return 1
fi
local tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/mempal_agy_render.XXXXXX")"
# Python over awk/sed — INSTALL_DIR may legitimately contain
# characters (spaces, colons) that would require careful escaping
# in a sed s/// replacement. Python read+replace handles all of
# them uniformly.
python3 -c "
import sys
src, dst, install_dir = sys.argv[1:4]
with open(src, 'r') as f:
body = f.read()
body = body.replace('__PLUGIN_DIR__', install_dir)
with open(dst, 'w') as f:
f.write(body)
" "$src" "$tmp" "$INSTALL_DIR"
if [ -f "$dst" ] && cmp -s "$tmp" "$dst"; then
rm -f "$tmp"
log debug "unchanged: $dst"
return 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "[install] DRY-RUN: would render $src -> $dst"
rm -f "$tmp"
return 0
fi
mv "$tmp" "$dst"
log info "wrote: $dst"
}
# ── copy_file: cmp-gated copy that preserves mode ────────────────────
copy_file() {
local src="$1"
local dst="$2"
if [ ! -f "$src" ]; then
log error "missing source file: $src"
return 1
fi
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
log debug "unchanged: $dst"
return 0
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "[install] DRY-RUN: would copy $src -> $dst"
return 0
fi
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
log info "wrote: $dst"
}
# ── Uninstall path ───────────────────────────────────────────────────
#
# We DO NOT remove the install dir by string-substring match against
# the path. We require the install dir's basename to be exactly
# "mempalace" — that way an unrelated sibling like
# ~/.gemini/config/plugins/mempalace-foo/ is left alone, and a
# malformed --install-dir like ~ or / cannot wipe the user's home.
do_uninstall() {
local base
base="$(basename "$INSTALL_DIR")"
if [ "$base" != "mempalace" ]; then
log error "refusing to uninstall: install dir basename is '$base', expected 'mempalace'"
log error "(safety guard: prevents accidental wipe of unrelated directories)"
return 1
fi
if [ ! -d "$INSTALL_DIR" ]; then
log info "nothing to uninstall: $INSTALL_DIR does not exist"
return 0
fi
# Verify the dir LOOKS like our plugin before removing — a
# plugin.json file with our marker is the proof.
if [ ! -f "$INSTALL_DIR/plugin.json" ]; then
log error "refusing to uninstall: $INSTALL_DIR has no plugin.json"
return 1
fi
if ! grep -q '"name"[[:space:]]*:[[:space:]]*"mempalace"' "$INSTALL_DIR/plugin.json" 2>/dev/null; then
log error "refusing to uninstall: $INSTALL_DIR/plugin.json is not a mempalace plugin"
return 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
echo "[install] DRY-RUN: would rm -rf $INSTALL_DIR"
return 0
fi
rm -rf "$INSTALL_DIR"
log info "uninstalled: $INSTALL_DIR"
}
if [ "$UNINSTALL" -eq 1 ]; then
do_uninstall
exit 0
fi
# ── Pre-install sanity ───────────────────────────────────────────────
if [ ! -d "$PLUGIN_SRC" ]; then
log error "missing source: $PLUGIN_SRC"
exit 1
fi
if [ ! -d "$HOOKS_SRC" ]; then
log error "missing source: $HOOKS_SRC"
exit 1
fi
# Soft-check that mempalace-mcp is on PATH; warn but do not fail.
if ! command -v mempalace-mcp >/dev/null 2>&1; then
log warn "mempalace-mcp is not on PATH; the MCP server will fail to start until it is."
log warn " fix: 'uv tool install mempalace' or 'pip install mempalace'"
fi
# Soft-check ~/.gemini exists; if missing, Antigravity isn't installed.
if [ ! -d "$HOME/.gemini" ]; then
log warn "$HOME/.gemini not found — Antigravity is probably not installed yet."
log warn " the install will still proceed; Antigravity will pick up the plugin on first launch."
fi
log info "install dir: $INSTALL_DIR"
# ── Install: directories ─────────────────────────────────────────────
run mkdir -p "$INSTALL_DIR" \
"$INSTALL_DIR/skills/mempalace" \
"$INSTALL_DIR/skills/mempalace-recall" \
"$INSTALL_DIR/rules" \
"$INSTALL_DIR/hooks" \
"$INSTALL_DIR/hooks/lib"
# ── Install: plugin metadata ─────────────────────────────────────────
copy_file "$PLUGIN_SRC/plugin.json" "$INSTALL_DIR/plugin.json"
copy_file "$PLUGIN_SRC/mcp_config.json" "$INSTALL_DIR/mcp_config.json"
copy_file "$PLUGIN_SRC/README.md" "$INSTALL_DIR/README.md"
# ── Install: skills (real files, no symlinks at the discovery path) ──
copy_file "$PLUGIN_SRC/skills/mempalace/SKILL.md" \
"$INSTALL_DIR/skills/mempalace/SKILL.md"
copy_file "$PLUGIN_SRC/skills/mempalace-recall/SKILL.md" \
"$INSTALL_DIR/skills/mempalace-recall/SKILL.md"
# ── Install: optional recall rule ────────────────────────────────────
#
# Antigravity discovers markdown rules under the plugin's rules/
# directory. This one is recall-only and intentionally lightweight —
# it complements the mempalace-recall skill. Shipping it as a plugin
# rule (not an always-on global rule) keeps it scoped to recall-
# relevant turns, honouring MemPalace's "memory should feel instant"
# budget.
copy_file "$PLUGIN_SRC/rules/mempalace-recall.md" \
"$INSTALL_DIR/rules/mempalace-recall.md"
# ── Install: hooks ───────────────────────────────────────────────────
copy_file "$HOOKS_SRC/lib/common.sh" "$INSTALL_DIR/hooks/lib/common.sh"
copy_file "$HOOKS_SRC/mempal_save_hook_antigravity.sh" "$INSTALL_DIR/hooks/mempal_save_hook_antigravity.sh"
copy_file "$HOOKS_SRC/mempal_wake_hook_antigravity.sh" "$INSTALL_DIR/hooks/mempal_wake_hook_antigravity.sh"
# Ensure hook scripts are executable on the install side. cp preserves
# mode but a fresh git clone from a tarball might not — chmod is
# defensive, idempotent, and bash 3.2 safe.
if [ "$DRY_RUN" -ne 1 ]; then
chmod 755 "$INSTALL_DIR/hooks/mempal_save_hook_antigravity.sh" 2>/dev/null || true
chmod 755 "$INSTALL_DIR/hooks/mempal_wake_hook_antigravity.sh" 2>/dev/null || true
fi
# ── Install: render hooks.json from template ─────────────────────────
render_template "$PLUGIN_SRC/hooks.json.tmpl" "$INSTALL_DIR/hooks.json"
# ── Done ─────────────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
log info "DRY-RUN complete; no files written."
else
log info "install complete: $INSTALL_DIR"
log info "restart Antigravity to load the plugin."
fi
+498
View File
@@ -0,0 +1,498 @@
# shellcheck shell=bash
# MEMPALACE ANTIGRAVITY HOOK — shared helpers
#
# Sourced by the two Antigravity hook scripts:
# * mempal_save_hook_antigravity.sh (Stop event)
# * mempal_wake_hook_antigravity.sh (PreInvocation event, gated to invocationNum==1)
#
# Mirrors the conventions of the existing Claude Code hook scripts
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh):
#
# * STATE_DIR layout under ~/.mempalace/hook_state/
# * MEMPAL_PYTHON resolution order (override -> $PATH -> bare python3)
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
# * fail-open on internal errors: emit valid JSON and log, never crash
# the hook host
#
# Antigravity-specific contract differences from Claude / Cursor:
#
# * Antigravity stdin uses camelCase (transcriptPath, conversationId,
# workspacePaths, executionNum, terminationReason, fullyIdle,
# invocationNum, initialNumSteps), not the snake_case Claude Code
# format (session_id, transcript_path, stop_hook_active).
# * Antigravity stdout for Stop event MUST be {} on every success path
# because { "decision": "continue" } would force the agent into an
# infinite re-execution loop. The save hook explicitly refuses to
# ever emit the "continue" decision.
# * Antigravity stdout for PreInvocation can carry an "injectSteps"
# array of { "ephemeralMessage": "..." } objects to inject memory
# into the agent's first turn.
#
# This file is sourced, not executed, so it intentionally has no
# shebang. The `# shellcheck shell=bash` directive above tells
# shellcheck to treat it as bash when run standalone.
# bash 3.2.57 (the macOS default) is the lower bound. Do not use
# `mapfile`, `readarray`, `declare -A`, or `${var^^}` — none of those
# exist in 3.2. Use `sed -n 'Np'` for line extraction and case-folding
# via `tr` instead.
# ── State directory + log path ────────────────────────────────────────
#
# Honour MEMPAL_STATE_DIR while keeping the default identical to the
# Claude Code hooks so a user running both keeps a single state directory
# (constraint #7 in the integration brief).
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
MEMPAL_AGY_LOG="$MEMPAL_STATE_DIR/antigravity_hook.log"
# ── Python interpreter resolution ─────────────────────────────────────
#
# The hooks run mempalace as `"$MEMPAL_PYTHON_BIN" -m mempalace`, so the
# resolved interpreter MUST be one that has the mempalace package
# importable. The single most common install path —
# `uv tool install mempalace` (and `pipx install`) — puts the
# `mempalace` / `mempalace-mcp` *console scripts* on PATH inside an
# ISOLATED environment whose interpreter is NOT the system `python3`.
# So naively resolving `command -v python3` lands on a system Python
# that can't import mempalace, the `-m mempalace` probe fails, and
# mining silently never fires. (This bit a real user on PR #1633.)
#
# Resolution order — first hit wins:
# 1. $MEMPAL_PYTHON — explicit operator override
# 2. shebang of the mempalace-mcp / mempalace console script on PATH
# — pip/uv write these with an absolute-path shebang pointing at
# the exact interpreter that owns the package. This is the SAME
# console script mcp_config.json launches, so if the MCP server
# can start, the hooks resolve a working interpreter too — no
# MEMPAL_PYTHON needed for the common install paths.
# 3. $(command -v python3) — an activated dev venv /
# editable install where python3 itself owns the package
# 4. bare "python3" — last-resort fallback
#
# Steps 2-4 are pure string parsing + stat (no Python subprocess), so
# resolution stays cheap enough to run at source time on every hook
# fire, including gated-out / kill-switched ones. We deliberately do
# NOT run an `import mempalace` probe here: building that import pays
# the chromadb/onnx cold-start cost, which a recent perf fix
# (df295bd) moved OFF the hook foreground on purpose. The downstream
# `-m mempalace --version` probe (backgrounded in the save hook,
# subprocessed in the wake hook) is the safety net that catches a
# shebang interpreter whose package is genuinely broken.
mempal_resolve_python() {
# 1. Explicit override always wins.
local p="${MEMPAL_PYTHON:-}"
if [ -n "$p" ] && [ -x "$p" ]; then
printf '%s' "$p"
return 0
fi
# 2. Derive the interpreter from a mempalace console-script shebang.
local script_path shebang interp
for script_path in mempalace-mcp mempalace; do
script_path="$(command -v "$script_path" 2>/dev/null || true)"
[ -n "$script_path" ] || continue
[ -r "$script_path" ] || continue
shebang="$(sed -n '1p' "$script_path" 2>/dev/null)"
case "$shebang" in
'#!'*)
interp="${shebang#\#!}" # drop the leading '#!'
interp="${interp%$'\r'}" # strip a trailing CR (CRLF files)
interp="${interp# }" # drop one leading space
interp="${interp%% *}" # first whitespace-delimited token
# Guard against `#!/usr/bin/env python` wrappers: only
# accept a token whose basename looks like a Python
# interpreter and is executable. An `env`-style shebang
# yields `/usr/bin/env` here, which we skip.
case "${interp##*/}" in
python*)
if [ -x "$interp" ]; then
printf '%s' "$interp"
return 0
fi
;;
esac
;;
esac
done
# 3. First python3 on PATH.
p="$(command -v python3 2>/dev/null || true)"
if [ -n "$p" ]; then
printf '%s' "$p"
return 0
fi
# 4. Last-resort bare name.
printf '%s' "python3"
}
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
# ── Logging ───────────────────────────────────────────────────────────
#
# ISO8601Z timestamps are greppable across timezones.
mempal_log() {
local event="${1:-?}"
local conv="${2:-unknown}"
local msg="${3:-}"
local ts
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
>> "$MEMPAL_AGY_LOG" 2>/dev/null
}
# ── Kill switch ───────────────────────────────────────────────────────
#
# Disabled if ANY of:
# * MEMPAL_DISABLE_HOOK is a truthy string
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no
# * ~/.mempalace/config.json sets hooks.auto_save: false
# * ~/.mempalace/ directory does not exist (user nuked the palace)
#
# Returns 0 (kill switch tripped, hook should short-circuit) or non-zero
# (proceed normally).
mempal_kill_switch_tripped() {
# Palace nuke is the strongest signal: respect it before touching
# disk for state, logging, etc.
if [ ! -d "$HOME/.mempalace" ]; then
return 0
fi
case "${MEMPAL_DISABLE_HOOK:-}" in
1|true|TRUE|yes|YES) return 0 ;;
esac
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
false|FALSE|0|no|NO) return 0 ;;
esac
local cfg="$HOME/.mempalace/config.json"
if [ -f "$cfg" ]; then
local auto
auto=$("$MEMPAL_PYTHON_BIN" -c "
import json, sys
try:
with open(sys.argv[1]) as f:
cfg = json.load(f)
print(str(cfg.get('hooks', {}).get('auto_save', True)).lower())
except Exception:
print('true')
" "$cfg" 2>/dev/null)
if [ "$auto" = "false" ]; then
return 0
fi
fi
return 1
}
# ── camelCase JSON parser (Antigravity stdin) ────────────────────────
#
# Reads JSON from stdin once and prints a sanitized, sentinel-bracketed
# block of fields the bash side can grab via `sed -n 'Np'`. Why a
# sentinel and per-line layout: bash 3.2 doesn't have `mapfile` or
# `readarray`, and `eval`-on-shell-var is the wrong shape (every value
# is user-controllable JSON). Sentinel + line offset is the same pattern
# the existing Claude Code hook (hooks/mempal_save_hook.sh) uses.
#
# Output layout (one field per line; line numbers are stable and the
# fields are documented in STDIN_SHAPE.md):
#
# line 1: __MEMPAL_PARSE_OK__ — sentinel (parse success marker)
# line 2: conversationId — sanitized to [A-Za-z0-9._-]
# line 3: transcriptPath — sanitized to a safe path charset
# line 4: workspacePath — workspacePaths[0], sanitized
# line 5: artifactDirectoryPath — sanitized
# line 6: executionNum — integer, default 0
# line 7: terminationReason — sanitized to [a-z_]
# line 8: fullyIdle — "True" or "False" (string)
# line 9: invocationNum — integer, default 0
# line 10: initialNumSteps — integer, default 0
#
# The sanitizers are defense-in-depth: every field is also vetted by
# the Python json.load step, but we still strip shell-meaningful chars
# from any field a downstream bash variable might interpolate, so that
# a hostile / malformed harness payload cannot inject command tokens.
#
# Stderr from Python is captured to last_python_err.log at mode 0600 so
# operators can debug parse failures without re-firing the hook. The
# umask 077 on the inner subshell creates the file at 0600 atomically;
# the explicit chmod 600 below is a belt-and-suspenders guard if a
# future edit ever drops the umask.
mempal_parse_stdin() {
local input="$1"
(
umask 077
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c "
import sys, json, re
# IMPORTANT: do NOT wrap json.load in a try/except. If the input is
# not valid JSON we want Python to exit non-zero BEFORE printing the
# __MEMPAL_PARSE_OK__ sentinel — the bash caller looks for the
# sentinel on line 1 to decide whether to engage its defense-in-depth
# 'failed to parse' branch. Catching the exception and falling back
# to data={} would let the sentinel print, masking parse failures
# from the bash side. The traceback lands in
# antigravity_last_python_err.log so operators can debug.
data = json.load(sys.stdin)
def safe(s, allowed=r'[^a-zA-Z0-9_/.\-~]'):
return re.sub(allowed, '', str(s))
def safe_id(s):
return re.sub(r'[^a-zA-Z0-9._-]', '', str(s))
def safe_int(v, default=0):
try:
n = int(v)
return n if n >= 0 else default
except Exception:
return default
def safe_lower_alpha_underscore(s):
return re.sub(r'[^a-z_]', '', str(s).lower())
conv_id = safe_id(data.get('conversationId', ''))
transcript = safe(data.get('transcriptPath', ''))
wp_arr = data.get('workspacePaths', [])
if isinstance(wp_arr, list) and wp_arr:
workspace = safe(wp_arr[0])
else:
workspace = ''
artifact = safe(data.get('artifactDirectoryPath', ''))
execution_num = safe_int(data.get('executionNum', 0))
termination_reason = safe_lower_alpha_underscore(data.get('terminationReason', ''))
fully_idle_raw = data.get('fullyIdle', None)
if fully_idle_raw is True or str(fully_idle_raw).lower() in ('true', '1', 'yes'):
fully_idle = 'True'
else:
fully_idle = 'False'
invocation_num = safe_int(data.get('invocationNum', 0))
initial_num_steps = safe_int(data.get('initialNumSteps', 0))
print('__MEMPAL_PARSE_OK__')
print(conv_id)
print(transcript)
print(workspace)
print(artifact)
print(execution_num)
print(termination_reason)
print(fully_idle)
print(invocation_num)
print(initial_num_steps)
" 2>"$MEMPAL_STATE_DIR/antigravity_last_python_err.log"
)
# Tidy up the err log: keep it iff non-empty (failure happened).
if [ -s "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" ]; then
chmod 600 "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" 2>/dev/null
else
rm -f "$MEMPAL_STATE_DIR/antigravity_last_python_err.log" 2>/dev/null
fi
}
# ── Transcript path validator ─────────────────────────────────────────
#
# Mirrors mempalace.hooks_cli._validate_transcript_path: rejects empty,
# non-jsonl/json suffixes, and any `..` traversal segment.
mempal_is_valid_transcript_path() {
local path="$1"
[ -n "$path" ] || return 1
case "$path" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$path/" in
*/../*) return 1 ;;
esac
return 0
}
# ── Wing inference ────────────────────────────────────────────────────
#
# Takes the first workspace path from workspacePaths[] (already
# extracted into $1) and derives a `wing_<slug>` name from its leaf
# directory. Hyphens become underscores; spaces become underscores.
# Empty input yields wing_sessions, matching mempalace.hooks_cli's
# fallback.
mempal_infer_wing() {
local workspace="$1"
if [ -z "$workspace" ]; then
printf 'wing_sessions'
return 0
fi
# Strip trailing slashes
while [ "${workspace}" != "${workspace%/}" ]; do
workspace="${workspace%/}"
done
if [ -z "$workspace" ]; then
printf 'wing_sessions'
return 0
fi
local leaf="${workspace##*/}"
if [ -z "$leaf" ]; then
printf 'wing_sessions'
return 0
fi
# Lowercase + hyphens-to-underscores. tr is bash 3.2 safe; ${var^^}
# / ${var//-/_} on a fresh expansion are bash 4+ only.
local slug
slug=$(printf '%s' "$leaf" | tr 'A-Z' 'a-z' | tr ' -' '__')
printf 'wing_%s' "$slug"
}
# ── Save-interval floor ───────────────────────────────────────────────
#
# Reads MEMPAL_SAVE_INTERVAL from the environment, floors it to >= 1
# so that `count % interval` cannot divide by zero. We hit the
# divide-by-zero shape on the Cursor PR review; this guards explicitly.
mempal_save_interval() {
local raw="${MEMPAL_SAVE_INTERVAL:-15}"
case "$raw" in
''|*[!0-9]*) printf '15'; return 0 ;;
esac
# Strip leading zeros. bash arithmetic ($((...))) parses any token
# starting with `0` as octal, so MEMPAL_SAVE_INTERVAL=08 would
# crash $((COUNT % INTERVAL)) with "value too great for base".
# Loop while the value still starts with 0 AND has length > 1, so
# the literal string "0" is preserved (then floored to 15 below).
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
raw="${raw#0}"
done
if [ "$raw" -lt 1 ] 2>/dev/null; then
printf '15'
return 0
fi
printf '%s' "$raw"
}
# ── Atomic counter write ──────────────────────────────────────────────
#
# Writes $value to $file via a same-directory temp file + `mv -f`.
# `mv` (rename) is atomic on a single filesystem, so a concurrent
# reader either sees the old contents or the new contents — never a
# half-written / truncated file. A plain `printf > file` truncates
# first and then writes, leaving a window where a concurrent Stop fire
# could read an empty / partial value. Concurrent fires for one
# conversation are unlikely (Antigravity serializes turns) but the
# previous "written atomically" comment was simply false; this makes
# it true.
#
# bash 3.2 safe. The temp lives in the same directory as the target so
# the rename stays on one filesystem (a cross-device mv would fall back
# to copy+unlink and lose atomicity). On any failure we degrade to a
# direct write rather than leaving the counter unwritten.
mempal_write_counter_atomic() {
local file="$1"
local value="$2"
local tmp
tmp="$(mktemp "${file}.XXXXXX" 2>/dev/null)" || {
printf '%s' "$value" > "$file"
return
}
printf '%s' "$value" > "$tmp"
mv -f "$tmp" "$file" 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
printf '%s' "$value" > "$file"
}
}
# ── State-file TTL ────────────────────────────────────────────────────
#
# Per-conversation state artifacts (antigravity_save_count_<conv>,
# antigravity_pending_<conv>, antigravity_woke_<conv>/) accumulate one
# set per conversation and are never otherwise removed. Reads
# MEMPAL_STATE_TTL_DAYS (default 30), validated digits-only and
# leading-zero-stripped like mempal_save_interval so `find -mtime`
# never sees a bad token. A value of 0 means "sweep aggressively"
# (everything older than today); we floor empty/garbage to 30.
mempal_state_ttl_days() {
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
case "$raw" in
''|*[!0-9]*) printf '30'; return 0 ;;
esac
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
raw="${raw#0}"
done
printf '%s' "$raw"
}
# ── Stale state GC ────────────────────────────────────────────────────
#
# Opportunistic sweep of per-conversation state older than the TTL.
# Gated to run at most once per 24h via the antigravity_last_sweep
# marker so it costs nothing on the vast majority of fires (a single
# mtime comparison). When it does run, three `find` passes remove the
# stale counter files, pending markers, and woke marker directories.
#
# The name globs are specific (antigravity_save_count_*, _pending_*,
# _woke_*), so the shared log files (antigravity_hook.log,
# antigravity_last_input.log, antigravity_last_python_err.log) and the
# antigravity_last_sweep marker itself are never touched. BSD find
# (macOS default) and GNU find both accept `-maxdepth`, `-mtime +N`,
# and `-exec ... +`.
#
# Fail-open: every step is best-effort; a missing state dir, a find
# that errors, or a permission problem must never abort the caller.
mempal_gc_stale_state() {
[ -d "$MEMPAL_STATE_DIR" ] || return 0
local marker="$MEMPAL_STATE_DIR/antigravity_last_sweep"
if [ -f "$marker" ]; then
local mtime now
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
&& now=$(date '+%s' 2>/dev/null) \
&& [ -n "$mtime" ] \
&& [ "$((now - mtime))" -lt 86400 ]; then
return 0
fi
fi
# Touch the marker first so a crash mid-sweep still throttles the
# next fire (better to skip a sweep than to hammer the disk).
: > "$marker" 2>/dev/null
local ttl
ttl=$(mempal_state_ttl_days)
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
-name 'antigravity_save_count_*' -mtime +"$ttl" \
-exec rm -f {} + 2>/dev/null
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
-name 'antigravity_pending_*' -mtime +"$ttl" \
-exec rm -f {} + 2>/dev/null
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type d \
-name 'antigravity_woke_*' -mtime +"$ttl" \
-exec rm -rf {} + 2>/dev/null
return 0
}
# ── Fail-open emitters ────────────────────────────────────────────────
#
# Every code path in both hooks must terminate by calling exactly one
# of these emitters. Stdout is JSON. Exit status is always 0 — the hook
# never blocks the user's IDE on its own failure (constraint #2).
#
# CRITICAL: mempal_emit_stop_pass MUST NEVER emit
# {"decision":"continue"} — that would force the agent to keep running
# instead of letting the turn end. Antigravity treats any value other
# than "continue" (including `{}`) as "allow the stop". We enforce this
# by hard-coding the empty object output here.
mempal_emit_stop_pass() {
printf '{}\n'
}
mempal_emit_wake_inject() {
local message="$1"
if [ -z "$message" ]; then
printf '{}\n'
return 0
fi
# Encode the message as JSON via Python so embedded quotes / newlines
# / control chars don't corrupt the output.
"$MEMPAL_PYTHON_BIN" -c "
import json, sys
msg = sys.argv[1]
print(json.dumps({'injectSteps': [{'ephemeralMessage': msg}]}))
" "$message" 2>/dev/null || printf '{}\n'
}
+245
View File
@@ -0,0 +1,245 @@
#!/bin/bash
# MEMPALACE ANTIGRAVITY SAVE HOOK — Stop event handler
#
# Antigravity fires the Stop event each time the agent's execution loop
# terminates. We use it to background-mine the active conversation
# transcript every Nth save into the user's MemPalace, and to write a
# diary checkpoint via `mempalace mine --mode convos`.
#
# Mirrors the Claude Code (hooks/mempal_save_hook.sh) and Codex
# (.codex-plugin/hooks/mempal-hook.sh) integrations as closely as the
# Antigravity stdin/stdout contract allows. Differences:
#
# * Antigravity stdin uses camelCase: conversationId, transcriptPath,
# workspacePaths, executionNum, terminationReason, fullyIdle.
# * Antigravity stdout MUST be `{}` on every code path. Emitting
# `{"decision":"continue"}` would force the agent to keep running
# and create an infinite loop. We never call mempal_emit_stop_pass
# with anything other than the literal empty object.
# * Counter file is namespaced antigravity_save_count_<conversationId>
# to coexist with Claude Code / Cursor / Codex state in the same
# ~/.mempalace/hook_state/ directory.
#
# === STDIN (verified, camelCase) ===
# {
# "executionNum": 1,
# "terminationReason": "model_stop",
# "error": "",
# "fullyIdle": true,
# "conversationId": "<uuid>",
# "workspacePaths": ["/abs/path/..."],
# "transcriptPath": "/abs/path/transcript.jsonl",
# "artifactDirectoryPath": "/abs/path/artifacts/"
# }
#
# === STDOUT (always) ===
# {}
#
# `set -e` is intentionally NOT enabled — a broken hook must not block
# the user's conversation (constraint #2 in the integration brief).
# ── Locate this script + source common helpers ───────────────────────
MEMPAL_AGY_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=lib/common.sh
. "$MEMPAL_AGY_HOOK_DIR/lib/common.sh"
# ── Read all of stdin once ───────────────────────────────────────────
INPUT=$(cat)
# ── Kill switch: short-circuit cleanly if disabled ───────────────────
if mempal_kill_switch_tripped; then
mempal_emit_stop_pass
exit 0
fi
# ── Opportunistic GC of stale per-conversation state ─────────────────
#
# Self-throttled to at most once per 24h (see mempal_gc_stale_state),
# so this is a single mtime check on the overwhelming majority of
# fires. Runs after the kill switch so a disabled hook touches nothing.
mempal_gc_stale_state
# ── Parse stdin (camelCase, sentinel-guarded) ────────────────────────
_parsed=$(mempal_parse_stdin "$INPUT")
_marker=$(printf '%s\n' "$_parsed" | sed -n '1p')
CONVERSATION_ID=$(printf '%s\n' "$_parsed" | sed -n '2p')
TRANSCRIPT_PATH=$(printf '%s\n' "$_parsed" | sed -n '3p')
WORKSPACE_PATH=$(printf '%s\n' "$_parsed" | sed -n '4p')
# Line 5 (artifactDirectoryPath) is parsed but unused for save. Skip.
EXECUTION_NUM=$(printf '%s\n' "$_parsed" | sed -n '6p')
# Line 7 (terminationReason) is parsed but used only for logging.
TERMINATION_REASON=$(printf '%s\n' "$_parsed" | sed -n '7p')
FULLY_IDLE=$(printf '%s\n' "$_parsed" | sed -n '8p')
# ── Defense-in-depth: surface raw input on parse failure ─────────────
#
# When the sentinel is missing, Python crashed before reaching its
# print() calls. Persist the offending payload (capped at 4 KB, mode
# 0600) so the next debugger doesn't lose a day to log lines that say
# "Session unknown".
if [ -n "$INPUT" ] && [ "$_marker" != "__MEMPAL_PARSE_OK__" ]; then
mempal_log "stop" "unknown" "input parse failed (sentinel missing); see antigravity_last_input.log + antigravity_last_python_err.log"
(
umask 077
printf '%s' "$INPUT" | head -c 4096 > "$MEMPAL_STATE_DIR/antigravity_last_input.log"
)
chmod 600 "$MEMPAL_STATE_DIR/antigravity_last_input.log" 2>/dev/null
# Continue with empty fields; the validators below will reject.
fi
CONVERSATION_ID="${CONVERSATION_ID:-unknown}"
TRANSCRIPT_PATH="${TRANSCRIPT_PATH:-}"
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
EXECUTION_NUM="${EXECUTION_NUM:-0}"
TERMINATION_REASON="${TERMINATION_REASON:-}"
FULLY_IDLE="${FULLY_IDLE:-False}"
# Expand ~ in the transcript path
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
# ── Bail when fullyIdle is False ─────────────────────────────────────
#
# If background commands or async tasks are still running, the
# transcript is still in motion. Defer the save until the next Stop
# event when the agent is fully done — better to skip than to ingest a
# half-finished transcript and pollute the search index.
if [ "$FULLY_IDLE" != "True" ]; then
mempal_log "stop" "$CONVERSATION_ID" "deferring save: fullyIdle=False (executionNum=$EXECUTION_NUM, terminationReason=$TERMINATION_REASON)"
mempal_emit_stop_pass
exit 0
fi
# ── Skip when terminationReason is `error` ───────────────────────────
#
# A model error termination usually means the transcript is corrupt or
# truncated. Don't ingest noise.
if [ "$TERMINATION_REASON" = "error" ]; then
mempal_log "stop" "$CONVERSATION_ID" "skipping save: terminationReason=error"
mempal_emit_stop_pass
exit 0
fi
# ── Increment counter (per conversation) ─────────────────────────────
#
# The counter is a single integer, written via mempal_write_counter_atomic
# (same-dir temp + `mv`, which is an atomic rename on one filesystem).
# Concurrent Stop fires for the same conversation are unlikely
# (Antigravity serializes turns), but the atomic write means a
# concurrent reader always sees a complete value rather than a
# half-written / truncated file. The integer-only validation on read
# is a second guard: any garbled value resets the count to 0.
COUNTER_FILE="$MEMPAL_STATE_DIR/antigravity_save_count_${CONVERSATION_ID}"
COUNT=0
if [ -f "$COUNTER_FILE" ]; then
raw=$(cat "$COUNTER_FILE" 2>/dev/null)
case "$raw" in
''|*[!0-9]*) COUNT=0 ;;
*) COUNT="$raw" ;;
esac
fi
COUNT=$((COUNT + 1))
mempal_write_counter_atomic "$COUNTER_FILE" "$COUNT"
INTERVAL=$(mempal_save_interval)
mempal_log "stop" "$CONVERSATION_ID" "count=$COUNT interval=$INTERVAL executionNum=$EXECUTION_NUM workspace=$WORKSPACE_PATH"
# ── Modulo gate ──────────────────────────────────────────────────────
#
# `count % interval == 0` triggers a save. INTERVAL has been floored to
# >= 1 by mempal_save_interval, so the modulo cannot divide by zero
# even if the user explicitly set MEMPAL_SAVE_INTERVAL=0 or empty.
if [ $((COUNT % INTERVAL)) -ne 0 ]; then
mempal_emit_stop_pass
exit 0
fi
# ── Pending-marker guard ─────────────────────────────────────────────
#
# If a previous save is still running (the marker file exists), skip
# this fire. The mine subprocess removes the marker on exit, but a
# crashed mine could leave the marker forever — guard against that by
# treating markers older than 1 hour as stale and reclaiming them.
PENDING_FILE="$MEMPAL_STATE_DIR/antigravity_pending_${CONVERSATION_ID}"
if [ -f "$PENDING_FILE" ]; then
# mtime in epoch seconds (portable; BSD/macOS `date -r` takes epoch, not a path).
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$PENDING_FILE" 2>/dev/null) \
&& now=$(date '+%s') \
&& [ -n "$mtime" ] \
&& [ "$((now - mtime))" -lt 3600 ]; then
mempal_log "stop" "$CONVERSATION_ID" "pending save still in flight; skipping"
mempal_emit_stop_pass
exit 0
fi
mempal_log "stop" "$CONVERSATION_ID" "stale pending marker reclaimed"
rm -f "$PENDING_FILE" 2>/dev/null
fi
# ── Validate transcript path ─────────────────────────────────────────
if ! mempal_is_valid_transcript_path "$TRANSCRIPT_PATH"; then
mempal_log "stop" "$CONVERSATION_ID" "invalid transcriptPath rejected: $TRANSCRIPT_PATH"
mempal_emit_stop_pass
exit 0
fi
if [ ! -f "$TRANSCRIPT_PATH" ]; then
mempal_log "stop" "$CONVERSATION_ID" "transcriptPath does not exist: $TRANSCRIPT_PATH"
mempal_emit_stop_pass
exit 0
fi
# ── Trigger save ─────────────────────────────────────────────────────
WING=$(mempal_infer_wing "$WORKSPACE_PATH")
TRANSCRIPT_DIR=$(dirname "$TRANSCRIPT_PATH")
mempal_log "stop" "$CONVERSATION_ID" "TRIGGERING SAVE wing=$WING transcript_dir=$TRANSCRIPT_DIR"
# Drop the pending marker BEFORE spawning so a near-simultaneous fire
# sees it. If the spawn fails, remove the marker so the next fire can
# retry.
: > "$PENDING_FILE" 2>/dev/null
# Detach EVERYTHING heavy into a single background subshell: the
# runnability probe, the mine itself, and the pending-marker cleanup.
# The foreground returns immediately after spawning, so the hook's
# stdout (`{}`) reaches Antigravity within milliseconds.
#
# Why the probe must NOT run in the foreground: `mempalace --version`
# is NOT cheap. Building the `mine` argument parser imports
# `mempalace.miner` (-> palace -> backends -> chromadb/onnx) before
# argparse ever processes `--version`, so the probe pays the full
# cold-start import cost. Running it in the foreground would block the
# hook for that entire import and blow the <500ms save budget. Moving
# it inside the backgrounded subshell keeps the foreground instant.
#
# Folding the cleanup into this same subshell also removes the need for
# a separate process-liveness polling watcher: the `rm -f
# "$PENDING_FILE"` simply runs after the mine returns, in the same
# shell that owns the mine — no sibling-PID `wait` hazard, no polling
# loop.
#
# We invoke mempalace as `"$MEMPAL_PYTHON_BIN" -m mempalace` rather than
# the bare `mempalace` console script so a user with the package
# installed only inside a venv (and the venv's bin/ not on the hook's
# PATH, e.g. `uv tool install` in some distributions, or a manually
# managed virtualenv) still hits a working mine. MEMPAL_PYTHON honours
# user override; sees ``mempalace/__main__.py`` which dispatches to
# ``mempalace.cli:main`` — identical to the console script.
mempal_log "stop" "$CONVERSATION_ID" "spawning background mine wing=$WING transcript_dir=$TRANSCRIPT_DIR"
(
if "$MEMPAL_PYTHON_BIN" -m mempalace --version >/dev/null 2>&1; then
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$TRANSCRIPT_DIR" \
--mode convos \
--wing "$WING" \
>> "$MEMPAL_AGY_LOG" 2>&1 < /dev/null
mempal_log "stop" "$CONVERSATION_ID" "background mine finished wing=$WING"
else
mempal_log "stop" "$CONVERSATION_ID" "ERROR: mempalace is not runnable via $MEMPAL_PYTHON_BIN -m mempalace; install mempalace or set MEMPAL_PYTHON"
fi
rm -f "$PENDING_FILE" 2>/dev/null
) >/dev/null 2>&1 < /dev/null &
# ── Always emit `{}` ─────────────────────────────────────────────────
#
# Never `{"decision":"continue"}`. That would force the agent into an
# infinite re-execution loop. mempal_emit_stop_pass hard-codes `{}`.
mempal_emit_stop_pass
exit 0
+166
View File
@@ -0,0 +1,166 @@
#!/bin/bash
# MEMPALACE ANTIGRAVITY WAKE HOOK — PreInvocation event handler
#
# Antigravity fires the PreInvocation event before every model
# invocation, with `invocationNum` carrying the sequence number of the
# call. We use the first invocation (invocationNum == 1) as our
# session-start equivalent and inject a verbatim memory pointer into
# the agent's context via the `injectSteps[].ephemeralMessage` output
# field — the message lives for one turn and does not persist into the
# transcript, so it doesn't pollute future invocations of this same
# conversation.
#
# === STDIN (verified, camelCase) ===
# {
# "invocationNum": 1,
# "initialNumSteps": 0,
# "conversationId": "<uuid>",
# "workspacePaths": ["/abs/path/..."],
# "transcriptPath": "/abs/path/transcript.jsonl",
# "artifactDirectoryPath": "/abs/path/artifacts/"
# }
#
# === STDOUT ===
# Either:
# {} — no injection
# Or:
# {"injectSteps":[{"ephemeralMessage":"..."}]} — verbatim memory pointer
#
# Verbatim guarantee: the ephemeralMessage carries the exact text
# emitted by `mempalace wake-up`, never paraphrased or summarized.
#
# Performance budget: the integration brief sets a 100ms ceiling for
# startup injection. We enforce a 500ms hard timeout on the
# `mempalace wake-up` subprocess (more generous than 100ms because
# cold ChromaDB connections can dominate, and missing the budget is
# strictly better than blocking the user) — if it doesn't return in
# time we emit `{}` and let the conversation start without injection.
#
# `set -e` is intentionally NOT enabled — fail-open is mandatory.
# ── Locate this script + source common helpers ───────────────────────
MEMPAL_AGY_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
# shellcheck source=lib/common.sh
. "$MEMPAL_AGY_HOOK_DIR/lib/common.sh"
# ── Read all of stdin once ───────────────────────────────────────────
INPUT=$(cat)
# ── Kill switch ──────────────────────────────────────────────────────
if mempal_kill_switch_tripped; then
mempal_emit_stop_pass
exit 0
fi
# ── Parse stdin ──────────────────────────────────────────────────────
_parsed=$(mempal_parse_stdin "$INPUT")
_marker=$(printf '%s\n' "$_parsed" | sed -n '1p')
CONVERSATION_ID=$(printf '%s\n' "$_parsed" | sed -n '2p')
# Lines 3-5 (transcriptPath, workspacePath, artifactDirectoryPath) are
# parsed; we use workspacePath for wing inference. transcriptPath and
# artifactDirectoryPath are unused by the wake flow.
# Line 4: workspacePath
WORKSPACE_PATH=$(printf '%s\n' "$_parsed" | sed -n '4p')
INVOCATION_NUM=$(printf '%s\n' "$_parsed" | sed -n '9p')
# Defense-in-depth on parse failure
if [ -n "$INPUT" ] && [ "$_marker" != "__MEMPAL_PARSE_OK__" ]; then
mempal_log "preInvocation" "unknown" "input parse failed (sentinel missing)"
mempal_emit_stop_pass
exit 0
fi
CONVERSATION_ID="${CONVERSATION_ID:-unknown}"
WORKSPACE_PATH="${WORKSPACE_PATH:-}"
INVOCATION_NUM="${INVOCATION_NUM:-0}"
# ── Gate: only inject on the FIRST invocation ────────────────────────
#
# PreInvocation fires before every model call. Without this gate we'd
# inject memory on every single turn — both expensive and visually
# noisy. invocationNum == 1 means "first model call of this
# conversation", which is the closest thing Antigravity exposes to
# Cursor's `sessionStart`.
if [ "$INVOCATION_NUM" != "1" ]; then
mempal_emit_stop_pass
exit 0
fi
# ── Loop guard ───────────────────────────────────────────────────────
#
# Defense in depth: even within the first invocation, we only ever
# want to inject once per conversation. mkdir is atomic and works on
# bash 3.2 / macOS / Linux without flock or other GNU coreutils
# extensions.
WOKE_MARKER="$MEMPAL_STATE_DIR/antigravity_woke_${CONVERSATION_ID}"
if ! mkdir "$WOKE_MARKER" 2>/dev/null; then
mempal_log "preInvocation" "$CONVERSATION_ID" "already woke this conversation; skipping"
mempal_emit_stop_pass
exit 0
fi
# ── Run wake-up with a hard timeout ──────────────────────────────────
#
# `timeout` is GNU coreutils — present on most Linux installs but
# missing from stock macOS. Wrap the subprocess in a Python timeout
# (subprocess.run(timeout=...)) which is cross-platform. The Python
# script also constructs the final JSON envelope for stdout, so the
# bash side just passes the result through.
WING=$(mempal_infer_wing "$WORKSPACE_PATH")
mempal_log "preInvocation" "$CONVERSATION_ID" "WAKE injection wing=$WING invocationNum=$INVOCATION_NUM"
OUTPUT=$("$MEMPAL_PYTHON_BIN" -c "
import json, subprocess, sys
wing = sys.argv[1]
timeout_s = 0.5 # 500 ms
# Invoke as ``[sys.executable, '-m', 'mempalace', ...]`` rather than
# the bare ``mempalace`` console script. sys.executable is the same
# Python that resolved MEMPAL_PYTHON in lib/common.sh, so this binds
# the wake-up call to the correct interpreter (and its installed
# mempalace package) even when the venv's bin/ isn't on PATH.
try:
completed = subprocess.run(
[sys.executable, '-m', 'mempalace', 'wake-up', '--wing', wing],
capture_output=True,
text=True,
timeout=timeout_s,
)
if completed.returncode != 0:
print('{}')
sys.exit(0)
body = (completed.stdout or '').strip()
if not body:
print('{}')
sys.exit(0)
# Verbatim — pass the wake-up text exactly as emitted, wrapped in
# the Antigravity injectSteps envelope. json.dumps escapes embedded
# control chars and quotes correctly.
print(json.dumps({'injectSteps': [{'ephemeralMessage': body}]}))
except FileNotFoundError:
print('{}')
except subprocess.TimeoutExpired:
print('{}')
except Exception:
print('{}')
" "$WING" 2>/dev/null)
if [ -z "$OUTPUT" ]; then
OUTPUT='{}'
fi
# Sanity-check: never emit `decision` from a PreInvocation hook (that
# field belongs to the Stop event). The Python helper only ever
# constructs `{"injectSteps": [...]}` or `{}`, so this is belt-and-
# suspenders against a future edit ever leaking a Stop-shaped object.
case "$OUTPUT" in
*\"decision\"*)
mempal_log "preInvocation" "$CONVERSATION_ID" "ERROR: refused to emit decision field from wake hook"
mempal_emit_stop_pass
exit 0
;;
esac
printf '%s\n' "$OUTPUT"
exit 0
+157
View File
@@ -0,0 +1,157 @@
# MemPalace Cursor IDE Hooks
Auto-save and session-recall hooks for the [Cursor](https://cursor.com) IDE,
matching the behaviour of the existing Claude Code + Codex hooks at the repo
root and adding two Cursor-only capabilities (`sessionStart` recall and a
preCompact transcript snapshot).
For the rendered documentation see
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) or
the published version at
[mempalaceofficial.com/guide/cursor-hooks](https://mempalaceofficial.com/guide/cursor-hooks.html).
## What's here
| File | Role |
|-------------------------------------|---------------------------------------------------------------------|
| `lib/common.sh` | Shared bash helpers (parse, log, counter, wing inference, kill switch). Sourced by all three hooks. |
| `mempal_save_hook_cursor.sh` | Cursor `stop` hook. Counts stop invocations per conversation, emits a `followup_message` every `SAVE_INTERVAL` (default 15) telling the agent to file the session into MemPalace. |
| `mempal_precompact_hook_cursor.sh` | Cursor `preCompact` hook. Runs `mempalace mine` synchronously on the transcript before compaction, then drops a `.pending` marker so the next stop forces a save nudge. |
| `mempal_wake_hook_cursor.sh` | Cursor `sessionStart` hook. Returns `additional_context` telling the agent to recall scoped to the wing inferred from the workspace root. Cursor-only — Claude Code has no equivalent. |
| `install.sh` | Optional installer. Copies the scripts to `~/.mempalace/hooks/cursor/` and merges entries into `~/.cursor/hooks.json` (or `.cursor/hooks.json` for project scope). Supports `--dry-run` and `--uninstall`. |
| `STDIN_SHAPE.md` | Reference. Per-event stdin / stdout schema with citations to the official Cursor docs. |
## Quick install
Preview first (writes nothing, prints the would-be JSON to stdout):
```bash
hooks/cursor/install.sh --scope user --dry-run
```
Apply — writes `~/.cursor/hooks.json` and copies the scripts to `~/.mempalace/hooks/cursor/`:
```bash
hooks/cursor/install.sh --scope user
```
Pass `--scope project --target <repo>` to write `<repo>/.cursor/hooks.json` instead.
The installer never auto-runs — it is a documented opt-in step. We do not
modify your Cursor config on `pip install mempalace` because editor config
is sacred and should never be touched without explicit consent.
## Manual install (no installer)
The minimum wiring is `stop` only. Add to `~/.cursor/hooks.json`:
```json
{
"version": 1,
"hooks": {
"stop": [
{
"command": "/absolute/path/to/hooks/cursor/mempal_save_hook_cursor.sh",
"loop_limit": 1
}
]
}
}
```
For the full triple (recommended), also wire `sessionStart` and `preCompact`
— see [`examples/cursor/hooks.json`](../../examples/cursor/hooks.json).
After editing the file, Cursor watches `hooks.json` and reloads
automatically. If hooks still do not fire, restart Cursor and check the
Hooks panel in Settings.
## Configuration
All knobs are env vars; defaults match the Claude Code hooks where
possible so a single hook-state directory works for both editors.
| Variable | Default | Purpose |
|--------------------------------|------------------------------------|---------|
| `MEMPAL_SAVE_INTERVAL` | `15` | Number of `stop` events between save followups. |
| `MEMPAL_CURSOR_SILENT` | (unset) | Set to `1`/`true`/`yes` to suppress the `followup_message`. The hook still runs its best-effort background mine and keeps its counters — it just stays silent. `MEMPAL_VERBOSE=false`/`0`/`no` does the same. See note below on why the followup is on by default. |
| `MEMPAL_DIR` | (unset) | Optional project directory to also mine on each save. Additive — never replaces the transcript mine. |
| `MEMPAL_PYTHON` | auto-detected | Path to a Python 3 interpreter. Fallback order: `$MEMPAL_PYTHON` → `command -v python3` → bare `python3`. Useful when Cursor is launched from a GUI on macOS and the inherited PATH lacks your installed `python3`. |
| `MEMPAL_STATE_DIR` | `$HOME/.mempalace/hook_state` | Where the hook keeps its per-conversation counter files, pending-save markers, and `cursor_hook.log`. |
| `MEMPAL_STATE_TTL_DAYS` | `30` | Age (days) after which stale `cursor_*.count` / `cursor_*.pending` state files are garbage-collected. A daily-throttled sweep runs from the hooks; only Cursor state is touched (shared logs and other editors' state are left alone). |
| `MEMPAL_DISABLE_HOOK` | (unset) | Set to `1`/`true`/`yes` to disable all three hooks. Emergency kill switch. |
| `MEMPALACE_HOOKS_AUTO_SAVE` | (unset) | Set to `false`/`0`/`no` to disable. Same semantics as the Claude Code hooks. Also honoured via `~/.mempalace/config.json` → `{"hooks": {"auto_save": false}}`. |
## Debugging
Everything appends to:
```bash
cat ~/.mempalace/hook_state/cursor_hook.log
```
Example log lines (ISO 8601 + event + conversation id):
```
[2026-05-27T02:16:01Z] [event=sessionStart] [conv=abc123] workspace=/Users/me/proj wing=proj
[2026-05-27T02:21:33Z] [event=stop] [conv=abc123] counter 0 -> 1 (interval=15)
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] counter 14 -> 15 (interval=15)
[2026-05-27T02:42:09Z] [event=stop] [conv=abc123] TRIGGERING SAVE at counter=15
[2026-05-27T02:42:11Z] [event=stop] [conv=abc123] loop_count>0; letting agent stop
```
When a hook can't parse its stdin (corrupt payload, future Cursor schema
change), the raw input (capped at 4096 bytes, mode 0600) lands at:
```
~/.mempalace/hook_state/cursor_last_input.log
~/.mempalace/hook_state/cursor_last_python_err.log
```
These are overwritten on each failure, never appended, so a repeating
misconfiguration cannot grow disk usage.
## What differs from the Claude Code hooks
| Aspect | Claude Code hooks (`hooks/mempal_*.sh`) | Cursor hooks (`hooks/cursor/*.sh`) |
|-------------------------|------------------------------------------------|-----------------------------------------------------|
| Counter key | `session_id` | `conversation_id` (Cursor's stable per-conv id) |
| Loop guard | `stop_hook_active` flag in stdin | `loop_count` field in stdin |
| Counting method | Parses JSONL transcript for user messages | Counts `stop` invocations (transcript schema undoc) |
| Capture path | Background `mine --mode convos` (normalize.py has a Claude parser) | Background mine is best-effort (no Cursor parser); the `followup_message` carries verbatim capture |
| Save default | Silent — diary nudge opt-IN behind `MEMPAL_VERBOSE=true` | Followup ON by default; opt-OUT via `MEMPAL_CURSOR_SILENT=1` / `MEMPAL_VERBOSE=false` |
| PreCompact behaviour | `decision: block` forces save before compaction | Pre-mine + pending-save marker (Cursor preCompact is observational-only) |
| sessionStart | n/a (Claude Code has no equivalent) | `additional_context` injects recall guidance |
| State dir | `$HOME/.mempalace/hook_state` (hardcoded) | Same default, plus `MEMPAL_STATE_DIR` env override |
| Kill switch | `MEMPALACE_HOOKS_AUTO_SAVE=false` | Same, plus `MEMPAL_DISABLE_HOOK=1` alias |
| Log file | `hook.log` | `cursor_hook.log` (kept separate to avoid cross-tool log churn) |
See [`STDIN_SHAPE.md`](STDIN_SHAPE.md) for the per-event schema and
[`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for
the full walkthrough with diagrams.
## Why the followup is on by default (Cursor-specific)
Unlike the Claude Code hook — which is silent by default because its
background `mempalace mine --mode convos` captures the verbatim transcript
on its own — Cursor's transcript format is **undocumented** and
`mempalace/normalize.py` has **no Cursor parser**. The background mine on
the Cursor `stop`/`preCompact` hooks is therefore **best-effort only**: it
does not yet yield clean verbatim conversation drawers.
That makes the `followup_message` the **load-bearing verbatim-capture
path** for Cursor — it drives the agent to file its own in-context
verbatim quotes via `mempalace_add_drawer` / `mempalace_diary_write`.
Silencing it by default would leave a default Cursor install capturing
nothing, which is why it is on by default here. Set `MEMPAL_CURSOR_SILENT=1`
(or `MEMPAL_VERBOSE=false`) if you prefer the Claude-style silent
behaviour and accept the reduced capture. Once `normalize.py` learns to
read Cursor transcripts, this default will flip to silent to match Claude.
## Cost
Zero extra LLM tokens spent by the hooks themselves. The hooks are local
bash scripts that run on your machine. The followup message the save hook
emits is a normal user turn — it counts the same as any other user message
and does not invoke any extra LLM call beyond the one the user would
otherwise make. Suppress it with `MEMPAL_CURSOR_SILENT=1` if you want zero
followups in the chat window.
+184
View File
@@ -0,0 +1,184 @@
# Cursor Hook Stdin Shape — Reference
This file documents the JSON payloads the Cursor IDE sends to the
MemPalace hook scripts in `hooks/cursor/`. It exists so a future
contributor does not have to re-discover the schema by writing a
probe hook.
**Source:** [`cursor.com/docs/hooks.md`](https://cursor.com/docs/hooks.md),
fetched 2026-05-27. Cursor's hook system is documented as a stable
v1 schema (`{"version": 1, ...}` at the top of `hooks.json`).
If you suspect Cursor has changed the payload shape since that fetch
date, re-verify against the upstream docs and update both this file
and `hooks/cursor/lib/common.sh::mempal_parse_stdin`. The hook
scripts deliberately ignore fields they do not consume, so adding
new fields is non-breaking.
## Common fields (all events)
Every hook receives these on stdin in addition to its event-specific
fields. Source: docs section "Common schema → Input (all hooks)".
```json
{
"conversation_id": "string",
"generation_id": "string",
"model": "string",
"hook_event_name": "string",
"cursor_version": "string",
"workspace_roots": ["<absolute path>"],
"user_email": "string | null",
"transcript_path": "string | null"
}
```
**Field notes (verified):**
- `conversation_id` is the stable per-conversation ID. The Cursor
`stop` event does **not** carry a `session_id` — only
`conversation_id`. MemPalace keys its counter files on this. Cursor
`sessionStart` does carry a `session_id`, and the docs note it is
"same as `conversation_id`".
- `generation_id` changes every user message. We do not use it.
- `transcript_path` may be `null` if the user has disabled
transcripts in Cursor settings. The hooks degrade gracefully when
the value is empty.
- `workspace_roots` is normally a single-entry array but multi-root
workspaces are supported; MemPalace uses index `[0]`.
## Event-specific fields
### `stop` (consumed by `mempal_save_hook_cursor.sh`)
```json
{
"status": "completed" | "aborted" | "error",
"loop_count": 0
}
```
- `loop_count` indicates how many times this stop hook has already
triggered an automatic followup for this conversation (starts at
0). When `loop_count > 0` we know our own previous `followup_message`
is currently being processed — the save hook returns `{}` so the
agent can finish. Equivalent to Claude Code's `stop_hook_active`.
- The per-script `loop_limit` (default 5 for Cursor hooks, configurable
via the `loop_limit` field on the hook entry in `hooks.json`) is
defense-in-depth on top of our own check. The example `hooks.json`
in `examples/cursor/` sets `loop_limit: 1`.
**Allowed output fields** (only):
```json
{ "followup_message": "<text to auto-submit as next user turn>" }
```
### `preCompact` (consumed by `mempal_precompact_hook_cursor.sh`)
```json
{
"trigger": "auto" | "manual",
"context_usage_percent": 85,
"context_tokens": 120000,
"context_window_size": 128000,
"message_count": 45,
"messages_to_compact": 30,
"is_first_compaction": true
}
```
**Critical constraint:** preCompact is documented as **observational
only**. It cannot block compaction and its allowed output fields are
limited to:
```json
{ "user_message": "<short message shown to the user when compaction occurs>" }
```
There is **no** `followup_message` and **no** `decision: block` on
this event — unlike Claude Code's `PreCompact`. MemPalace works
around this by:
1. Running `mempalace mine` synchronously inside the hook so the
verbatim transcript lands in the palace before compaction
summarises it.
2. Dropping a `cursor_<conversation_id>.pending` marker that the next
`stop` invocation reads and uses to force a save followup
regardless of its counter.
### `sessionStart` (consumed by `mempal_wake_hook_cursor.sh`)
```json
{
"session_id": "<unique session identifier>",
"is_background_agent": true,
"composer_mode": "agent" | "ask" | "edit"
}
```
`session_id` equals `conversation_id` on this event (docs are
explicit about this).
**Allowed output fields:**
```json
{
"env": { "<key>": "<value>" },
"additional_context": "<text added to conversation's initial system context>"
}
```
`additional_context` is the field MemPalace uses. The schema also
accepts `continue` and `user_message` but the docs explicitly note
"current callers do not enforce them; session creation is not
blocked even when continue is false". We do not emit either.
## Environment variables (all hooks)
Cursor sets these env vars on every hook execution; the hook scripts
fall back to them when JSON parsing fails for any reason.
| Variable | Description |
|---------------------------|---------------------------------------------------|
| `CURSOR_PROJECT_DIR` | Workspace root (= `workspace_roots[0]`) |
| `CURSOR_VERSION` | Cursor version string |
| `CURSOR_USER_EMAIL` | Authenticated user email (if logged in) |
| `CURSOR_TRANSCRIPT_PATH` | Conversation transcript path (if transcripts on) |
| `CURSOR_CODE_REMOTE` | `"true"` if running in a remote workspace |
| `CLAUDE_PROJECT_DIR` | Alias for `CURSOR_PROJECT_DIR` (Claude compat) |
## Exit code semantics
Cursor interprets command-hook exit codes as follows
(docs "Hook Types → Command-Based Hooks → Exit code behavior"):
- `0` — success, use the JSON output.
- `2` — block the action (equivalent to `permission: "deny"`).
- Other — hook failed; action proceeds (fail-open by default).
MemPalace hooks always exit `0` and emit either `{}` (no-op) or a
valid JSON response. We never use exit code `2`; nothing MemPalace
does should ever block an agent action.
## Working directory contract
- **User hooks** (`~/.cursor/hooks.json`) run from `~/.cursor/`.
- **Project hooks** (`.cursor/hooks.json`) run from the project root.
The MemPalace hooks always resolve their sibling `lib/common.sh` via
`BASH_SOURCE[0]` so the working directory does not matter for the
script's own loading — only the `command` path in `hooks.json` needs
to point at the absolute location of the script.
## Transcript file format (out of scope)
The format of the file at `transcript_path` is **not documented by
Cursor** as of the fetch date above. MemPalace deliberately does not
parse it: the save hook counts `stop` invocations (each one
corresponds to one assistant turn) and hands the transcript to
`mempalace mine`, which has its own normaliser layer.
If you need to consume the transcript directly, probe its shape with
a throw-away hook that does `cat > /tmp/cursor-transcript-sample.txt`
and inspect the output — there is no shortcut.
+399
View File
@@ -0,0 +1,399 @@
#!/bin/bash
# MEMPALACE CURSOR HOOK INSTALLER
#
# Optional helper. Copies the three Cursor hook scripts to a
# stable install location and merges entries into a Cursor
# `hooks.json` config file — without clobbering unrelated hooks
# already in that file.
#
# This is NEVER auto-invoked. Editor config is sacred; we do not
# modify a user's hooks.json without explicit consent. The user runs
# this script (or wires the hooks manually) as a documented opt-in.
#
# === USAGE ===
#
# hooks/cursor/install.sh [options]
#
# Options:
# --scope user|project Target scope. Default: user.
# - user: merges into ~/.cursor/hooks.json
# - project: merges into <target>/.cursor/hooks.json
# --target <path> Project root for --scope project (default: $PWD).
# Ignored for --scope user.
# --install-dir <path> Where to copy the hook scripts.
# Default: ~/.mempalace/hooks/cursor
# --variant full|minimal Which hook set to wire.
# - full: stop + preCompact + sessionStart
# - minimal: stop only
# Default: full.
# --dry-run Print the would-be JSON to stdout, do not write
# and do not copy scripts.
# --uninstall Remove MemPalace entries from the target
# hooks.json (preserves unrelated hooks).
# Does NOT delete the installed scripts.
# -h, --help Show this help and exit.
#
# === PORTABILITY ===
#
# Pure bash 3.2 + POSIX tools + python3 (which the hook scripts
# themselves already require). No `jq` dependency.
#
# Python helpers are materialised to temp files rather than piped via
# `$(... <<'PYEOF' ... PYEOF)` to dodge the bash 3.2.57 parser bug
# that trips on parens nested inside a heredoc body that lives inside
# a `$(...)` command substitution.
set -e
usage() {
sed -n '2,38p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
# ── Defaults ──────────────────────────────────────────────────────
SCOPE="user"
TARGET=""
INSTALL_DIR="$HOME/.mempalace/hooks/cursor"
VARIANT="full"
DRY_RUN=0
UNINSTALL=0
# ── Parse args ────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--scope)
shift
SCOPE="${1:-}"
;;
--target)
shift
TARGET="${1:-}"
;;
--install-dir)
shift
INSTALL_DIR="${1:-}"
;;
--variant)
shift
VARIANT="${1:-}"
;;
--dry-run) DRY_RUN=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help)
usage
exit 0
;;
*)
printf 'install.sh: unknown argument: %s\n' "$1" >&2
usage >&2
exit 64
;;
esac
shift || true
done
case "$SCOPE" in
user|project) ;;
*)
printf 'install.sh: --scope must be "user" or "project" (got "%s")\n' \
"$SCOPE" >&2
exit 64
;;
esac
case "$VARIANT" in
full|minimal) ;;
*)
printf 'install.sh: --variant must be "full" or "minimal" (got "%s")\n' \
"$VARIANT" >&2
exit 64
;;
esac
# ── Resolve paths ─────────────────────────────────────────────────
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
SOURCE_DIR="$_script_dir"
# Resolve --install-dir to an absolute path before it gets baked into
# hooks.json. Cursor invokes hook commands from its own working
# directory (typically the project root), so a relative command path
# would silently fail to launch the hook. gh-PR review caught this.
case "$INSTALL_DIR" in
/*) ;;
*) INSTALL_DIR="$PWD/$INSTALL_DIR" ;;
esac
# Resolve the Python interpreter the same way the hooks themselves do
# so a user with a non-default Python is consistent across install +
# runtime.
if [ -n "${MEMPAL_PYTHON:-}" ] && [ -x "$MEMPAL_PYTHON" ]; then
PYTHON_BIN="$MEMPAL_PYTHON"
else
PYTHON_BIN="$(command -v python3 2>/dev/null || true)"
fi
if [ -z "$PYTHON_BIN" ]; then
printf 'install.sh: python3 not found on PATH; cannot proceed.\n' >&2
printf 'Set $MEMPAL_PYTHON to an interpreter path or install python3.\n' >&2
exit 1
fi
# Determine the target hooks.json path.
case "$SCOPE" in
user)
if [ -n "$TARGET" ]; then
printf 'install.sh: --target is only meaningful with --scope project; ignoring.\n' >&2
fi
TARGET_DIR="$HOME/.cursor"
;;
project)
TARGET_DIR="${TARGET:-$PWD}/.cursor"
;;
esac
TARGET_FILE="$TARGET_DIR/hooks.json"
# Determine which commands the merge / uninstall logic should
# install or remove. Paths point at the install location, NOT the
# source repo — once the user runs install.sh they can move / delete
# the cloned repo without breaking the wiring.
SAVE_CMD="$INSTALL_DIR/mempal_save_hook_cursor.sh"
PRECOMPACT_CMD="$INSTALL_DIR/mempal_precompact_hook_cursor.sh"
WAKE_CMD="$INSTALL_DIR/mempal_wake_hook_cursor.sh"
# ── Step 1: copy scripts (skipped on --dry-run / --uninstall) ─────
if [ "$UNINSTALL" -eq 0 ] && [ "$DRY_RUN" -eq 0 ]; then
mkdir -p "$INSTALL_DIR/lib"
cp "$SOURCE_DIR/lib/common.sh" "$INSTALL_DIR/lib/common.sh"
cp "$SOURCE_DIR/mempal_save_hook_cursor.sh" "$INSTALL_DIR/"
cp "$SOURCE_DIR/mempal_precompact_hook_cursor.sh" "$INSTALL_DIR/"
cp "$SOURCE_DIR/mempal_wake_hook_cursor.sh" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR/mempal_save_hook_cursor.sh" \
"$INSTALL_DIR/mempal_precompact_hook_cursor.sh" \
"$INSTALL_DIR/mempal_wake_hook_cursor.sh"
printf 'install.sh: copied scripts to %s\n' "$INSTALL_DIR" >&2
fi
# ── Short-circuit: uninstall with no existing file is a no-op ─────
#
# Without this, the merge step would happily write an empty
# {"version": 1, "hooks": {}} to a brand new file that the user
# never asked us to create — surprising behaviour that the test
# suite explicitly guards against.
if [ "$UNINSTALL" -eq 1 ] && [ ! -f "$TARGET_FILE" ]; then
printf 'install.sh: nothing to uninstall (%s does not exist)\n' \
"$TARGET_FILE" >&2
exit 0
fi
# ── Step 2: merge / unmerge hooks.json via python3 ────────────────
#
# Materialise the merge logic to a temp .py file (see bash 3.2
# rationale at the top of this script), then invoke it. The Python
# script is responsible for:
# * tolerating a missing or empty hooks.json (starts from {})
# * preserving unrelated hook entries on install
# * preserving unrelated hook entries on uninstall
# * recognising MemPalace entries by basename in the `command` field
# * idempotent install (re-running does not duplicate entries)
# mktemp portability: pass an explicit absolute template so we sidestep
# the BSD vs GNU difference in `-t` semantics (BSD treats it as a
# prefix; GNU treats it as a template). Honour TMPDIR if set, fall
# back to /tmp. gh-PR review caught the previous `-t` form as
# non-portable.
MERGE_PY="$(mktemp "${TMPDIR:-/tmp}/mempal-install-merge.XXXXXX")"
trap 'rm -f "$MERGE_PY"' EXIT
cat > "$MERGE_PY" <<'PYEOF'
"""hooks.json merge helper for hooks/cursor/install.sh.
Argv:
sys.argv[1]: path to hooks.json (may not exist)
sys.argv[2]: variant ("full" or "minimal")
sys.argv[3]: uninstall flag ("1" or "0")
sys.argv[4]: save_cmd absolute path
sys.argv[5]: precompact_cmd absolute path
sys.argv[6]: wake_cmd absolute path
Output: prints the merged JSON to stdout. Exits 2 on a malformed
existing config (refuses to overwrite a broken file).
"""
import json
import os
import sys
target_file = sys.argv[1]
variant = sys.argv[2]
uninstall = sys.argv[3] == "1"
save_cmd = sys.argv[4]
precompact_cmd = sys.argv[5]
wake_cmd = sys.argv[6]
# Recognise our entries by basename. The three filenames below are
# the unique product-of-our-naming convention; any entry whose command
# ends in one of them is treated as a MemPalace entry on
# install (so we replace rather than duplicate it) and on uninstall
# (so we remove it without touching unrelated entries). Matching on
# basename rather than a full-path substring lets users pick any
# --install-dir without breaking uninstall.
MEMPAL_BASENAMES = (
"mempal_save_hook_cursor.sh",
"mempal_precompact_hook_cursor.sh",
"mempal_wake_hook_cursor.sh",
)
if os.path.exists(target_file):
with open(target_file, "r", encoding="utf-8") as fh:
try:
cfg = json.load(fh)
except Exception as exc:
sys.stderr.write(
"install.sh: existing %s is not valid JSON: %s\n"
"Refusing to overwrite. Fix the file and retry.\n"
% (target_file, exc)
)
sys.exit(2)
else:
cfg = {}
if not isinstance(cfg, dict):
sys.stderr.write(
"install.sh: %s top level must be a JSON object; got %s\n"
% (target_file, type(cfg).__name__)
)
sys.exit(2)
cfg.setdefault("version", 1)
cfg.setdefault("hooks", {})
if not isinstance(cfg["hooks"], dict):
sys.stderr.write(
"install.sh: %s 'hooks' must be a JSON object\n" % target_file
)
sys.exit(2)
def is_mempal_entry(entry):
if not isinstance(entry, dict):
return False
cmd = entry.get("command", "")
if not isinstance(cmd, str):
return False
# Match on basename so a customised --install-dir (e.g. /opt/...,
# ~/.local/share/..., or anything with or without a leading dot)
# still round-trips through uninstall.
base = os.path.basename(cmd)
return base in MEMPAL_BASENAMES
def filter_mempal(entries):
if not isinstance(entries, list):
return entries
return [e for e in entries if not is_mempal_entry(e)]
def upsert(event, entry):
existing = cfg["hooks"].get(event, [])
if not isinstance(existing, list):
sys.stderr.write(
"install.sh: %s hooks[%s] must be a list\n" % (target_file, event)
)
sys.exit(2)
cleaned = [e for e in existing if not is_mempal_entry(e)]
cleaned.append(entry)
cfg["hooks"][event] = cleaned
if uninstall:
for event in list(cfg["hooks"].keys()):
cfg["hooks"][event] = filter_mempal(cfg["hooks"][event])
if not cfg["hooks"][event]:
del cfg["hooks"][event]
else:
upsert("stop", {"command": save_cmd, "loop_limit": 1})
if variant == "full":
upsert("preCompact", {"command": precompact_cmd})
upsert("sessionStart", {"command": wake_cmd})
# Stable key order for the events MemPalace touches, then preserve
# any unrelated event names in their original order so future Cursor
# events we don't know about yet still round-trip.
known_order = [
"sessionStart",
"stop",
"preCompact",
"sessionEnd",
"preToolUse",
"postToolUse",
"postToolUseFailure",
"subagentStart",
"subagentStop",
"beforeShellExecution",
"afterShellExecution",
"beforeMCPExecution",
"afterMCPExecution",
"beforeReadFile",
"afterFileEdit",
"beforeSubmitPrompt",
"afterAgentResponse",
"afterAgentThought",
"beforeTabFileRead",
"afterTabFileEdit",
"workspaceOpen",
]
ordered_hooks = {}
for event in known_order:
if event in cfg["hooks"]:
ordered_hooks[event] = cfg["hooks"][event]
for event, entries in cfg["hooks"].items():
if event not in ordered_hooks:
ordered_hooks[event] = entries
cfg["hooks"] = ordered_hooks
print(json.dumps(cfg, indent=2, sort_keys=False))
PYEOF
NEW_JSON="$("$PYTHON_BIN" "$MERGE_PY" \
"$TARGET_FILE" "$VARIANT" "$UNINSTALL" \
"$SAVE_CMD" "$PRECOMPACT_CMD" "$WAKE_CMD")"
# ── Step 3: emit, write, or remove ────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
printf 'install.sh: --dry-run; would write to %s\n' "$TARGET_FILE" >&2
printf '%s\n' "$NEW_JSON"
exit 0
fi
mkdir -p "$TARGET_DIR"
# If --uninstall left an empty hooks object AND no other top-level
# keys beyond version, remove the file entirely so the user's
# `.cursor/` directory does not accumulate orphan configs.
if [ "$UNINSTALL" -eq 1 ]; then
# Inline the emptiness check via `python -c '...'` rather than a
# temp .py file. The body is short enough that a tmpfile is pure
# overhead, and removing the tmpfile eliminates a small leak
# window if the script is interrupted between mktemp and rm -f.
# gh-PR review suggested this simplification.
NON_EMPTY="$(printf '%s' "$NEW_JSON" | "$PYTHON_BIN" -c '
import json, sys
cfg = json.load(sys.stdin)
hooks = cfg.get("hooks", {})
extras = [k for k in cfg.keys() if k not in ("version", "hooks")]
print("1" if (hooks or extras) else "0")
')"
if [ "$NON_EMPTY" = "0" ] && [ -f "$TARGET_FILE" ]; then
rm -f "$TARGET_FILE"
printf 'install.sh: removed empty %s\n' "$TARGET_FILE" >&2
exit 0
fi
fi
TMP_FILE="${TARGET_FILE}.tmp.$$"
printf '%s\n' "$NEW_JSON" > "$TMP_FILE"
mv "$TMP_FILE" "$TARGET_FILE"
if [ "$UNINSTALL" -eq 1 ]; then
printf 'install.sh: removed MemPalace entries from %s\n' "$TARGET_FILE" >&2
else
printf 'install.sh: wrote %s\n' "$TARGET_FILE" >&2
printf 'install.sh: restart Cursor (or wait for it to reload hooks.json)\n' >&2
fi
+490
View File
@@ -0,0 +1,490 @@
# shellcheck shell=bash
# MEMPALACE CURSOR HOOK — shared helpers
#
# Sourced by the three Cursor hooks (stop / preCompact / sessionStart).
# Mirrors the conventions of the existing Claude Code hook scripts
# (hooks/mempal_save_hook.sh, hooks/mempal_precompact_hook.sh) so a
# user who already debugs one knows how to debug the other:
#
# * STATE_DIR layout under ~/.mempalace/hook_state/
# * MEMPAL_PYTHON resolution order (override → $PATH → bare python3)
# * MEMPALACE_HOOKS_AUTO_SAVE=false kill switch (config.json fallback)
# * sentinel-guarded Python parser via `sed -n 'Np'` (bash 3.2 safe)
# * fail-open on internal errors: emit `{}` and log, never crash the
# hook host
#
# Cursor-specific additions on top of that contract:
#
# * MEMPAL_DISABLE_HOOK=1 as an additional kill-switch alias
# * MEMPAL_STATE_DIR env override for the state directory
# * conversation_id (Cursor's stable per-conversation ID) replaces
# Claude Code's session_id in the counter file names — Cursor `stop`
# events do not carry a session_id, only conversation_id
# * loop_count is the loop-prevention signal in place of Claude Code's
# stop_hook_active flag (Cursor docs, "stop" event)
#
# This file is sourced, not executed, so it intentionally has no
# shebang. The `# shellcheck shell=bash` directive above tells
# shellcheck to treat it as bash when run standalone.
# ── State directory + log path ────────────────────────────────────────
#
# Honour MEMPAL_STATE_DIR (additive override introduced for Cursor)
# while keeping the default identical to the Claude Code hooks so a
# user running both keeps a single state directory.
MEMPAL_STATE_DIR="${MEMPAL_STATE_DIR:-$HOME/.mempalace/hook_state}"
mkdir -p "$MEMPAL_STATE_DIR" 2>/dev/null
MEMPAL_CURSOR_LOG="$MEMPAL_STATE_DIR/cursor_hook.log"
# ── Python interpreter resolution ─────────────────────────────────────
#
# Same contract as the Claude Code hooks:
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
# 2. $(command -v python3) — first python3 on the hook's PATH
# 3. bare "python3" — last-resort fallback
mempal_resolve_python() {
local p="${MEMPAL_PYTHON:-}"
if [ -n "$p" ] && [ -x "$p" ]; then
printf '%s' "$p"
return 0
fi
p="$(command -v python3 2>/dev/null || true)"
if [ -n "$p" ]; then
printf '%s' "$p"
return 0
fi
printf '%s' "python3"
}
MEMPAL_PYTHON_BIN="$(mempal_resolve_python)"
# ── Logging ───────────────────────────────────────────────────────────
#
# Lines are `[ISO8601Z] [event=...] [conv=...] message`. ISO8601 keeps
# the format greppable across timezones (the Claude Code log uses
# %H:%M:%S which loses the date — we improve on that here without
# changing the existing log file).
mempal_log() {
local event="${1:-?}"
local conv="${2:-unknown}"
local msg="${3:-}"
local ts
ts="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
printf '[%s] [event=%s] [conv=%s] %s\n' "$ts" "$event" "$conv" "$msg" \
>> "$MEMPAL_CURSOR_LOG" 2>/dev/null
}
# ── Kill switch ───────────────────────────────────────────────────────
#
# Disabled if ANY of:
# * MEMPAL_DISABLE_HOOK is a truthy string (Cursor-prompt addition)
# * MEMPALACE_HOOKS_AUTO_SAVE is false/0/no (Claude Code convention)
# * ~/.mempalace/config.json has hooks.auto_save == false
#
# Returns 0 (true in shell) when disabled, 1 when enabled.
mempal_is_disabled() {
case "${MEMPAL_DISABLE_HOOK:-}" in
1|true|yes|on) return 0 ;;
esac
case "${MEMPALACE_HOOKS_AUTO_SAVE:-}" in
false|0|no|off) return 0 ;;
esac
local cfg="$HOME/.mempalace/config.json"
if [ -f "$cfg" ]; then
local result
# Use python -c '...' with the config path as argv[1] rather
# than a heredoc. A heredoc body that contains parens inside a
# $(...) command substitution trips the bash 3.2.57 parser
# bug (macOS /bin/bash default) — gh-PR review caught this.
# The -c form is also consistent with mempal_parse_stdin below.
result="$("$MEMPAL_PYTHON_BIN" -c '
import json, sys
try:
with open(sys.argv[1]) as f:
cfg = json.load(f)
print(str(cfg.get("hooks", {}).get("auto_save", True)).lower())
except Exception:
print("true")
' "$cfg" 2>/dev/null)"
if [ "$result" = "false" ]; then
return 0
fi
fi
return 1
}
# ── Stdin parser ──────────────────────────────────────────────────────
#
# Reads Cursor's hook JSON from $1 and exports:
# MEMPAL_CONV_ID — conversation_id, falls back to "unknown"
# MEMPAL_LOOP_COUNT — integer (0 if absent / non-numeric)
# MEMPAL_TRANSCRIPT — transcript_path, may be empty
# MEMPAL_WORKSPACE — first workspace_roots entry, falls back to
# CURSOR_PROJECT_DIR env var, then $PWD
# MEMPAL_TRIGGER — preCompact trigger ("auto" | "manual"), empty otherwise
# MEMPAL_STATUS — stop status ("completed" | "aborted" | "error"),
# empty otherwise
# MEMPAL_PARSE_OK — "1" if parser ran cleanly, "0" otherwise
#
# Uses the same sentinel + `sed -n 'Np'` extraction as the Claude Code
# hooks for bash 3.2 compatibility (mapfile/readarray are unavailable
# on macOS /bin/bash 3.2.57; #1440 regression). Each line of output is
# pre-sanitised by the Python side to a shell-safe character set.
mempal_parse_stdin() {
local input="${1:-}"
local parsed
# We invoke Python via -c with a single-quoted multi-line string
# rather than ``python3 - <<'PYEOF'`` because the heredoc form
# would shadow Python's stdin with the heredoc body, leaving
# ``json.load(sys.stdin)`` to read nothing and silently fail. The
# parser body deliberately uses only double-quoted Python strings
# so the surrounding bash single-quote is safe verbatim, and uses
# only the shell-safe character set (alphanumeric, underscore,
# dash, slash, dot, tilde) matching the Claude Code hook's
# sanitiser so a hostile transcript_path cannot splice
# metacharacters into the parsed output.
parsed="$(
umask 077
printf '%s' "$input" | "$MEMPAL_PYTHON_BIN" -c '
import json, re, sys
def safe_str(value):
return re.sub(r"[^a-zA-Z0-9_/.\-~]", "", str(value or ""))
def safe_int(value):
try:
return str(int(value))
except (TypeError, ValueError):
return "0"
try:
data = json.load(sys.stdin)
except Exception:
sys.exit(1)
if not isinstance(data, dict):
sys.exit(1)
conv = safe_str(data.get("conversation_id") or data.get("session_id"))
loop_count = safe_int(data.get("loop_count", 0))
transcript = safe_str(data.get("transcript_path", ""))
trigger = safe_str(data.get("trigger", ""))
status = safe_str(data.get("status", ""))
roots = data.get("workspace_roots") or []
workspace = ""
if isinstance(roots, list) and roots:
workspace = safe_str(roots[0])
print("__MEMPAL_PARSE_OK__")
print(conv)
print(loop_count)
print(transcript)
print(workspace)
print(trigger)
print(status)
' 2>"$MEMPAL_STATE_DIR/cursor_last_python_err.log"
)"
# Drop empty stderr capture on success; lock it to 0600 on failure
# (mirrors the privacy contract in the Claude Code hooks — the
# traceback can echo back transcript_path / home layout).
if [ -s "$MEMPAL_STATE_DIR/cursor_last_python_err.log" ]; then
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
else
rm -f "$MEMPAL_STATE_DIR/cursor_last_python_err.log" 2>/dev/null
fi
local marker
marker="$(printf '%s\n' "$parsed" | sed -n '1p')"
if [ "$marker" = "__MEMPAL_PARSE_OK__" ]; then
MEMPAL_PARSE_OK="1"
MEMPAL_CONV_ID="$(printf '%s\n' "$parsed" | sed -n '2p')"
MEMPAL_LOOP_COUNT="$(printf '%s\n' "$parsed" | sed -n '3p')"
MEMPAL_TRANSCRIPT="$(printf '%s\n' "$parsed" | sed -n '4p')"
MEMPAL_WORKSPACE="$(printf '%s\n' "$parsed" | sed -n '5p')"
MEMPAL_TRIGGER="$(printf '%s\n' "$parsed" | sed -n '6p')"
MEMPAL_STATUS="$(printf '%s\n' "$parsed" | sed -n '7p')"
else
MEMPAL_PARSE_OK="0"
MEMPAL_CONV_ID=""
MEMPAL_LOOP_COUNT="0"
MEMPAL_TRANSCRIPT=""
MEMPAL_WORKSPACE=""
MEMPAL_TRIGGER=""
MEMPAL_STATUS=""
fi
# Defaults and environment fallbacks. The Cursor docs guarantee
# CURSOR_TRANSCRIPT_PATH and CURSOR_PROJECT_DIR env vars are set
# for every hook execution; if JSON parsing failed for any reason
# (sentinel missing, malformed payload, missing interpreter) we
# still have a usable workspace.
MEMPAL_CONV_ID="${MEMPAL_CONV_ID:-unknown}"
case "$MEMPAL_LOOP_COUNT" in
''|*[!0-9]*) MEMPAL_LOOP_COUNT="0" ;;
esac
if [ -z "$MEMPAL_TRANSCRIPT" ] && [ -n "${CURSOR_TRANSCRIPT_PATH:-}" ]; then
MEMPAL_TRANSCRIPT="${CURSOR_TRANSCRIPT_PATH}"
fi
if [ -z "$MEMPAL_WORKSPACE" ]; then
if [ -n "${CURSOR_PROJECT_DIR:-}" ]; then
MEMPAL_WORKSPACE="${CURSOR_PROJECT_DIR}"
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
MEMPAL_WORKSPACE="${CLAUDE_PROJECT_DIR}"
else
MEMPAL_WORKSPACE="${PWD:-/}"
fi
fi
# Expand a leading ~ in the transcript path so downstream
# ``[ -f "$path" ]`` checks resolve correctly.
case "$MEMPAL_TRANSCRIPT" in
'~/'*) MEMPAL_TRANSCRIPT="$HOME/${MEMPAL_TRANSCRIPT#~/}" ;;
esac
}
# ── Defense-in-depth: dump unparseable stdin ──────────────────────────
#
# Same shape as the Claude Code hooks' last_input.log: bounded to 4096
# bytes, overwritten (never appended) so a misconfiguration loop cannot
# grow disk usage, 0600 perms because the dump mirrors the raw hook
# payload (transcript_path reveals the user's home + project layout).
mempal_dump_bad_input() {
local input="${1:-}"
if [ -z "$input" ]; then
return 0
fi
mempal_log "${2:-?}" "${MEMPAL_CONV_ID:-unknown}" \
"WARN: input parse failed (sentinel missing); see $MEMPAL_STATE_DIR/cursor_last_input.log + cursor_last_python_err.log"
(
umask 077
printf '%s' "$input" | head -c 4096 > "$MEMPAL_STATE_DIR/cursor_last_input.log"
)
chmod 600 "$MEMPAL_STATE_DIR/cursor_last_input.log" 2>/dev/null
}
# ── Counter helpers ───────────────────────────────────────────────────
#
# One counter file per conversation_id. Atomic write via temp file
# inside the same directory (rename is atomic on POSIX) so concurrent
# hook invocations cannot half-write the file. Read tolerates a
# corrupted or empty file by returning 0, never crashing.
_mempal_counter_path() {
local conv="${1:-unknown}"
# Sanitise the conv id one more time: it has already been through
# the Python sanitiser, but be defensive in case a caller passes a
# raw string. Strip any character outside [a-zA-Z0-9_.-].
local safe_conv
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
if [ -z "$safe_conv" ]; then
safe_conv="unknown"
fi
printf '%s/cursor_%s.count' "$MEMPAL_STATE_DIR" "$safe_conv"
}
mempal_read_counter() {
local path="$1"
if [ ! -f "$path" ]; then
printf '0'
return 0
fi
local raw
raw="$(cat "$path" 2>/dev/null)"
case "$raw" in
''|*[!0-9]*) printf '0' ;;
*) printf '%s' "$raw" ;;
esac
}
mempal_write_counter_atomic() {
local path="$1"
local value="$2"
case "$value" in
''|*[!0-9]*) value="0" ;;
esac
local tmp="${path}.tmp.$$"
printf '%s' "$value" > "$tmp" 2>/dev/null || return 1
mv "$tmp" "$path" 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
return 1
}
}
# ── Pending-save marker ───────────────────────────────────────────────
#
# Dropped by the preCompact hook (which cannot itself emit a
# followup_message — Cursor's preCompact is observational-only) and
# consumed by the next stop invocation so the LLM still gets a diary
# nudge after compaction.
_mempal_pending_path() {
local conv="${1:-unknown}"
local safe_conv
safe_conv="$(printf '%s' "$conv" | tr -cd 'a-zA-Z0-9_.-')"
if [ -z "$safe_conv" ]; then
safe_conv="unknown"
fi
printf '%s/cursor_%s.pending' "$MEMPAL_STATE_DIR" "$safe_conv"
}
mempal_set_pending() {
local conv="${1:-unknown}"
local path
path="$(_mempal_pending_path "$conv")"
: > "$path" 2>/dev/null || return 1
chmod 600 "$path" 2>/dev/null
}
mempal_consume_pending() {
local conv="${1:-unknown}"
local path
path="$(_mempal_pending_path "$conv")"
if [ -f "$path" ]; then
rm -f "$path" 2>/dev/null
return 0
fi
return 1
}
# ── State-file TTL ────────────────────────────────────────────────────
#
# Per-conversation state artifacts (cursor_<conv>.count and
# cursor_<conv>.pending) accumulate one set per conversation and are
# never otherwise removed (igorls review, PR #1632 — unbounded state
# growth). Reads MEMPAL_STATE_TTL_DAYS (default 30), validated
# digits-only and leading-zero-stripped (mirrors the SAVE_INTERVAL
# sanitiser) so `find -mtime` never sees a bad or octal token. Empty or
# non-numeric floors to 30; a value of 0 means "sweep everything older
# than today".
mempal_state_ttl_days() {
local raw="${MEMPAL_STATE_TTL_DAYS:-30}"
case "$raw" in
''|*[!0-9]*) printf '30'; return 0 ;;
esac
while [ "${raw}" != "${raw#0}" ] && [ "${#raw}" -gt 1 ]; do
raw="${raw#0}"
done
printf '%s' "$raw"
}
# ── Stale state GC ────────────────────────────────────────────────────
#
# Opportunistic sweep of per-conversation Cursor state older than the
# TTL. Throttled to at most once per 24h via the cursor_last_sweep
# marker, so it costs a single mtime comparison on the vast majority of
# fires. When it does run, two `find` passes remove the stale counter
# files and pending markers.
#
# The globs are Cursor-specific and suffix-anchored (cursor_*.count,
# cursor_*.pending), so the shared logs (cursor_hook.log,
# cursor_last_input.log, cursor_last_python_err.log), the
# cursor_last_sweep marker itself, and any antigravity_*/Claude state
# sharing the same directory are never touched. BSD find (macOS default)
# and GNU find both accept -maxdepth, -mtime +N, and -exec ... +.
#
# Fail-open: every step is best-effort; a missing state dir, a find that
# errors, or a permission problem must never abort the caller.
mempal_gc_stale_state() {
[ -d "$MEMPAL_STATE_DIR" ] || return 0
local marker="$MEMPAL_STATE_DIR/cursor_last_sweep"
if [ -f "$marker" ]; then
local mtime now
if mtime=$("$MEMPAL_PYTHON_BIN" -c 'import os, sys; print(int(os.path.getmtime(sys.argv[1])))' "$marker" 2>/dev/null) \
&& now=$(date '+%s' 2>/dev/null) \
&& [ -n "$mtime" ] \
&& [ "$((now - mtime))" -lt 86400 ]; then
return 0
fi
fi
# Touch the marker first so a crash mid-sweep still throttles the
# next fire (better to skip a sweep than to hammer the disk).
: > "$marker" 2>/dev/null
local ttl
ttl=$(mempal_state_ttl_days)
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
-name 'cursor_*.count' -mtime +"$ttl" \
-exec rm -f {} + 2>/dev/null
find "$MEMPAL_STATE_DIR" -maxdepth 1 -type f \
-name 'cursor_*.pending' -mtime +"$ttl" \
-exec rm -f {} + 2>/dev/null
return 0
}
# ── Workspace → wing inference ────────────────────────────────────────
#
# basename(workspace_root), normalised to [a-z0-9_-]. Edge cases:
# / → "root"
# /path/ → trailing slash stripped, then basename
# "/foo bar/" → "foo_bar" (spaces collapsed to underscores)
# "" → "cursor_session"
# "C:\\proj" → "proj" (Windows-style path; basename via tr fallback)
#
# We intentionally keep this in pure bash + POSIX tools so the
# inference is identical across the hook scripts and the test suite
# can target it as a function via `bash -c 'source ...; ...'`.
mempal_infer_wing() {
local raw="${1:-}"
if [ -z "$raw" ]; then
printf 'cursor_session'
return 0
fi
# Strip trailing slashes (but preserve the lone "/" case).
while [ "$raw" != "/" ] && [ "${raw%/}" != "$raw" ]; do
raw="${raw%/}"
done
if [ "$raw" = "/" ]; then
printf 'root'
return 0
fi
local base="${raw##*/}"
# On Windows-style paths with backslashes, fall back to splitting
# on backslash too so we don't return the whole path verbatim.
case "$base" in
*\\*) base="${base##*\\}" ;;
esac
# Lowercase + replace anything outside [a-z0-9_-] with underscore.
# Collapse runs of underscores so "foo bar" doesn't become
# "foo__bar".
base="$(printf '%s' "$base" \
| tr '[:upper:]' '[:lower:]' \
| tr -c 'a-z0-9_-' '_' \
| tr -s '_' \
| sed 's/^_//; s/_$//')"
if [ -z "$base" ]; then
printf 'cursor_session'
return 0
fi
printf '%s' "$base"
}
# ── Transcript path validation ────────────────────────────────────────
#
# Mirrors hooks/mempal_save_hook.sh::is_valid_transcript_path so the
# Cursor and Claude Code hooks reject the same shapes:
# * non-empty
# * .json or .jsonl suffix
# * no .. traversal segments
mempal_is_valid_transcript() {
local path="${1:-}"
[ -n "$path" ] || return 1
case "$path" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$path/" in
*/../*) return 1 ;;
esac
return 0
}
# ── JSON emit ─────────────────────────────────────────────────────────
#
# Final stdout write. Uses ``printf '%s'`` instead of ``echo`` because
# echo interprets ``-n``/``-e``/``-E`` as flags and varies in backslash
# handling between builtin and /bin/echo (xpg_echo shopt). Matches the
# Claude Code hook's documented rationale.
mempal_emit() {
printf '%s\n' "${1:-{\}}"
}
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
# MEMPALACE CURSOR PRE-COMPACT HOOK — Snapshot transcript before compaction
#
# Cursor "preCompact" hook. Cursor's preCompact is documented as
# OBSERVATIONAL ONLY (cursor.com/docs/hooks.md fetched 2026-05-27):
#
# * It cannot block compaction.
# * Its only output field is `user_message` (no `followup_message`,
# no `decision: block`).
#
# So unlike the Claude Code PreCompact hook (which can block the AI
# and force a save before compaction proceeds), the Cursor preCompact
# hook can only do two useful things at this moment:
#
# 1. Run `mempalace mine` SYNCHRONOUSLY against the transcript file
# so whatever Cursor's transcript contains is ingested BEFORE
# Cursor summarises the conversation — zero LLM cost, no agent
# interaction needed. NOTE: this is BEST-EFFORT for Cursor.
# Cursor's transcript format is undocumented and normalize.py has
# no Cursor parser, so this does not yet produce clean verbatim
# drawers; it is a safety net, not the primary capture path.
#
# 2. Drop a `.pending` marker file keyed on conversation_id. The
# next `stop` hook reads that marker and forces a save followup
# regardless of its counter, so the AI still gets a "write a
# diary entry now" nudge on the very next turn. THIS followup is
# the load-bearing verbatim-capture path for Cursor (the agent
# files its own in-context verbatim quotes via the MCP tools).
#
# === INSTALL ===
#
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
# scope) under "preCompact":
#
# {
# "version": 1,
# "hooks": {
# "preCompact": [
# { "command": "/absolute/path/to/mempal_precompact_hook_cursor.sh" }
# ]
# }
# }
#
# No loop_limit is needed; preCompact is not a looping hook.
_mempal_self="${BASH_SOURCE[0]:-$0}"
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
# shellcheck source=lib/common.sh
. "$_mempal_dir/lib/common.sh"
# Optional additional project directory to mine before compaction
# (parity with the Claude Code hook's MEMPAL_DIR knob).
MEMPAL_DIR="${MEMPAL_DIR:-}"
if mempal_is_disabled; then
mempal_emit '{}'
exit 0
fi
# Opportunistic, daily-throttled GC of stale per-conversation state.
# Placed after the kill switch so a disabled hook touches nothing.
mempal_gc_stale_state
INPUT="$(cat)"
mempal_parse_stdin "$INPUT"
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
mempal_dump_bad_input "$INPUT" "preCompact"
mempal_emit '{}'
exit 0
fi
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"trigger=${MEMPAL_TRIGGER:-?} transcript=$MEMPAL_TRANSCRIPT"
# ── Synchronous mine ──────────────────────────────────────────────
#
# This intentionally blocks the hook. Compaction is irreversible —
# once Cursor summarises the conversation we cannot get the verbatim
# text back — so we must finish ingesting before returning. Background
# mining would race the compaction and lose data.
#
# TIMEOUT TRADEOFF (igorls review, PR #1632): on a very large transcript
# this synchronous mine can exceed Cursor's per-hook timeout, in which
# case Cursor kills the process mid-mine. That is acceptable and safe
# here: `mempalace mine` is incremental and append-only (a crash mid-
# operation leaves the existing palace untouched — see CLAUDE.md
# "Incremental only"), so a killed mine simply resumes on the next mine
# invocation rather than corrupting the palace. We deliberately do NOT
# wrap this in a shorter timeout, because truncating the mine would
# trade a recoverable partial-ingest for guaranteed silent data loss
# right before the irreversible compaction. The pending-save marker
# below is the backstop: the next `stop` hook re-mines and nudges a
# verbatim save regardless of whether this mine completed.
if command -v mempalace >/dev/null 2>&1; then
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"WARN: mempalace mine convos returned non-zero"
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
fi
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$MEMPAL_CURSOR_LOG" 2>&1 || \
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"WARN: mempalace mine projects returned non-zero"
fi
else
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"mempalace CLI not on PATH; skipping synchronous mine"
fi
# ── Drop the pending-save marker ──────────────────────────────────
mempal_set_pending "$MEMPAL_CONV_ID" || \
mempal_log "preCompact" "$MEMPAL_CONV_ID" \
"WARN: could not write pending-save marker"
# Surface a short user-visible note that compaction is about to
# happen and we've already captured the verbatim text. user_message
# is the only output field Cursor's preCompact accepts.
"$MEMPAL_PYTHON_BIN" -c '
import json
print(json.dumps({
"user_message": (
"MemPalace: transcript snapshotted before compaction. "
"A diary nudge is queued for the next agent turn."
)
}))
'
+267
View File
@@ -0,0 +1,267 @@
#!/bin/bash
# MEMPALACE CURSOR SAVE HOOK — Auto-save every N stop events
#
# Cursor "stop" hook. After every agent loop ends, this hook:
# 1. Counts stop invocations per conversation_id (each stop ≈ one
# assistant turn ≈ roughly one user message — see plan rationale).
# 2. Every SAVE_INTERVAL stops, returns a followup_message telling
# the agent to file the session into MemPalace and write a diary
# entry. Cursor auto-submits that as the next user message.
# 3. On the next stop, loop_count > 0 so we let the agent finish
# without re-firing — Cursor's loop_count is the equivalent of
# Claude Code's stop_hook_active flag.
# 4. If the preCompact hook has left a `.pending` marker, force a
# save followup regardless of the counter and clear the marker.
#
# === WHY THE FOLLOWUP FIRES BY DEFAULT (differs from the Claude hook) ===
#
# The Claude Code hook (hooks/mempal_save_hook.sh) is SILENT by default:
# its background `mempalace mine --mode convos` captures the verbatim
# transcript on its own, and the LLM-driven diary nudge is opt-IN behind
# MEMPAL_VERBOSE. That works because mempalace/normalize.py has a Claude
# Code JSONL parser.
#
# Cursor is different. Cursor's transcript format is undocumented (see
# STDIN_SHAPE.md) and normalize.py has NO Cursor parser, so the
# background mine below is BEST-EFFORT only — it does not yet yield clean
# verbatim drawers for Cursor. The followup_message is therefore the
# load-bearing verbatim-capture path: it drives the agent to call
# mempalace_add_drawer / mempalace_diary_write from its in-context memory.
# That is why it is ON by default here — silencing it by default would
# leave a default Cursor install capturing nothing.
#
# Users who want the Claude-style "zero tokens in the chat window"
# behaviour can silence the followup (see MEMPAL_CURSOR_SILENT /
# MEMPAL_VERBOSE below). Once normalize.py learns to read Cursor
# transcripts, this default should flip to silent to match Claude.
#
# Companion files in this directory:
# * lib/common.sh — shared helpers (sourced)
# * mempal_precompact_hook_cursor.sh — preCompact event
# * mempal_wake_hook_cursor.sh — sessionStart event
#
# === INSTALL ===
#
# Recommended path: run `hooks/cursor/install.sh` from a cloned repo,
# which copies the scripts to ~/.mempalace/hooks/cursor/ and merges
# the wiring into your ~/.cursor/hooks.json. See hooks/cursor/README.md
# for the full walkthrough, or website/guide/cursor-hooks.md for the
# rendered version.
#
# Manual wiring (user scope: ~/.cursor/hooks.json):
#
# {
# "version": 1,
# "hooks": {
# "stop": [
# {
# "command": "/absolute/path/to/mempal_save_hook_cursor.sh",
# "loop_limit": 1
# }
# ]
# }
# }
#
# The `loop_limit: 1` cap is defense-in-depth — even if our own
# loop_count check below regresses, Cursor itself will stop emitting
# our followup after one auto-iteration.
#
# === KILL SWITCHES ===
#
# MEMPAL_DISABLE_HOOK=1 — Cursor-prompt addition
# MEMPALACE_HOOKS_AUTO_SAVE=false — matches the Claude Code hooks
# ~/.mempalace/config.json "hooks.auto_save": false
#
# Any one of these short-circuits the hook to `{}` and exits 0.
# Resolve the directory this script lives in so we can source the
# sibling lib/common.sh whether the user invoked us by absolute path,
# by relative path, or via a symlink.
_mempal_self="${BASH_SOURCE[0]:-$0}"
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
# shellcheck source=lib/common.sh
. "$_mempal_dir/lib/common.sh"
SAVE_INTERVAL="${MEMPAL_SAVE_INTERVAL:-15}"
# Coerce empty, non-numeric, AND zero to the default. SAVE_INTERVAL=0
# would otherwise crash bash on the modulo check below ($((NEXT % 0))
# is "division by 0"). gh-PR review caught this edge case.
case "$SAVE_INTERVAL" in
''|*[!0-9]*|0) SAVE_INTERVAL=15 ;;
esac
# Optional additional project directory to mine on save (parity with
# the Claude Code hook's MEMPAL_DIR knob — purely additive, never an
# override for the transcript mine).
MEMPAL_DIR="${MEMPAL_DIR:-}"
# ── Followup opt-out ──────────────────────────────────────────────
#
# Returns 0 (true) when the user has asked to suppress the
# followup_message. See the header comment for why the followup is ON
# by default for Cursor. When silenced, the hook still runs the
# best-effort background mine and still maintains its counters/markers
# — it just emits `{}` instead of a followup. Two equivalent signals:
# * MEMPAL_CURSOR_SILENT=1|true|yes|on — dedicated Cursor opt-out
# * MEMPAL_VERBOSE=false|0|no|off — cross-hook silence signal
# (mirror-image of the Claude hook, where MEMPAL_VERBOSE=true is
# what turns its diary nudge ON)
mempal_followup_silenced() {
case "${MEMPAL_CURSOR_SILENT:-}" in
1|true|yes|on) return 0 ;;
esac
case "${MEMPAL_VERBOSE:-}" in
false|0|no|off) return 0 ;;
esac
return 1
}
# Kill switch — emit `{}` so Cursor proceeds with normal stop.
if mempal_is_disabled; then
mempal_emit '{}'
exit 0
fi
# Opportunistic, daily-throttled GC of stale per-conversation state.
# Placed after the kill switch so a disabled hook touches nothing.
mempal_gc_stale_state
INPUT="$(cat)"
mempal_parse_stdin "$INPUT"
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
mempal_dump_bad_input "$INPUT" "stop"
# Fail-open: don't block the host on a parse error.
mempal_emit '{}'
exit 0
fi
mempal_log "stop" "$MEMPAL_CONV_ID" \
"loop_count=$MEMPAL_LOOP_COUNT status=${MEMPAL_STATUS:-?} workspace=$MEMPAL_WORKSPACE"
# ── Loop-prevention ────────────────────────────────────────────────
#
# Cursor's loop_count indicates how many times THIS stop hook has
# already triggered an automatic followup for this conversation
# (starts at 0). If it is > 0, our own previous followup is currently
# being consumed by the agent — let it finish without re-firing.
if [ "$MEMPAL_LOOP_COUNT" -gt 0 ] 2>/dev/null; then
mempal_log "stop" "$MEMPAL_CONV_ID" "loop_count>0; letting agent stop"
mempal_emit '{}'
exit 0
fi
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
# Build the followup message once; both the pending-marker branch and
# the threshold branch use it. Constructed via Python -c (rather than
# a heredoc) so we can pass the inferred wing as argv[1] and so the
# JSON encoding is correct even for wings whose name would otherwise
# need shell quoting.
_mempal_build_followup() {
"$MEMPAL_PYTHON_BIN" -c '
import json, sys
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
msg = (
"MemPalace save checkpoint. Call mempalace_checkpoint ONCE with: "
"items=[{wing: " + wing + ", room: <short topic>, content: <verbatim "
"quote>}, ...] for the key topics, decisions, and verbatim quotes from "
"this session; and diary={agent_name: cursor-ide, wing: " + wing + ", "
"entry: <AAAK-format summary>}. It dedups, files non-duplicates, and "
"writes the diary in one call. Then stop."
)
print(json.dumps({"followup_message": msg}))
' "$WING"
}
# ── Pending-save marker from preCompact ───────────────────────────
#
# preCompact cannot itself emit a followup_message (Cursor docs:
# preCompact is observational-only, output supports only user_message),
# so it drops a marker file and we consume it here. Forces a save
# nudge regardless of the counter.
if mempal_consume_pending "$MEMPAL_CONV_ID"; then
mempal_log "stop" "$MEMPAL_CONV_ID" \
"consumed pending-save marker (post-compaction)"
if mempal_followup_silenced; then
mempal_log "stop" "$MEMPAL_CONV_ID" \
"followup silenced (MEMPAL_CURSOR_SILENT/MEMPAL_VERBOSE); emitting {}"
mempal_emit '{}'
exit 0
fi
_mempal_build_followup
exit 0
fi
# ── Normal counter path ───────────────────────────────────────────
COUNTER_FILE="$(_mempal_counter_path "$MEMPAL_CONV_ID")"
CURRENT="$(mempal_read_counter "$COUNTER_FILE")"
NEXT=$((CURRENT + 1))
mempal_write_counter_atomic "$COUNTER_FILE" "$NEXT" || {
mempal_log "stop" "$MEMPAL_CONV_ID" \
"WARN: counter write failed for $COUNTER_FILE; passing through"
mempal_emit '{}'
exit 0
}
mempal_log "stop" "$MEMPAL_CONV_ID" \
"counter $CURRENT -> $NEXT (interval=$SAVE_INTERVAL)"
# Trigger when we hit a multiple of SAVE_INTERVAL. Modulo arithmetic
# keeps the counter monotonically growing (no reset) so the log file
# is greppable for total turns across a conversation.
if [ "$((NEXT % SAVE_INTERVAL))" -ne 0 ]; then
mempal_emit '{}'
exit 0
fi
mempal_log "stop" "$MEMPAL_CONV_ID" "TRIGGERING SAVE at counter=$NEXT"
# ── Background mine (best effort) ─────────────────────────────────
#
# Two independent targets — both run if both are set:
# 1. transcript_path → its parent directory, --mode convos
# 2. MEMPAL_DIR (user-configured project) → --mode projects
#
# IMPORTANT (Cursor caveat): the --mode convos mine is BEST-EFFORT for
# Cursor. Cursor's transcript format is undocumented and
# mempalace/normalize.py has no Cursor parser, so this call does not
# yet produce clean verbatim conversation drawers — at best it ingests
# raw bytes. The verbatim-capture guarantee for Cursor is carried by
# the followup_message below, which drives the agent to file its own
# in-context verbatim quotes. The --mode projects target (MEMPAL_DIR)
# is unaffected — normalize.py reads ordinary project files fine.
#
# Both run with stdout/stderr appended to the cursor log and are
# backgrounded so a slow mine cannot push the hook past its
# Cursor-configured timeout. `command -v mempalace` gates so a user
# without the CLI on PATH (e.g. a fresh GUI-launched session) does
# not see a noisy error.
if command -v mempalace >/dev/null 2>&1; then
if mempal_is_valid_transcript "$MEMPAL_TRANSCRIPT" \
&& [ -f "$MEMPAL_TRANSCRIPT" ]; then
( mempalace mine "$(dirname "$MEMPAL_TRANSCRIPT")" --mode convos \
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
elif [ -n "$MEMPAL_TRANSCRIPT" ]; then
mempal_log "stop" "$MEMPAL_CONV_ID" \
"skipping invalid transcript path: $MEMPAL_TRANSCRIPT"
fi
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
( mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$MEMPAL_CURSOR_LOG" 2>&1 ) &
fi
else
mempal_log "stop" "$MEMPAL_CONV_ID" \
"mempalace CLI not on PATH; skipping background mine"
fi
# The followup is the load-bearing verbatim path for Cursor (see header),
# so it fires by default. Honour the opt-out for users who want silence.
if mempal_followup_silenced; then
mempal_log "stop" "$MEMPAL_CONV_ID" \
"followup silenced (MEMPAL_CURSOR_SILENT/MEMPAL_VERBOSE); background mine only"
mempal_emit '{}'
exit 0
fi
_mempal_build_followup
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# MEMPALACE CURSOR WAKE HOOK — Session-start memory recall
#
# Cursor "sessionStart" hook. This is a Cursor-only capability —
# Claude Code's third-party-hooks compatibility layer does not have
# an equivalent event with the same "inject context into the agent's
# initial system message" semantics.
#
# Behaviour:
# 1. Parse Cursor's sessionStart payload (conversation_id /
# session_id, is_background_agent, composer_mode, plus the
# common workspace_roots field).
# 2. Infer the wing from basename(workspace_roots[0]).
# 3. Return {"additional_context": "..."} instructing the agent to
# scope its memory recall by calling mempalace_search +
# mempalace_diary_read with wing=<inferred>.
#
# sessionStart is documented as fire-and-forget — Cursor does not
# enforce a blocking response and does not consume "continue" /
# "user_message" — but "additional_context" is honoured and added to
# the conversation's initial system context. Verified in
# cursor.com/docs/hooks.md (fetched 2026-05-27).
#
# === INSTALL ===
#
# Add to ~/.cursor/hooks.json (or .cursor/hooks.json for project
# scope) under "sessionStart":
#
# {
# "version": 1,
# "hooks": {
# "sessionStart": [
# { "command": "/absolute/path/to/mempal_wake_hook_cursor.sh" }
# ]
# }
# }
_mempal_self="${BASH_SOURCE[0]:-$0}"
_mempal_dir="$(cd "$(dirname "$_mempal_self")" 2>/dev/null && pwd)"
# shellcheck source=lib/common.sh
. "$_mempal_dir/lib/common.sh"
if mempal_is_disabled; then
mempal_emit '{}'
exit 0
fi
INPUT="$(cat)"
mempal_parse_stdin "$INPUT"
if [ "$MEMPAL_PARSE_OK" != "1" ]; then
mempal_dump_bad_input "$INPUT" "sessionStart"
mempal_emit '{}'
exit 0
fi
WING="$(mempal_infer_wing "$MEMPAL_WORKSPACE")"
mempal_log "sessionStart" "$MEMPAL_CONV_ID" \
"workspace=$MEMPAL_WORKSPACE wing=$WING"
# Emit the additional_context payload via Python -c (rather than a
# heredoc) so the JSON encoding survives wings whose name contains
# characters that would otherwise need shell escaping, and so the
# inferred wing arrives as an argv positional. The MCP tool names
# referenced here are verified against mempalace/mcp_server.py:
# mempalace_search and mempalace_diary_read both exist and accept
# the wing parameter.
"$MEMPAL_PYTHON_BIN" -c '
import json, sys
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
ctx = (
"MemPalace wake-up. The Cursor workspace maps to wing=" + wing + ". "
"Before answering anything that touches past work in this "
"project, call mempalace_search (wing=" + wing + ", "
"query=<relevant keywords>) and mempalace_diary_read "
"(agent_name=cursor-ide, wing=" + wing + ", last_n=10). "
"Use what you find verbatim where it answers the question; "
"never summarise the user'"'"'s own words."
)
print(json.dumps({"additional_context": ctx}))
' "$WING"
+5 -13
View File
@@ -119,16 +119,8 @@ INPUT=$(cat)
# backslashes are not mangled by echo flag parsing.
_mempal_parsed=$(
umask 077
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
import sys, json, re
data = json.load(sys.stdin)
sid = data.get('session_id', '')
tp = data.get('transcript_path', '')
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
print('__MEMPAL_PARSE_OK__')
print(safe(sid))
print(safe(tp))
" 2>"$STATE_DIR/last_python_err.log"
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-precompact \
2>"$STATE_DIR/last_python_err.log"
)
# Drop the empty file on success; chmod 600 on failure to mirror
# last_input.log's privacy contract.
@@ -190,14 +182,14 @@ echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
# 2. MEMPAL_DIR → --mode projects
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
>> "$STATE_DIR/hook.log" 2>&1
elif [ -n "$TRANSCRIPT_PATH" ]; then
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
echo "[$(date '+%H:%M:%S')] Skipping missing or invalid transcript path after normalization: $TRANSCRIPT_PATH" \
>> "$STATE_DIR/hook.log"
fi
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
mempalace mine "$MEMPAL_DIR" --mode projects \
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$STATE_DIR/hook.log" 2>&1
fi
+8 -35
View File
@@ -149,21 +149,8 @@ INPUT=$(cat)
# ``printf '%s'`` removes the class of bug entirely.
_mempal_parsed=$(
umask 077
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
import sys, json, re
data = json.load(sys.stdin)
sid = data.get('session_id', '')
sha_raw = data.get('stop_hook_active', False)
tp = data.get('transcript_path', '')
# Shell-safe output: only allow alphanumeric, underscore, hyphen, slash, dot, tilde
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
# Coerce stop_hook_active to strict boolean string
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
print('__MEMPAL_PARSE_OK__')
print(safe(sid))
print(sha)
print(safe(tp))
" 2>"$STATE_DIR/last_python_err.log"
printf '%s' "$INPUT" | "$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell parse-stop \
2>"$STATE_DIR/last_python_err.log"
)
# The 2> redirect creates the file even when stderr is empty (success).
# Remove the empty file so the state directory stays clean on the happy
@@ -239,24 +226,10 @@ fi
# Count human messages in the JSONL transcript
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
if [ -f "$TRANSCRIPT_PATH" ]; then
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
import json, sys
count = 0
with open(sys.argv[1]) as f:
for line in f:
try:
entry = json.loads(line)
msg = entry.get('message', {})
if isinstance(msg, dict) and msg.get('role') == 'user':
content = msg.get('content', '')
if isinstance(content, str) and '<command-message>' in content:
continue
count += 1
except:
pass
print(count)
PYEOF
2>/dev/null)
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" -m mempalace.hook_shell count-human-messages "$TRANSCRIPT_PATH" 2>/dev/null)
elif [ -n "$TRANSCRIPT_PATH" ]; then
echo "[$(date '+%H:%M:%S')] WARN: transcript_path not found after normalization: $TRANSCRIPT_PATH" >> "$STATE_DIR/hook.log"
EXCHANGE_COUNT=0
else
EXCHANGE_COUNT=0
fi
@@ -292,14 +265,14 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
# MEMPAL_DIR is *additive*, not an override: a user with MEMPAL_DIR
# pointed at their project still gets the active conversation mined.
if is_valid_transcript_path "$TRANSCRIPT_PATH" && [ -f "$TRANSCRIPT_PATH" ]; then
mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$(dirname "$TRANSCRIPT_PATH")" --mode convos \
>> "$STATE_DIR/hook.log" 2>&1 &
elif [ -n "$TRANSCRIPT_PATH" ]; then
echo "[$(date '+%H:%M:%S')] Skipping invalid transcript path: $TRANSCRIPT_PATH" \
>> "$STATE_DIR/hook.log"
fi
if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
mempalace mine "$MEMPAL_DIR" --mode projects \
"$MEMPAL_PYTHON_BIN" -m mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$STATE_DIR/hook.log" 2>&1 &
fi
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# MemPalace SessionEnd Hook — final save on clean exit.
#
# Claude Code documents a default SessionEnd hook timeout of 1.5s; a per-hook
# "timeout" in settings.local.json can raise it (up to 60s), but a
# plugin-provided timeout cannot (https://code.claude.com/docs/en/hooks). A cold
# `mempalace` start alone can exceed 1.5s, so we background the hook and return
# immediately; the detached child finishes the save after the session has
# exited. All logic lives in mempalace.hooks_cli for cross-harness extensibility.
run_mempalace_hook() {
if command -v mempalace >/dev/null 2>&1; then
exec mempalace hook run "$@"
fi
MEMPAL_PYTHON_BIN="${MEMPAL_PYTHON:-}"
if [ -z "$MEMPAL_PYTHON_BIN" ] || [ ! -x "$MEMPAL_PYTHON_BIN" ]; then
MEMPAL_PYTHON_BIN="$(command -v python3 2>/dev/null || echo python3)"
fi
if "$MEMPAL_PYTHON_BIN" -c "import mempalace" >/dev/null 2>&1; then
exec "$MEMPAL_PYTHON_BIN" -m mempalace hook run "$@"
fi
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
exec python -m mempalace hook run "$@"
fi
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
exit 1
}
# Capture stdin (the SessionEnd JSON) before backgrounding — the parent's
# stdin is gone once we return. Forward it to the detached worker, which runs
# the final mine on its own time and outlives this process.
payload="$(cat)"
(
printf '%s' "$payload" | run_mempalace_hook --hook session-end --harness "${MEMPALACE_HOOK_HARNESS:-claude-code}"
) >/dev/null 2>&1 </dev/null &
disown 2>/dev/null || true
# Return immediately so the harness never blocks on session exit.
printf '{}'
+66 -3
View File
@@ -1,7 +1,7 @@
---
name: mempalace
description: "MemPalace — Local AI memory with 96.6% recall. Semantic search, temporal knowledge graph, palace architecture (wings/rooms/drawers). Free, no cloud, no API keys."
version: 3.3.0
version: 3.7.0
homepage: https://github.com/MemPalace/mempalace
user-invocable: true
metadata:
@@ -46,6 +46,10 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
## Available Tools
Full MCP surface: 36 tools. Destructive or host-level tools are documented so
you know they exist, but use them only when the user explicitly asks or when a
tool-specific workflow below says to.
### Search & Browse
- `mempalace_search` — Semantic search across all memories. Always start here.
- `query` (required): natural language search — keep it short, keywords or a question. Do NOT include system prompts or conversation context.
@@ -58,6 +62,14 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
- `mempalace_status` — Palace overview: total drawers, wings, rooms, AAAK spec
- `mempalace_list_wings` — All wings with drawer counts
- `mempalace_list_rooms` — Rooms within a wing (optional wing filter)
- `mempalace_list_drawers` — Paginated drawer listing
- `wing`, `room`: optional filters
- `since`: only drawers filed on/after this ISO date/time
- `before`: only drawers filed before this ISO date/time
- `limit`: max results (default 20)
- `offset`: pagination offset (default 0)
- `mempalace_get_drawer` — Fetch a single drawer by ID. Returns full verbatim content and metadata.
- `drawer_id` (required)
- `mempalace_get_taxonomy` — Full wing/room/count tree
- `mempalace_get_aaak_spec` — Get AAAK compression dialect specification
@@ -81,17 +93,59 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
- `mempalace_traverse` — Walk from a room, find connected ideas across wings
- `start_room` (required): room to start from
- `max_hops`: connection depth (default 2)
- `mempalace_find_tunnels` — Find rooms that bridge two wings
- `wing_a`, `wing_b` (required)
- `mempalace_find_tunnels` — Find rooms that bridge two wings via *implicit* overlap (rooms whose drawers naturally share content across wings — discovered, not declared)
- `wing_a`, `wing_b`: optional filters; omit both to scan all wing pairs
- `mempalace_create_tunnel` — Create an *explicit* cross-wing tunnel: a user/agent-declared link between two locations. Use when you notice content in one project relates to another (e.g. API design in `project_api` connects to schema in `project_database`).
- `source_wing`, `source_room`, `target_wing`, `target_room` (required)
- `label`: short description of the relationship
- `source_drawer_id`, `target_drawer_id`: anchor to specific drawers
- `mempalace_list_tunnels` — List all explicit tunnels, optionally filtered by wing
- `wing`: optional filter
- `mempalace_delete_tunnel` — Remove an explicit tunnel by ID
- `tunnel_id` (required)
- `mempalace_list_hallways` — List within-wing entity hallways (entity-to-entity co-occurrence links built at mine time)
- `wing`: optional filter
- `mempalace_delete_hallway` — Remove a hallway record by ID
- `hallway_id` (required)
- `mempalace_follow_tunnels` — From a room, follow explicit tunnels to connected drawers in other wings
- `wing`, `room` (required)
- `mempalace_graph_stats` — Graph connectivity overview
### Write
- `mempalace_add_drawer` — Store verbatim content into a wing/room
- `wing`, `room`, `content` (required)
- `source_file`: optional source reference
- `added_by`: optional filing agent label
- Checks for duplicates automatically
- `mempalace_checkpoint` — Save a whole session in one call: dedup each item, file non-duplicates, then write one diary entry
- `items` (required): array of `{wing, room, content}`; content must be verbatim
- `diary`: optional `{agent_name, entry, topic?, wing?}`; entry should use AAAK format
- `dedup_threshold`: similarity threshold (default 0.9)
- `added_by`: optional filing agent label (defaults to the diary `agent_name`, else `checkpoint`)
- `mempalace_update_drawer` — Update an existing drawer's content and/or move it to a different wing/room
- `drawer_id` (required)
- `content`, `wing`, `room`: at least one must be provided (no-op otherwise)
- `mempalace_delete_drawer` — Remove a drawer by ID
- `drawer_id` (required)
### Ingest & Cleanup
- `mempalace_mine` — Mine a directory into the palace. Host-level ingest; call only when the user asks to import files.
- `source` (required): directory to mine
- `mode`: `projects` (default), `convos`, or `extract`
- `wing`: target wing (default: source directory name)
- `agent`: recorded on every drawer (default `mempalace`)
- `limit`: max files to process (0 = all)
- `dry_run`: preview without writing
- `extract`: convos extraction strategy (`exchange` default, or `general`)
- `mempalace_sync` — Prune drawers whose source files are gitignored, deleted, or moved. Use dry-run first.
- `project_dir`: optional project root scope
- `wing`: optional wing scope
- `apply`: actually delete; default is dry-run preview
- `mempalace_delete_by_source` — Bulk-delete drawers with one exact `source_file`. Destructive; use dry-run first.
- `source_file` (required): exact metadata value to remove
- `dry_run`: preview match count and sample (default true)
### Diary & Session
- `mempalace_diary_write` — Write a session diary entry
- `agent_name` (required): your name/identifier
- `entry` (required): what happened, what you learned, what matters
@@ -99,6 +153,15 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
- `mempalace_diary_read` — Read recent diary entries
- `agent_name` (required)
- `last_n`: number of entries (default 10)
- `mempalace_memories_filed_away` — Acknowledge the latest silent auto-save checkpoint.
- Returns: how many messages were tucked into drawers since the last ack
- When to call: at the START of a session, to confirm prior-conversation persistence
### System
- `mempalace_hook_settings` — Get or set auto-save hook behavior. Host-level setting; do not change silently.
- `silent_save`: true saves directly without MCP-level clutter
- `desktop_toast`: true shows a desktop notification when saves complete
- `mempalace_reconnect` — Force reconnect to the palace database after external writes or stale index state
## Setup
@@ -0,0 +1,125 @@
# MemPalace Shared-Brain Coordination Protocol
The canonical protocol for agents sharing one MemPalace hub — memory
discipline plus the logstream coordination layer (RFC 003). Like
`recall-protocol.md`, this file is the single source of truth: skills,
rules, and system prompts should link here or copy the System-Prompt
Snippet below verbatim, so the protocol never drifts per-agent.
## The two layers
A shared palace gives every agent two distinct channels. Do not mix them:
- **Memory (drawers, KG, diary)** — durable knowledge worth recalling
later. Searched semantically. Follow
[`recall-protocol.md`](recall-protocol.md).
- **Coordination (logstream events + artifacts)** — active work moving
between agents *right now*: delegations, replies, patches, acks.
Filtered structurally, never searched semantically.
Rule of thumb: if another agent should **act** on it, it is an event.
If a future session should **know** it, it is a drawer. A concluded
delegation usually produces both: the events carried the work; a drawer
records the outcome.
## Identity
Every agent uses one stable `from_agent` identity, formatted
`<machine>-<harness>` (e.g. `mac-claude`, `windows-codex`,
`aero-opencode`). Never impersonate another agent; never rotate names —
the event trail is only auditable if identities are stable.
## Delegating work (requester)
1. Generate a `correlation_id` for the task: `task_<short-description>`
plus enough entropy to be unique (e.g. `task_fix_ranking_7f3a`).
2. `mempalace_event_append` with `type=task.request`, `stream=project/<name>`,
`room=delegation`, `to_agent=<worker>`, `status=open`, and a body that
states the goal, the branch, the base commit, and the definition of done.
3. Wait for the reply: `mempalace_event_wait` with the `correlation_id`
and `to_agent=<you>`. Waits cap at 5 minutes — loop, passing
`since_event_id` of the last event you saw.
4. When a `patch.ready` arrives: `mempalace_artifact_get`, verify the
`sha256`, apply locally, run the stated verification.
5. Always close the loop with `mempalace_event_ack` — `status=applied`
on success, `status=failed` with verbatim evidence on failure.
## Receiving work (worker)
1. Poll or wait for `type=task.request`, `to_agent=<you>` (plus `*`
broadcasts are matched automatically).
2. Claim it: `mempalace_event_ack` with `status=claimed` so no other
agent duplicates the work.
3. Do the work on the stated branch/commit.
4. Deliver through the formal channel — `mempalace_patch_submit` with the
diff, `correlation_id`, `branch`, and `base_commit`. **Pushing a
branch is not a handoff**; the event is. If you also pushed, say so
in the body.
5. If blocked or unable to produce a patch, still reply:
`type=task.reply` with `status=blocked` or `failed` and verbatim
notes. Silence is the only unrecoverable failure.
## Hard rules
- **Never apply a patch silently.** Fetching an artifact is free;
applying it is an explicit local decision, stated to the user.
- **Verify hashes.** An artifact's `sha256` must match its content
before you act on it.
- **Append-only.** Never try to edit or delete events; supersede with a
new event (`status=superseded`) referencing the old one.
- **Exact payloads.** Bodies and artifacts are verbatim — no summaries
of diffs, no truncated logs. If it is too big, store it as an
artifact and reference it.
- **Close every loop.** Every `task.request` you claimed ends in an
`applied`, `failed`, or `blocked` — no dangling `open` tasks.
- **File the outcome.** When a delegation concludes, write one drawer
(`mempalace_add_drawer`) recording what was decided/learned, so the
result is searchable without replaying the event trail.
## System-Prompt Snippet
Copy this block into an agent's system prompt / custom instructions.
Replace `<AGENT_ID>` with the agent's stable identity.
```text
## MemPalace shared brain
You share a MemPalace hub with other agents. Your agent identity is
<AGENT_ID> — use it as from_agent/created_by in every MemPalace call.
Memory (recall + writing):
- Before answering about past work, decisions, people, or projects,
search the palace (mempalace_search; mempalace_kg_query for
relational/temporal facts). Quote results verbatim — never paraphrase
stored content. If the palace has nothing, say so; don't guess.
- File durable outcomes (decisions, conclusions, learned facts) with
mempalace_add_drawer. When a fact changes: mempalace_kg_invalidate the
old fact, then mempalace_kg_add the new one. Don't file secrets or
tokens.
Coordination (logstream):
- Check your inbox when starting work and before long tasks:
mempalace_event_list with to_agent=<AGENT_ID> (new since your last
seen event id).
- To delegate: mempalace_event_append (type=task.request, stream=
project/<name>, room=delegation, correlation_id=task_..., status=open,
body = goal + branch + base commit + definition of done), then
mempalace_event_wait on that correlation_id for the reply.
- When you accept a task: ack it with status=claimed. Deliver code as a
patch via mempalace_patch_submit (never just push a branch and go
silent). If blocked, reply with status=blocked and verbatim notes.
- When you receive a patch: mempalace_artifact_get, verify sha256,
apply only with explicit user-visible intent, run the stated tests,
then mempalace_event_ack with status=applied or failed.
- Events are append-only and verbatim. Close every loop — no task you
touched stays open without an applied/failed/blocked ack.
```
## See also
- [`recall-protocol.md`](recall-protocol.md) — the search-before-answer
memory protocol this composes with.
- [Agent Logstream concepts](../../website/concepts/agent-logstream.md) —
event/artifact model and the full tool reference.
- RFC 003 (`docs/rfcs/003-agent-logstream-coordination.md`) — design
rationale and storage model.
+125
View File
@@ -0,0 +1,125 @@
# MemPalace Recall Protocol
The canonical "search before answering" protocol shared across every
MemPalace integration (Cursor, Antigravity, Claude Code, Codex,
OpenClaw). This file is the single source of truth — skills and rules
should link here rather than restating the protocol, so the rule never
drifts from the skill.
The protocol exists to honour MemPalace's foundational promise:
**100% recall, verbatim, never guess.** When the palace might hold the
answer, the agent must read the palace before answering from model
memory.
## When to recall
Search the palace **before answering** whenever the user asks about
anything that may already be filed:
- Past work, prior decisions, or "what did we do / decide / try?"
- A person, project, or entity ("who is …", "what is …")
- Something that happened in an earlier session ("remember when …",
"last time …", "the thing we discussed")
- A preference, fact, or relationship that could have changed over time
If the question is pure greenfield work with no memory relevance (e.g.
"rename this variable", "fix this typo"), do not search — recall is
question-driven, not reflexive.
## The protocol
1. **On wake-up** (if a session-start hook injected context, honour its wing scoping / `additional_context`): scope recall to the wing inferred from the workspace, then continue.
2. **Before responding** about people, projects, past events, or prior
decisions: call `mempalace_search` first. For relational or temporal
facts ("who reported to whom in March", "what was true then"), call
`mempalace_kg_query` instead or as well.
3. **If unsure** about a fact (name, age, relationship, preference): say
"let me check the palace" and query. Wrong is worse than slow.
4. **Return verbatim.** Quote the drawer's exact stored words. Never
summarize, paraphrase, or lossy-compress what the palace returns —
that is the whole point of the system.
5. **After a substantive session**, record continuity with
`mempalace_diary_write` (background hooks may already do this — do not
double-file).
6. **When a fact changes**, call `mempalace_kg_invalidate` on the old
fact, then `mempalace_kg_add` for the new one.
## Tool selection
| You need | Tool |
|---|---|
| Find any memory by meaning | `mempalace_search` (start here) |
| Relational / time-bound facts about an entity | `mempalace_kg_query` |
| The chronological story of an entity | `mempalace_kg_timeline` |
| Recent session continuity | `mempalace_diary_read` |
| Which wings / rooms exist (when scope unknown) | `mempalace_list_wings`, `mempalace_list_rooms` |
| Record this session | `mempalace_diary_write` |
`mempalace_search` takes a short natural-language `query` (keywords or a
question — not a system prompt or pasted conversation) plus optional
`wing` / `room` filters and `limit` (default 5).
## Unhappy paths
- **Empty results.** Say the palace has nothing on this; do not invent an
answer to fill the gap. Offer to widen the search (drop the wing
filter) or to file the new information.
- **MCP unavailable / tool error.** Surface the error plainly and suggest
the user verify the server (`mempalace status`, or re-run install).
Do not silently fall back to guessing from model memory.
- **Palace index corrupt / compactor error.** When the server returns an
error mentioning the HNSW segment writer, a ChromaDB compaction
failure, or a stuck "Not connected" state after a write, the on-disk
vector index is out of sync with `chroma.sqlite3` — but the drawer rows
are intact in SQLite. Recover by rebuilding the index from SQLite, not
by re-mining. See "Recovering a corrupt index" below. Do not attempt an
in-process repair from the agent; guide the user to run the CLI.
- **Stale or conflicting facts.** Prefer the knowledge graph's
time-valid answer; if a fact has changed, invalidate the old one and
add the new one rather than overwriting context silently.
## Recovering a corrupt index
A ChromaDB compaction failure can leave the drawers HNSW index out of
sync with `chroma.sqlite3` and wedge the MCP server (every call returns
"Not connected"). The data is safe in SQLite; rebuild the index from it.
Guide the user through these CLI steps — never run an in-process rebuild
from the agent (it can break other live clients):
1. Stop the MCP server (kill the `mempalace-mcp` process, or restart the
host editor).
2. Optional backup of the palace directory (`--archive-existing` already
moves the old palace aside, so this is belt-and-suspenders):
- macOS / Linux: `cp -a ~/.mempalace/palace ~/.mempalace/palace.bak.$(date +%F)`
- Windows (PowerShell): `Copy-Item -Recurse "$env:USERPROFILE\.mempalace\palace" "$env:USERPROFILE\.mempalace\palace.bak"`
3. Rebuild from SQLite:
`mempalace repair --mode from-sqlite --archive-existing --yes`
4. Verify: `mempalace repair-status` (divergence should read 0).
5. Restart the MCP server.
Do **not** re-mine from source files to recover: re-mining drops drawers
added through the MCP server and diary entries, which have no source file
(see MemPalace issue #1843).
## Anti-patterns
- Answering about past work, people, or decisions from model memory when
the palace might know — search first.
- Paraphrasing or summarizing stored content instead of quoting it
verbatim.
- Searching reflexively on every turn, including pure greenfield coding
with no memory relevance.
- Pasting the full conversation or a system prompt into the `query`
argument — keep queries short and keyword-driven.
## See also
- [`integrations/openclaw/SKILL.md`](../openclaw/SKILL.md) — the original
full-protocol skill this is distilled from.
- [`coordination-protocol.md`](coordination-protocol.md) — the shared-brain
companion protocol: when agents delegate work to each other over the
hub, they use the logstream (`mempalace_event_append` /
`mempalace_event_wait`), not drawers. Recall answers questions;
the logstream moves work.
- MemPalace design principles (verbatim, local-first, never summarize):
<https://github.com/MemPalace/mempalace>
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"mempalace": {
"command": "mempalace-mcp"
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ The Python package that powers MemPalace. All modules, all logic.
| `dialect.py` | AAAK compression — entity codes, emotion markers, 30x lossless ratio |
| `knowledge_graph.py` | Temporal entity-relationship graph — SQLite, time-filtered queries, fact invalidation |
| `palace_graph.py` | Room-based navigation graph — BFS traversal, tunnel detection across wings |
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, Palace Protocol, agent diary |
| `mcp_server.py` | MCP server — 34 tools, AAAK auto-teach, Palace Protocol, agent diary |
| `onboarding.py` | Guided first-run setup — asks about people/projects, generates AAAK bootstrap + wing config |
| `entity_registry.py` | Entity code registry — maps names to AAAK codes, handles ambiguous names |
| `entity_detector.py` | Auto-detect people and projects from file content |
+28
View File
@@ -17,6 +17,7 @@ Public surface:
from .base import (
BackendClosedError,
BackendError,
BackendMismatchError,
BaseBackend,
BaseCollection,
CollectionNotInitializedError,
@@ -24,14 +25,25 @@ from .base import (
EmbedderIdentityMismatchError,
GetResult,
HealthStatus,
LexicalHit,
LexicalResult,
MaintenanceResult,
PalaceNotFoundError,
PalaceRef,
QueryResult,
UnsupportedCapabilityError,
UnsupportedFilterError,
UnsupportedMaintenanceKindError,
)
from .chroma import ChromaBackend, ChromaCollection
from .milvus import MilvusBackend, MilvusCollection
from .pgvector import PgVectorBackend, PgVectorCollection
from .qdrant import QdrantBackend, QdrantCollection
from .sqlite_exact import SQLiteExactBackend, SQLiteExactCollection
from .registry import (
available_backends,
detect_backend_for_path,
detect_backends_for_path,
get_backend,
get_backend_class,
register,
@@ -43,6 +55,7 @@ from .registry import (
__all__ = [
"BackendClosedError",
"BackendError",
"BackendMismatchError",
"BaseBackend",
"BaseCollection",
"ChromaBackend",
@@ -52,11 +65,26 @@ __all__ = [
"EmbedderIdentityMismatchError",
"GetResult",
"HealthStatus",
"LexicalHit",
"LexicalResult",
"MaintenanceResult",
"MilvusBackend",
"MilvusCollection",
"PalaceNotFoundError",
"PalaceRef",
"PgVectorBackend",
"PgVectorCollection",
"QdrantBackend",
"QdrantCollection",
"QueryResult",
"SQLiteExactBackend",
"SQLiteExactCollection",
"UnsupportedCapabilityError",
"UnsupportedFilterError",
"UnsupportedMaintenanceKindError",
"available_backends",
"detect_backend_for_path",
"detect_backends_for_path",
"get_backend",
"get_backend_class",
"register",
+71
View File
@@ -0,0 +1,71 @@
"""Shared embedder-identity sidecar (RFC 001).
A small JSON file in the palace directory, keyed by collection name, recording
the embedder identity (``model_name`` / ``dimension``). It is deliberately
*separate* from a backend's mismatch marker: a 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 one. The sidecar is unguarded, so a brand-new palace can record
identity immediately — the same approach the chroma backend uses.
"""
import json
import os
from typing import Optional
EMBEDDER_SIDECAR_FILENAME = "mempalace_embedder.json"
def read_embedder_sidecar(path: Optional[str], collection_name: Optional[str]):
"""Return the recorded :class:`EmbedderIdentity` for ``collection_name``, or None.
Robust to a missing, unreadable, or malformed (non-dict) sidecar — any of
those degrade to ``None`` (the ``unknown`` state) rather than raising.
"""
from .base import EmbedderIdentity
if not path or not collection_name or not os.path.isfile(path):
return None
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict):
return None
entry = data.get(collection_name)
if not isinstance(entry, dict) or not entry.get("model_name"):
return None
return EmbedderIdentity(
model_name=str(entry["model_name"]),
dimension=int(entry.get("dimension") or 0),
)
def write_embedder_sidecar(path: Optional[str], collection_name: Optional[str], identity) -> None:
"""Record ``identity`` for ``collection_name`` in the sidecar, creating it if needed.
No-ops for a missing path, missing collection name, or a nameless identity.
Preserves other collections' entries; never raises on I/O failure.
"""
if not path or not collection_name or not identity or not getattr(identity, "model_name", ""):
return
data: dict = {}
if os.path.isfile(path):
try:
with open(path, encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
data = loaded
except (OSError, json.JSONDecodeError):
data = {}
data[collection_name] = {
"model_name": str(identity.model_name),
"dimension": int(identity.dimension or 0),
}
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.chmod(path, 0o600)
except (OSError, NotImplementedError):
pass
+330 -2
View File
@@ -14,8 +14,8 @@ conformance suite land in follow-up PRs.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar, Optional
from dataclasses import dataclass, field
from typing import ClassVar, Optional, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
@@ -58,6 +58,23 @@ class UnsupportedFilterError(BackendError):
"""
class UnsupportedCapabilityError(BackendError):
"""Raised when a backend does not implement an optional capability."""
class UnsupportedMaintenanceKindError(BackendError):
"""Raised when ``run_maintenance(kind)`` is called with an unadvertised kind.
A backend MUST advertise a kind in ``maintenance_kinds`` before it accepts
it (RFC 001). Advertising a kind it does not implement is a conformance
failure; a kind it has no analogue for MUST be omitted, not no-op'd.
"""
class BackendMismatchError(BackendError):
"""Raised when a selected backend does not match existing palace artifacts."""
class DimensionMismatchError(BackendError):
"""Raised when the embedding dimension on write does not match the collection."""
@@ -66,6 +83,15 @@ class EmbedderIdentityMismatchError(BackendError):
"""Raised when the stored embedder model name differs from the current one."""
class EmbedderIdentityUnknownWarning(UserWarning):
"""Emitted on first open of a collection with no recorded embedder identity.
Legacy palaces created before identity tracking carry no model name. Per
RFC 001 the right behavior is warn-not-fail: the identity is recorded on
the next write and subsequent opens become strict.
"""
# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------
@@ -78,6 +104,32 @@ class PalaceRef:
``id`` is always present and is the key backends use to cache handles.
``local_path`` is populated for filesystem-rooted palaces.
``namespace`` is used by server-mode backends for tenant / prefix routing.
Isolation contract (RFC 001 §2.1, conformance: ``tests/test_backend_conformance.py``)
-----------------------------------------------------------------------------------
``id`` is the *required* isolation key. Within a single backend instance:
A record written for one ``PalaceRef.id`` MUST NOT be returned,
modified, or deleted by an operation issued for a different
``PalaceRef.id``. Cross-palace access is a spec violation.
``namespace`` is *additional* partitioning, honored only by backends that
advertise the ``supports_namespace_isolation`` capability. For those
backends the same guarantee extends to namespaces:
A record written under one ``namespace`` MUST NOT be returned,
modified, or deleted by an operation issued under a different
``namespace`` within the same backend instance. Cross-namespace
access is a spec violation.
Backends that do not advertise ``supports_namespace_isolation`` (e.g.
path-rooted ``chroma`` / ``sqlite_exact``) MUST NOT silently accept and
ignore a populated ``namespace`` — they MUST raise
:class:`UnsupportedCapabilityError` (same spirit as
:class:`UnsupportedFilterError`). Callers targeting those backends MUST
leave ``namespace`` as ``None``. Isolation conformance lives in
``tests/_backend_conformance.py`` (cross-id arm for every backend;
same-id / different-namespace arm for advertisers only).
"""
id: str
@@ -85,6 +137,114 @@ class PalaceRef:
namespace: Optional[str] = None
@dataclass(frozen=True)
class EmbedderIdentity:
"""Identity of the embedder that produced a collection's vectors (RFC 001).
``model_name`` is the stable identity persisted alongside a collection and
checked on subsequent opens. ``dimension`` is the vector width. A
``dimension`` of ``0`` means *unknown / not probed* — comparisons treat it
as "no dimension signal" rather than a real zero-width vector, so a cheap
read-path check can compare model names without loading the model.
"""
model_name: str
dimension: int = 0
@dataclass(frozen=True)
class MaintenanceResult:
"""Observable outcome of ``run_maintenance(kind)`` (RFC 001).
Maintenance is *not* fire-and-forget: a backend MUST serialize concurrent
same-kind runs and report the outcome so a caller can learn it must not
re-trigger. ``status`` is one of:
* ``"ran"`` — this call performed the maintenance.
* ``"already_running"`` — another caller holds the work; this call did
nothing and the caller MUST NOT re-trigger (the production index-build
wedge: concurrent writers each issuing the build stacked exclusive locks).
* ``"noop"`` — nothing needed doing (e.g. the index already exists).
``stats`` is free-form per kind (rows analyzed, bytes reclaimed, index
build time) for benchmark/operator reporting.
"""
kind: str
status: str
stats: dict = field(default_factory=dict)
@runtime_checkable
class Embedder(Protocol):
"""Minimal embedder contract (RFC 001, normative for identity checking).
The fuller embedder RFC (batching/async/pooling) is additive; identity
enforcement depends only on these three members.
"""
model_name: str
dimension: int
def embed(self, texts: list[str]) -> list[list[float]]: ...
def check_embedder_identity(
stored: Optional[EmbedderIdentity],
current: Optional[EmbedderIdentity],
*,
force_model_swap: bool = False,
) -> str:
"""Three-state embedder-identity check (RFC 001).
Returns the resolved state and raises on a hard, unforced conflict:
* ``"unknown"`` — no identity recorded yet (legacy collection), or the
current embedder is nameless. The caller warns and records on write.
* ``"known_match"`` — stored name (and dimension, when both known) equal
the current embedder. Proceed normally.
* ``"known_mismatch"`` — names or dimensions differ. Without
``force_model_swap`` this raises (:class:`EmbedderIdentityMismatchError`
for a model swap, :class:`DimensionMismatchError` for a width change,
which is checked first because mismatched vectors are physically
unusable). With ``force_model_swap`` it returns the state so the caller
can re-record the identity and log the swap.
A ``dimension`` of ``0`` on either side means "unknown" and is skipped, so
a model-name-only check (cheap read path) still works.
"""
if current is None or not current.model_name:
return "unknown"
if stored is None:
return "unknown"
dim_conflict = bool(stored.dimension and current.dimension) and (
stored.dimension != current.dimension
)
name_conflict = stored.model_name != current.model_name
if not dim_conflict and not name_conflict:
return "known_match"
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."
)
raise EmbedderIdentityMismatchError(
f"collection was built with embedder {stored.model_name!r} but the "
f"current embedder is {current.model_name!r}. Searching across a model "
"swap silently degrades recall. Re-embed the palace, or run "
"`mempalace palace set-embedder --model <name> --force` to record the "
"new identity if you know the vectors are compatible."
)
@dataclass(frozen=True)
class HealthStatus:
ok: bool
@@ -177,6 +337,23 @@ class GetResult(_DictCompatMixin):
return cls(ids=[], documents=[], metadatas=[], embeddings=None)
@dataclass(frozen=True)
class LexicalHit:
"""One hit from backend lexical candidate search."""
id: str
document: str
metadata: dict
score: float
@dataclass(frozen=True)
class LexicalResult:
"""Typed return from ``BaseCollection.lexical_search``."""
hits: list[LexicalHit]
# ---------------------------------------------------------------------------
# Collection contract
# ---------------------------------------------------------------------------
@@ -253,6 +430,119 @@ class BaseCollection(ABC):
def health(self) -> HealthStatus:
return HealthStatus.healthy()
@property
def distance_metric(self) -> str:
"""The space this collection's ``distances`` are reported in.
Defaults to the owning backend's declared metric (cosine for all
in-tree backends). Collections that can vary per-collection — e.g. a
legacy Chroma palace built without ``hnsw:space=cosine`` — override
this to report their actual space so core ranking converts correctly.
"""
return "cosine"
def get_stored_embedder_identity(self) -> Optional[EmbedderIdentity]:
"""Return the embedder identity recorded for this collection, if any.
Returns ``None`` when nothing is recorded — a legacy collection, or a
backend that does not yet persist identity. Core treats ``None`` as the
``unknown`` state (warn, do not fail). Backends override this and
:meth:`set_embedder_identity` against their own metadata store.
"""
return None
def set_embedder_identity(self, identity: EmbedderIdentity) -> None:
"""Persist this collection's embedder identity. Default: no-op.
A backend without an identity slot inherits the no-op default and so
stays permanently ``unknown`` (safe — it simply never enforces). The
enforcement choke point calls this when recording on first write or
on an explicit, forced model swap.
"""
return None
def effective_embedder_identity(self) -> Optional[EmbedderIdentity]:
"""The identity of the embedder this collection actually uses.
For ``server_embedder`` backends that ignore the injected embedder,
this reports the server-side embedder so the same identity rules apply
(RFC 001). Defaults to ``None`` — the collection is embedded by the
injected/core embedder, and the caller supplies the current identity.
"""
return None
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
"""Return every matching record's metadata in one logical pass (#1796).
Default implementation pages through :meth:`get` using
``limit``/``offset`` -- correct for backends with a real server-side
cursor (e.g. Chroma's SQL OFFSET), and the same shape callers already
relied on before this method existed.
Backends whose ``get(limit=, offset=)`` is implemented by fully
materializing a result set and then Python-slicing it (no true
server-side cursor) MUST override this method to walk their native
cursor exactly once instead. Calling the default implementation on
such a backend is O(n^2) in collection size: each page re-walks the
entire collection just to discard everything outside the requested
slice. See issue #1796.
"""
all_meta: list[dict] = []
offset = 0
page_size = 1000
while True:
kwargs: dict = {"include": ["metadatas"], "limit": page_size, "offset": offset}
if where:
kwargs["where"] = where
batch = self.get(**kwargs)
batch_meta = batch.metadatas if hasattr(batch, "metadatas") else batch.get("metadatas")
if not batch_meta:
break
all_meta.extend(batch_meta)
if len(batch_meta) < page_size:
break
offset += len(batch_meta)
return all_meta
def facet_counts(
self,
field: str,
where: Optional[dict] = None,
limit: int = 1000,
) -> dict[str, int]:
"""Return counts for each distinct value of a metadata field."""
raise UnsupportedCapabilityError("backend does not support facet_counts")
def maintenance_state(self) -> dict:
"""Return a structured snapshot of this collection's maintenance state.
Free-form per backend (e.g. row count, whether a vector index exists,
last-analyze age). Used by benchmark harnesses to record state
alongside each latency/recall measurement so an un-analyzed store is
not compared against a settled one (RFC 001). Defaults to empty.
"""
return {}
def run_maintenance(self, kind: str) -> "MaintenanceResult":
"""Run a maintenance ``kind`` and return an observable result (RFC 001).
Backends advertise supported kinds in ``BaseBackend.maintenance_kinds``
and override this. The default supports nothing, so every kind raises
:class:`UnsupportedMaintenanceKindError`. Implementations MUST serialize
concurrent same-kind runs and report ``already_running`` rather than
stacking the work.
"""
raise UnsupportedMaintenanceKindError(f"backend does not support maintenance kind {kind!r}")
def lexical_search(
self,
*,
query: str,
n_results: int = 10,
where: Optional[dict] = None,
) -> LexicalResult:
raise UnsupportedCapabilityError("backend does not support lexical_search")
def update(
self,
*,
@@ -311,11 +601,49 @@ class BaseBackend(ABC):
Instances are lightweight on construction — no I/O, no network. All
connection work is deferred to ``get_collection``. Instances are thread-
safe for concurrent ``get_collection`` calls across different palaces.
Every backend MUST satisfy the per-``PalaceRef.id`` isolation guarantee in
:class:`PalaceRef`. Backends that additionally isolate by
``PalaceRef.namespace`` (multi-tenant / hosted deployments) MUST advertise
the ``supports_namespace_isolation`` capability token; doing so is a
promise to satisfy the cross-namespace guarantee and to pass the namespace
arm of the conformance suite. Backends without the token MUST raise
:class:`UnsupportedCapabilityError` when ``PalaceRef.namespace`` is
non-``None`` rather than silently accept-and-ignore (RFC 001 §4.4).
"""
name: ClassVar[str]
spec_version: ClassVar[str] = "1.0"
capabilities: ClassVar[frozenset[str]] = frozenset()
#: The space ``query()`` reports ``distances`` in (RFC 001 §2.1).
#: One of ``"cosine"`` | ``"l2"`` | ``"ip"``. The contract for the
#: ``distances`` field is *lower = closer* regardless of metric; core
#: search converts distance→similarity off this declaration rather than
#: assuming cosine. All in-tree backends are cosine today.
distance_metric: ClassVar[str] = "cosine"
#: Maintenance kinds this backend implements (RFC 001). Reserved names:
#: ``"analyze"`` (refresh planner/query statistics), ``"compact"`` (reclaim
#: space, rewrite storage), ``"reindex"`` (build/rebuild secondary indexes).
#: A backend with no analogue for a kind MUST omit it rather than declare a
#: no-op, so a benchmark harness can trust the set. Backends MAY add their
#: own kinds. ``run_maintenance`` raises ``UnsupportedMaintenanceKindError``
#: for anything not listed here.
maintenance_kinds: ClassVar[frozenset[str]] = frozenset()
def require_namespace_support(self, palace: PalaceRef) -> None:
"""Raise if ``palace.namespace`` is set but this backend does not isolate by it.
Call at the start of ``get_collection`` (and any other entry that
accepts a :class:`PalaceRef`) so non-advertising backends never
silently drop a tenant namespace (RFC 001 §4.4).
"""
if palace.namespace is None:
return
if "supports_namespace_isolation" not in self.capabilities:
raise UnsupportedCapabilityError(
f"{type(self).name} does not advertise supports_namespace_isolation; "
f"leave PalaceRef.namespace as None (got {palace.namespace!r})"
)
@abstractmethod
def get_collection(
+1108 -134
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
"""Core-side embedding adapter for explicit-vector backends."""
from __future__ import annotations
from typing import Optional
from .base import BaseCollection
def _embed_texts(texts: list[str]) -> list[list[float]]:
"""Embed ``texts`` with the configured local embedding function.
Embedding functions return ``list[np.ndarray]`` (float32). ``list(arr)``
would unpack that into ``np.float32`` *scalars*, which ChromaDB's
``normalize_embeddings`` rejects outright ("Expected embeddings to be a
list of floats or ints..."), so every write through this wrapper must
convert to real Python floats. ``.tolist()`` does that in C; the
``float(x)`` branch covers embedders that already hand back plain
sequences.
"""
if not texts:
return []
from ..embedding import get_embedding_function
ef = get_embedding_function()
vectors = ef(input=texts)
return [
v.tolist() if hasattr(v, "tolist") else [float(x) for x in v] # numpy | plain sequence
for v in vectors
]
def _as_list(value):
"""Normalize ChromaDB's ``OneOrMany`` shape (``str`` | ``dict`` | sequence) to a list.
A bare ``str`` (a document/id) or ``dict`` (a single metadata) must be
*wrapped*, not iterated: ``list("abc")`` yields ``['a', 'b', 'c']`` and
``list({"k": 1})`` yields ``['k']`` — either desyncs embeddings/metadatas
from ``ids`` on explicit-vector backends (pgvector, sqlite_exact). A list is
returned unchanged (no copy); any other iterable is materialized once.
See PR #1706/#1707 review.
"""
if isinstance(value, (str, dict)):
return [value]
if isinstance(value, list):
return value
return list(value)
class EmbeddingCollection(BaseCollection):
"""Wrap a collection that requires explicit vectors.
Backends opt in with the ``requires_explicit_embeddings`` capability.
Core callers can keep using ``documents=`` and ``query_texts=``; this
wrapper computes vectors locally before delegating to the backend.
"""
def __init__(self, inner: BaseCollection):
self._inner = inner
def __getattr__(self, name):
return getattr(self._inner, name)
@property
def distance_metric(self) -> str:
# Explicit delegation: ``BaseCollection`` defines ``distance_metric``
# as a property, so it resolves on this subclass and ``__getattr__``
# never fires — without this override the wrapper would report the
# base "cosine" default and mask a wrapped non-cosine backend.
return self._inner.distance_metric
# Same shadowing reason as ``distance_metric``: these are concrete methods
# on ``BaseCollection``, so ``__getattr__`` never delegates them. Forward
# explicitly to the wrapped backend collection's identity store.
def get_stored_embedder_identity(self):
return self._inner.get_stored_embedder_identity()
def set_embedder_identity(self, identity) -> None:
return self._inner.set_embedder_identity(identity)
def effective_embedder_identity(self):
return self._inner.effective_embedder_identity()
def maintenance_state(self) -> dict:
return self._inner.maintenance_state()
def run_maintenance(self, kind: str):
return self._inner.run_maintenance(kind)
def add(self, *, documents, ids, metadatas=None, embeddings=None):
documents = _as_list(documents)
ids = _as_list(ids)
if metadatas is not None:
metadatas = _as_list(metadatas)
if embeddings is None:
embeddings = _embed_texts(documents)
return self._inner.add(
documents=documents,
ids=ids,
metadatas=metadatas,
embeddings=embeddings,
)
def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
documents = _as_list(documents)
ids = _as_list(ids)
if metadatas is not None:
metadatas = _as_list(metadatas)
if embeddings is None:
embeddings = _embed_texts(documents)
return self._inner.upsert(
documents=documents,
ids=ids,
metadatas=metadatas,
embeddings=embeddings,
)
def query(
self,
*,
query_texts: Optional[list[str] | str] = None,
query_embeddings: Optional[list[list[float]]] = None,
n_results: int = 10,
where: Optional[dict] = None,
where_document: Optional[dict] = None,
include: Optional[list[str]] = None,
):
if query_texts is not None and query_embeddings is None:
query_embeddings = _embed_texts(_as_list(query_texts))
query_texts = None
return self._inner.query(
query_texts=query_texts,
query_embeddings=query_embeddings,
n_results=n_results,
where=where,
where_document=where_document,
include=include,
)
def get(
self, *, ids=None, where=None, where_document=None, limit=None, offset=None, include=None
):
return self._inner.get(
ids=ids,
where=where,
where_document=where_document,
limit=limit,
offset=offset,
include=include,
)
def delete(self, *, ids=None, where=None):
return self._inner.delete(ids=ids, where=where)
def count(self) -> int:
return self._inner.count()
def estimated_count(self) -> int:
return self._inner.estimated_count()
def close(self) -> None:
return self._inner.close()
def health(self):
return self._inner.health()
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None):
return self._inner.lexical_search(query=query, n_results=n_results, where=where)
def facet_counts(
self, field: str, where: Optional[dict] = None, limit: int = 1000
) -> dict[str, int]:
# ``BaseCollection.facet_counts`` is a concrete method that raises
# ``UnsupportedCapabilityError`` as its default. MRO resolves it on
# this subclass before ``__getattr__`` ever fires, so without an
# explicit forwarder every facet call against a wrapped backend
# (qdrant, pgvector, sqlite_exact) raises and silently degrades to
# client-side counting in mcp_server's try/except.
return self._inner.facet_counts(field, where=where, limit=limit)
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
# ``BaseCollection.get_all_metadata`` ships a concrete default that
# pages through ``self.get(include=["metadatas"])``. Without this
# forwarder, MRO resolves the call here on the subclass and runs the
# base default — which routes back through ``self.get()`` (the
# wrapper's get, then ``__getattr__`` to the inner's get). Result:
# the inner's overridden ``get_all_metadata`` (e.g. pgvector's
# ``with_document=False`` fast path from #1892) is never reached,
# and every metadata-only fetch transfers the full document column
# over the wire. Same MRO-shadow pattern as ``facet_counts`` /
# ``lexical_search`` above.
return self._inner.get_all_metadata(where=where)
def update(self, *, ids, documents=None, metadatas=None, embeddings=None):
ids = _as_list(ids)
if documents is not None:
documents = _as_list(documents)
if embeddings is None:
embeddings = _embed_texts(documents)
if metadatas is not None:
metadatas = _as_list(metadatas)
return self._inner.update(
ids=ids,
documents=documents,
metadatas=metadatas,
embeddings=embeddings,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+47 -8
View File
@@ -125,6 +125,38 @@ def get_backend(name: str) -> BaseBackend:
return inst
def detect_backends_for_path(path: str) -> list[str]:
"""Return all registered backend names whose artifacts are present at ``path``.
Detection is a migration/protection aid for local palaces. Backends are
checked in registry-name order so callers get deterministic diagnostics if
a broken directory contains artifacts from more than one backend.
"""
_discover_entry_points()
detected = []
for name in sorted(_registry):
cls = _registry[name]
try:
if cls.detect(path):
detected.append(name)
except Exception:
logger.exception("detect() raised on backend %r", name)
return detected
def detect_backend_for_path(path: str) -> Optional[str]:
"""Return the single detected backend at ``path``, or ``None``.
If multiple backend artifacts are present, the first name in registry order
is returned for backward compatibility. Callers that enforce mismatch
protection should use :func:`detect_backends_for_path`.
"""
detected = detect_backends_for_path(path)
if detected:
return detected[0]
return None
def reset_backends() -> None:
"""Close and drop all cached backend instances (primarily for tests)."""
with _lock:
@@ -161,14 +193,9 @@ def resolve_backend_for_palace(
return candidate
_discover_entry_points()
if palace_path:
for name, cls in _registry.items():
try:
if cls.detect(palace_path):
return name
except Exception:
logger.exception("detect() raised on backend %r", name)
continue
detected = detect_backend_for_path(palace_path) if palace_path else None
if detected:
return detected
return default
@@ -180,10 +207,22 @@ def resolve_backend_for_palace(
def _register_builtins() -> None:
"""Register chroma as the in-tree default."""
from .chroma import ChromaBackend
from .milvus import MilvusBackend
from .pgvector import PgVectorBackend
from .qdrant import QdrantBackend
from .sqlite_exact import SQLiteExactBackend
# Use setdefault semantics so a caller that pre-registered for tests wins.
if "chroma" not in _registry:
_registry["chroma"] = ChromaBackend
if "milvus" not in _registry:
_registry["milvus"] = MilvusBackend
if "qdrant" not in _registry:
_registry["qdrant"] = QdrantBackend
if "sqlite_exact" not in _registry:
_registry["sqlite_exact"] = SQLiteExactBackend
if "pgvector" not in _registry:
_registry["pgvector"] = PgVectorBackend
_register_builtins()
File diff suppressed because it is too large Load Diff
+285
View File
@@ -0,0 +1,285 @@
"""Writing and pruning palace backups.
``mempalace migrate`` and ``mempalace repair max-seq-id`` each write a fresh,
timestamped backup every time they run and historically never deleted the old
ones. On a machine that mines or repairs on a schedule those full-size copies
accumulate silently — a real palace was found with hundreds of gigabytes of
backups sitting beside only a few hundred megabytes of live data, nearly
filling the disk. This module prunes the backup set down to a bounded count
after each new backup is written.
The retention count comes from ``MempalaceConfig.max_backups`` (default 10).
``copy_palace_dir`` is the other half: the whole-directory copy that
``mempalace repair`` in its default mode and ``mempalace migrate`` take before
they overwrite a live palace. The other backup paths copy a single file and do
not use it.
"""
import glob
import os
import shutil
import stat
# Names for the file types a backup copy deliberately leaves behind. A socket
# or a named pipe makes ``shutil.copytree`` record an error for that entry and
# raise at the end. Device nodes do not, but dereferencing one copies the
# DEVICE instead of palace data, and a link to ``/dev/zero`` is copied without
# bound, so they are left out for the opposite reason: the copy would succeed
# at the wrong thing.
_UNCOPYABLE_FILE_TYPES = (
(stat.S_ISSOCK, "socket"),
(stat.S_ISFIFO, "named pipe"),
(stat.S_ISCHR, "character device"),
(stat.S_ISBLK, "block device"),
)
_UNNAMED_FILE_TYPE = "not a regular file or directory"
def _file_type_label(mode):
"""Name the file type in ``mode``, falling back to a generic phrase.
The fallback covers a file type this table has no name for. Nothing on
Linux reaches it, because ``stat`` there reports ``S_IFDOOR``,
``S_IFPORT`` and ``S_IFWHT`` as 0. macOS does define ``S_IFWHT``, a
union-mount whiteout, which is precisely what the fallback exists for:
an entry no copy can carry is skipped with an honest reason rather than
read as copyable.
"""
for is_type, label in _UNCOPYABLE_FILE_TYPES:
if is_type(mode):
return label
return _UNNAMED_FILE_TYPE
def _uncopyable_reason(path, *, follow_symlinks):
"""Name why ``path`` cannot be copied into a backup, or return ``None``.
This is an allowlist, matching how the rest of the package treats
directory entries: only regular files and directories are copyable, and
everything else is named and left out.
Args:
path: The directory entry to classify.
follow_symlinks: Mirrors the copy's own link handling. ``True`` when
the copy dereferences links, so the TARGET's file type decides;
``False`` when it recreates them as links, in which case any
symlink is fine and its target is never read.
Returns:
A short human-readable reason, or ``None`` both when the entry is
copyable and when its type could not be established.
Those two ``None`` cases are deliberately the same answer: only a file
type this could read names a reason. An entry whose type it could not
read is left for the copy to attempt and, if it really is broken, to
fail on.
A failed ``stat`` therefore never becomes a reason to skip. It says the
entry cannot be resolved right now, which is not the same as holding no
data, and no errno separates the two: a symlink into a volume that is
not mounted fails exactly like one whose target was deleted, and on
Windows an unmapped drive letter and an unreachable network share both
arrive as ``ENOENT`` as well.
"""
try:
st = os.lstat(path)
except OSError:
return None
if stat.S_ISLNK(st.st_mode):
if not follow_symlinks:
return None
try:
st = os.stat(path)
except OSError:
return None
if stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode):
return None
return _file_type_label(st.st_mode)
def _report_skipped(skipped, src, log):
"""Tell the operator what the copy left out.
Every step that can fail is guarded on its own, because this also runs
after the copy has failed: both callers pass ``print``, so one line the
terminal will not take must cost neither the copy's own outcome nor the
lines after it.
"""
noun = "entry" if len(skipped) == 1 else "entries"
lines = [f" Backup: skipped {len(skipped)} {noun} that cannot be copied:"]
for path, reason in skipped:
try:
name = os.path.relpath(path, src)
except (OSError, ValueError):
# On Windows ``relpath`` rejects paths on different drives; for a
# relative ``src`` it also reaches ``os.getcwd()``, which can fail
# on a deleted directory. Neither costs us the entry's name.
name = path
lines.append(f" {name} ({reason})")
for line in lines:
try:
log(line)
except UnicodeError:
# An stdout that cannot encode this entry's name. Dropping the
# line would leave a header whose count disagrees with the list
# under it, so the name is escaped and retried before it is
# given up on.
try:
log(line.encode("ascii", "backslashreplace").decode("ascii"))
except (OSError, UnicodeError):
continue
except OSError:
# A write that the device refused. A short report is buffered
# whole, so this arrives only once the report is long enough to
# flush partway through, and then the header is already out.
# Anything else is a caller passing something that is not a
# working ``log``, which should surface rather than be swallowed.
continue
def copy_palace_dir(src, dst, *, symlinks=False, log=None):
"""Copy a palace directory to ``dst``, skipping entries no copy can carry.
``shutil.copytree`` finishes the copy but raises ``shutil.Error`` at the
end when a directory entry is not something it knows how to duplicate: a
Unix domain socket, or a named pipe. The caller has to treat that as a
failed backup, so the command died before the rebuild the backup was
guarding (#2207). Such entries are runtime artifacts of whatever process
created them and never hold palace data, so a backup without them is
still a complete backup of the palace.
Args:
src: Palace directory to copy.
dst: Destination path. Must not already exist.
symlinks: Passed to ``shutil.copytree``. ``True`` recreates symlinks
as symlinks, ``False`` copies what they point at.
log: Optional callable (e.g. ``print``) for human-readable progress.
Returns:
The list of ``(path, reason)`` pairs that were skipped: sorted within
each directory, in the order the copy visited the directories. Like
``prune_backups``, this both returns what it did and logs it.
Every other copy failure still raises out of ``shutil.copytree``. A caller
about to overwrite the live palace must still stop when its safety copy
did not come out whole, so this narrows what the copy attempts rather
than swallowing what it reports.
``shutil.copytree`` hands the callback names rather than the ``os.scandir``
entries it already holds, so classifying costs one extra ``os.lstat`` per
directory entry, and it classifies a whole directory before copying any of
it. An entry whose type changes inside that window is handled by the
earlier reading, which cuts both ways: one that became a socket still
aborts the copy, and one that was a socket and became a regular file is
skipped with its contents. Nothing in MemPalace replaces an entry that
way, and the skipped name is printed either way.
"""
skipped = []
# ``shutil.copytree`` classifies the top directory before it creates the
# destination, so a copy that never starts still produces a skip list.
# Reporting one would tell the operator what a nonexistent backup is
# missing, so both ways that happens are excluded below: a destination
# already occupied (this snapshot), and a destination that could not be
# created at all.
dst_existed = os.path.lexists(dst)
def _ignore(directory, names):
ignored = set()
found = []
for name in names:
path = os.path.join(directory, name)
reason = _uncopyable_reason(path, follow_symlinks=not symlinks)
if reason is not None:
ignored.add(name)
found.append((path, reason))
skipped.extend(sorted(found))
return ignored
def _report():
if skipped and log and not dst_existed and os.path.isdir(dst):
_report_skipped(skipped, src, log)
try:
shutil.copytree(src, dst, symlinks=symlinks, ignore=_ignore)
except BaseException:
# Reported for a failed copy too, including an interrupted one: a
# half-written backup is exactly when the operator needs to know what
# was left out of it. But the copy's own failure is what the caller
# has to diagnose, so a report that fails on top of it is dropped
# rather than raised in its place.
try:
_report()
except Exception:
pass
raise
# Not suppressed here: with the backup complete and nothing destructive
# done yet, a ``log`` that cannot be called at all is a caller's bug worth
# hearing about. The terminal failures inside ``_report_skipped`` are
# absorbed on both paths.
_report()
return skipped
def prune_backups(pattern, max_backups, *, log=None):
"""Delete the oldest backups matching ``pattern`` so at most ``max_backups`` remain.
Args:
pattern: A glob pattern matching the backup paths (files or
directories). The caller is responsible for ``glob.escape``-ing
any literal, non-wildcard portion that can contain glob
metacharacters — palace paths sometimes do (e.g. a ``[``).
max_backups: Number of most-recent backups to keep. ``None`` or any
value ``<= 0`` disables pruning and returns immediately, so a
backup set is never touched when the user has opted out.
log: Optional callable (e.g. ``print``) for human-readable progress.
Returns:
The list of paths that were successfully removed.
Recency is determined by filesystem mtime rather than by parsing the
timestamp out of the name, so it stays correct even when two backup
producers use different timestamp formats. Deletion failures are logged
and skipped: pruning is best-effort cleanup and must never abort the
migrate/repair operation that just completed successfully.
"""
if max_backups is None or max_backups <= 0:
return []
scored = []
for path in glob.glob(pattern):
try:
scored.append((os.path.getmtime(path), path))
except OSError:
# Vanished between glob and stat (concurrent prune / cleanup);
# nothing for us to remove.
continue
if len(scored) <= max_backups:
return []
# Newest first; the path breaks mtime ties so ordering is deterministic.
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
removed = []
for _mtime, path in scored[max_backups:]:
try:
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path)
else:
os.remove(path)
except OSError as exc:
if log:
log(f" Backup prune: could not remove {path}: {exc}")
continue
removed.append(path)
if log:
log(f" Backup prune: removed old backup {path}")
return removed
+1584 -58
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More