324 Commits
Author SHA1 Message Date
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
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
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
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
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
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
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
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
Tom Boucher 234343ef68 fix: preserve collection name on MCP search retry 2026-05-25 20:09:34 -04: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
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
196 changed files with 35906 additions and 1123 deletions
+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.4.0",
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, guided setup.",
"version": "3.6.0",
"author": {
"name": "milla-jovovich"
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "mempalace",
"version": "3.4.0",
"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.6.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
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "mempalace",
"version": "3.4.0",
"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.6.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"
},
@@ -27,7 +27,7 @@
"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"
]
}
+11 -5
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,24 @@ 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 on Windows
# ("Failed to apply logs to the hnsw segment writer") regardless of our
# code — a long-standing, non-reproducible-on-Linux/macOS flake. Retry
# ONLY that specific transient error (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"
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 +51,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"
+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
+9 -9
View File
@@ -22,21 +22,21 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
# Needed for the emulated linux/arm64 build on real pushes.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
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@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -44,7 +44,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# latest -> main (the latest release); semver tags -> released versions.
@@ -56,7 +56,7 @@ jobs:
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
@@ -79,13 +79,13 @@ jobs:
build-gpu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build GPU image (validation only — not published)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.gpu
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Resolved tag: $tag"
- uses: actions/checkout@v6
- 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).
+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
+148 -1
View File
@@ -6,6 +6,142 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [Unreleased]
---
## [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 +630,18 @@ Initial public release.
---
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.6.0...HEAD
[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
+27 -36
View File
@@ -108,39 +108,23 @@ CUDA-accelerated embeddings, build the GPU variant with
## Storage backends
ChromaDB is the default. For the pluggable-backend preview, MemPalace also
ships `sqlite_exact` for local exact-vector correctness checks, and two opt-in
external service backends — `qdrant` (REST) and `pgvector` (Postgres). The two
external backends exercise the storage contract on different substrates (a
REST/dict store and a SQL/JSONB store), so it is not accidentally shaped around
one vendor.
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.
```bash
# local no-service backend
mempalace mine ~/projects/myapp --backend sqlite_exact
| 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` |
# Qdrant backend, defaulting to http://localhost:6333
MEMPALACE_QDRANT_URL=http://localhost:6333 \
mempalace mine ~/projects/myapp --backend qdrant
# Postgres + pgvector backend, defaulting to postgresql://localhost:5432/mempalace
# needs the optional driver: pip install mempalace[pgvector]
# and the `vector` extension available on the server
MEMPALACE_PGVECTOR_DSN=postgresql://localhost:5432/mempalace \
mempalace mine ~/projects/myapp --backend pgvector
```
Qdrant can also be configured with `MEMPALACE_QDRANT_API_KEY`,
`MEMPALACE_QDRANT_NAMESPACE`, and `MEMPALACE_QDRANT_TIMEOUT`; pgvector with
`MEMPALACE_PGVECTOR_NAMESPACE`. Both external backends isolate tenants by
namespace (advertised via the `supports_namespace_isolation` capability) and
write a local marker (`qdrant_backend.json` / `pgvector_backend.json`) to guard
against silently opening a palace against the wrong server.
When `MEMPALACE_QDRANT_URL` or `MEMPALACE_PGVECTOR_DSN` points anywhere other
than your own local or trusted self-hosted service, MemPalace will send and
store verbatim drawer text and metadata there. That is an explicit opt-in
backend choice, never the default.
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
@@ -156,7 +140,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).
---
@@ -224,7 +209,7 @@ Usage and tool reference:
## MCP server
29 MCP tools cover palace reads/writes, knowledge-graph operations,
36 MCP tools cover palace reads/writes, knowledge-graph operations,
cross-wing navigation, drawer management, and agent diaries. Installation
and the full tool list:
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
@@ -238,8 +223,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):
@@ -278,7 +269,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
MIT — see [LICENSE](LICENSE).
<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.4.0-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.6.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/
+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:
+48
View File
@@ -0,0 +1,48 @@
# 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
Restart=on-failure
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
+28
View File
@@ -0,0 +1,28 @@
# 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
# --- Palace location (systemd / bare-metal) -------------------------------------
# In Docker the palace lives on the mempalace-data volume by default.
# MEMPALACE_PALACE_PATH=/var/lib/mempalace/palace
+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.
+119
View File
@@ -0,0 +1,119 @@
# 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.
## Follow-up PRs
PR 2 will apply the policy to hook-triggered writes.
PR 3 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.
+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.6.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
+120
View File
@@ -0,0 +1,120 @@
# 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.
- 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 |
+7
View File
@@ -27,13 +27,16 @@ from .base import (
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
@@ -64,6 +67,9 @@ __all__ = [
"HealthStatus",
"LexicalHit",
"LexicalResult",
"MaintenanceResult",
"MilvusBackend",
"MilvusCollection",
"PalaceNotFoundError",
"PalaceRef",
"PgVectorBackend",
@@ -75,6 +81,7 @@ __all__ = [
"SQLiteExactCollection",
"UnsupportedCapabilityError",
"UnsupportedFilterError",
"UnsupportedMaintenanceKindError",
"available_backends",
"detect_backend_for_path",
"detect_backends_for_path",
+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
+246 -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
# ---------------------------------------------------------------------------
@@ -62,6 +62,15 @@ 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."""
@@ -74,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
# ---------------------------------------------------------------------------
@@ -116,6 +134,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
@@ -301,6 +427,110 @@ 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,
*,
@@ -381,6 +611,20 @@ class BaseBackend(ABC):
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()
@abstractmethod
def get_collection(
+232 -41
View File
@@ -8,7 +8,9 @@ import math
import os
import pickle
import re
import shlex
import sqlite3
import time
from collections import defaultdict
from numbers import Integral
from pathlib import Path
@@ -17,6 +19,8 @@ from typing import Any, Optional
import chromadb
from chromadb.errors import NotFoundError as _ChromaNotFoundError
from ..config import sqlite_read_uri
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BaseBackend,
BaseCollection,
@@ -78,13 +82,52 @@ def _hnsw_link_to_data_ratio(seg_dir: str) -> Optional[float]:
return link_size / data_size
def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
"""Return False when a non-trivial HNSW payload lacks usable link lists.
def _hnsw_metadata_marker_intact(seg_dir: str) -> bool:
"""Return True when ``index_metadata.pickle`` bears a complete envelope.
A missing or empty link_lists.bin is acceptable only for a fresh/empty
segment. Once data_level0.bin has real payload, a zero-byte link_lists.bin
is not a harmless async-flush shape: ChromaDB can later hand the broken
graph to hnswlib and crash in native code.
ChromaDB writes ``index_metadata.pickle`` last during a persist, so an
intact pickle envelope — protocol marker ``0x80`` at the head, ``STOP``
byte ``0x2e`` at the tail — proves the flush finished. Used as a
persist-completion marker: when present, the segment's on-disk shape
(including an all-layer-0 index with an empty ``link_lists.bin``) is the
one chromadb intentionally serialized, not a half-written one.
Deliberately byte-sniffs only; never deserializes. Deserialization can
execute arbitrary code, and the byte-sniff is enough to tell a complete
write from truncation or zero-fill. Assumes pickle protocol >= 2.
"""
meta_path = os.path.join(seg_dir, "index_metadata.pickle")
try:
if not os.path.isfile(meta_path) or os.path.getsize(meta_path) < 16:
return False
with open(meta_path, "rb") as f:
head = f.read(2)
f.seek(-1, 2) # last byte
tail = f.read(1)
except OSError:
return False
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
"""Return False when a non-trivial HNSW payload looks like a partial flush.
A zero-byte ``link_lists.bin`` is *not* corruption on its own. hnswlib
stores the entire layer-0 graph inside ``data_level0.bin`` and only writes
``link_lists.bin`` for elements promoted to level > 0. A small or
low-fanout index where every element stays on layer 0 therefore
serializes an empty ``link_lists.bin`` and loads/searches fine (#1716).
Treating that shape as corruption produced a self-perpetuating quarantine
loop: repair rebuilt the byte-identical all-layer-0 segment, which the next
cold start quarantined again, accumulating drift dirs without bound.
An empty ``link_lists.bin`` only signals trouble when the persist was
interrupted before it could finish. ChromaDB writes
``index_metadata.pickle`` last, so an intact metadata envelope proves the
flush completed and the empty ``link_lists.bin`` is the legitimate
all-layer-0 shape. Only when there is real payload, an empty
``link_lists.bin``, *and* no completion marker do we treat the segment as a
partial flush.
"""
data_path = os.path.join(seg_dir, "data_level0.bin")
link_path = os.path.join(seg_dir, "link_lists.bin")
@@ -97,10 +140,16 @@ def _hnsw_link_lists_is_usable_for_payload(seg_dir: str) -> bool:
if data_size <= _HNSW_MISSING_METADATA_DATA_FLOOR:
return True
return os.path.isfile(link_path) and os.path.getsize(link_path) > 0
if os.path.isfile(link_path) and os.path.getsize(link_path) > 0:
return True
except OSError:
return False
# Real payload with an empty/absent link_lists.bin: legitimate only when
# the persist completed (all-layer-0 index), proven by an intact metadata
# marker. Otherwise it is a half-written segment chromadb could segfault on.
return _hnsw_metadata_marker_intact(seg_dir)
def _hnsw_payload_appears_sane(seg_dir: str) -> bool:
"""Return False when HNSW payload files are structurally implausible."""
@@ -333,17 +382,7 @@ def _segment_appears_healthy(seg_dir: str) -> bool:
if not _hnsw_payload_appears_sane(seg_dir):
return False
try:
size = os.path.getsize(meta_path)
if size < 16:
return False
with open(meta_path, "rb") as f:
head = f.read(2)
f.seek(-1, 2) # last byte
tail = f.read(1)
except OSError:
return False
return len(head) == 2 and head[0] == 0x80 and tail == b"\x2e"
return _hnsw_metadata_marker_intact(seg_dir)
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 300.0) -> list[str]:
@@ -456,7 +495,7 @@ def _vector_segment_id(palace_path: str, collection_name: str) -> Optional[str]:
if not os.path.isfile(db_path):
return None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
row = conn.execute(
"""
@@ -605,6 +644,7 @@ def _hnsw_element_count(palace_path: str, segment_id: str) -> Optional[int]:
# sync_threshold) from expected steady-state lag.
_HNSW_DIVERGENCE_FALLBACK_FLOOR = 2000
_HNSW_DIVERGENCE_FRACTION = 0.10
_HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS = 300.0
def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
@@ -625,7 +665,7 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
if not os.path.isfile(db_path):
return 1000
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
cur = conn.cursor()
cur.execute(
@@ -648,6 +688,45 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
return 1000
def _collection_has_sync_threshold_metadata(palace_path: str, collection_name: str) -> bool:
"""Return True when the collection explicitly stores hnsw:sync_threshold."""
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
return False
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
row = conn.execute(
"""
SELECT 1
FROM collection_metadata cm
JOIN collections c ON cm.collection_id = c.id
WHERE c.name = ?
AND cm.key = 'hnsw:sync_threshold'
LIMIT 1
""",
(collection_name,),
).fetchone()
return row is not None
finally:
conn.close()
except Exception:
logger.debug("_collection_has_sync_threshold_metadata failed", exc_info=True)
return False
def _hnsw_metadata_age_seconds(palace_path: str, segment_id: str) -> Optional[float]:
"""Return index_metadata.pickle age in seconds, or None when unreadable."""
pickle_path = os.path.join(palace_path, segment_id, "index_metadata.pickle")
try:
return max(0.0, time.time() - os.path.getmtime(pickle_path))
except OSError:
return None
def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_drawers") -> dict:
"""Compare sqlite embedding count against HNSW element count.
@@ -692,11 +771,15 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
hnsw_count = _hnsw_element_count(palace_path, seg_id)
out["hnsw_count"] = hnsw_count
sync_threshold = _read_sync_threshold(palace_path, collection_name)
# Two synchronization windows worth — see comment above
# _HNSW_DIVERGENCE_FALLBACK_FLOOR for the rationale.
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
has_explicit_sync_threshold = _collection_has_sync_threshold_metadata(
palace_path,
collection_name,
)
metadata_age_seconds = (
_hnsw_metadata_age_seconds(palace_path, seg_id) if hnsw_count is not None else None
)
out["hnsw_metadata_age_seconds"] = metadata_age_seconds
if hnsw_count is None:
# No pickle yet, so this probe cannot measure HNSW capacity.
@@ -714,21 +797,53 @@ def hnsw_capacity_status(palace_path: str, collection_name: str = "mempalace_dra
divergence = sqlite_count - hnsw_count
out["divergence"] = divergence
threshold = max(divergence_floor, int(sqlite_count * _HNSW_DIVERGENCE_FRACTION))
if divergence > threshold:
# Newer palaces explicitly store mempalace's low sync threshold
# (currently 2), so a gap of dozens of rows is far beyond ordinary
# flush lag. Older palaces may lack the metadata row; keep the
# historical floor for fresh lag there, but do not let a stale pickle
# sit below the floor forever (#1816).
if has_explicit_sync_threshold:
threshold = max(0, 2 * sync_threshold)
else:
divergence_floor = max(_HNSW_DIVERGENCE_FALLBACK_FLOOR, 2 * sync_threshold)
threshold = max(
divergence_floor,
int(sqlite_count * _HNSW_DIVERGENCE_FRACTION),
)
out["threshold"] = threshold
stale_below_threshold = (
not has_explicit_sync_threshold
and divergence > 0
and metadata_age_seconds is not None
and metadata_age_seconds >= _HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS
)
if divergence > threshold or stale_below_threshold:
out["status"] = "diverged"
out["diverged"] = True
pct = 100.0 * divergence / max(sqlite_count, 1)
if divergence > threshold:
reason = f"exceeds threshold {threshold:,}"
else:
age = metadata_age_seconds or 0.0
reason = f"persisted below the old flush-lag floor for {age:.0f}s"
out["message"] = (
f"HNSW index holds {hnsw_count:,} elements but sqlite has "
f"{sqlite_count:,} embeddings — {divergence:,} drawers ({pct:.0f}%) "
"are invisible to vector search. Run `mempalace repair` to rebuild."
f"{sqlite_count:,} embeddings - {divergence:,} drawers "
f"({pct:.0f}%) are missing from the flushed HNSW index "
f"({reason}). Vector reads are disabled until "
"`mempalace repair` rebuilds it."
)
else:
out["status"] = "ok"
out["message"] = (
f"HNSW {hnsw_count:,} / sqlite {sqlite_count:,} (within flush-lag tolerance)"
)
if divergence < 0:
out["message"] += " (HNSW has extra flushed elements; treating as safe)"
except Exception:
logger.debug("hnsw_capacity_status failed", exc_info=True)
out["message"] = "HNSW capacity probe raised; skipping"
@@ -745,7 +860,7 @@ def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[
if not os.path.isfile(db_path):
return None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
row = conn.execute(
"""
@@ -806,7 +921,7 @@ def _sqlite_wing_room_counts(
if not os.path.isfile(db_path):
return None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
# Wait out a transient writer/checkpoint lock rather than falling
# straight back to the expensive vector-index path (#1681).
@@ -925,7 +1040,12 @@ def _missing_dimensionality_appears_recoverable(
return False
label_count = len(id_to_label)
if int(total) != label_count or len(label_to_id) != label_count:
# total_elements_added is monotonic across every add, while id_to_label and
# label_to_id hold only live elements, so a segment that has had deletions
# carries total_elements_added > label_count. Require >= (not ==), otherwise
# every post-deletion dim-None segment is wrongly quarantined (#1710); the
# label-map size and bijection checks still reject inconsistent label maps.
if int(total) < label_count or len(label_to_id) != label_count:
return False
try:
return all(label_to_id.get(label) == item_id for item_id, label in id_to_label.items())
@@ -1067,7 +1187,7 @@ def _fix_blob_seq_ids(palace_path: str) -> None:
if os.path.isfile(marker):
return
try:
with sqlite3.connect(db_path) as conn:
with contextlib.closing(sqlite3.connect(db_path)) as conn:
try:
rows = conn.execute(
"SELECT rowid, seq_id FROM embeddings WHERE typeof(seq_id) = 'blob'"
@@ -1255,8 +1375,8 @@ class ChromaCollection(BaseCollection):
@staticmethod
def _sanitize_documents_for_chromadb(documents):
"""Strip lone UTF-16 surrogates from every document before it reaches
the chromadb client.
"""Strip lone UTF-16 surrogates and embedded NUL characters from every
document before it reaches the chromadb client.
A single lone surrogate (U+D800–U+DFFF) raises ``UnicodeEncodeError``
inside chromadb's encode path and aborts the *entire* add/upsert batch
@@ -1269,18 +1389,29 @@ class ChromaCollection(BaseCollection):
directly. Sanitising here makes the chokepoint catch-all complete: the
sibling :meth:`_sanitize_metadatas_for_chromadb` already guarantees this
for metadata one method over; documents get the same guarantee.
A document containing an embedded NUL (U+0000) — routine in mined
Bash tool output — is well-formed UTF-8, unlike a lone surrogate, but
can corrupt the FTS5 inverted index for the whole collection rather
than just failing to store that one row (a ChromaDB-side bug; tracked
upstream). Stripping it here is the same defense-in-depth already
applied to surrogates: sanitize input we don't control before it
reaches a datastore we don't control.
"""
if documents is None:
return None
from ..config import strip_lone_surrogates
from ..config import strip_lone_surrogates, strip_nul_bytes
def _sanitize(d):
return strip_nul_bytes(strip_lone_surrogates(d))
# chromadb accepts OneOrMany[Document]: a bare str is a single document,
# not an iterable of characters. Handle it explicitly so we don't split
# it into per-character documents — that would be exactly the kind of
# silent corruption this method exists to prevent.
if isinstance(documents, str):
return strip_lone_surrogates(documents)
return [strip_lone_surrogates(d) if isinstance(d, str) else d for d in documents]
return _sanitize(documents)
return [_sanitize(d) if isinstance(d, str) else d for d in documents]
def add(self, *, documents, ids, metadatas=None, embeddings=None):
kwargs: dict[str, Any] = {
@@ -1564,7 +1695,7 @@ class ChromaCollection(BaseCollection):
# rowid, embedding_id is the user-facing drawer id.
public_ids: dict[int, str] = {}
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
conn.row_factory = sqlite3.Row
except sqlite3.Error:
logger.debug("Chroma lexical sqlite open failed", exc_info=True)
@@ -1724,6 +1855,46 @@ class ChromaCollection(BaseCollection):
"""
return self._collection.metadata or {}
@property
def distance_metric(self) -> str:
"""Report this collection's actual space from ``hnsw:space``.
MemPalace sets ``hnsw:space=cosine`` on every creation path, so a
healthy palace reports ``"cosine"``. When the key is absent, empty, or
an unrecognized value, the collection is genuinely using Chroma's HNSW
default — **L2** (Euclidean) — because cosine was never set on it. We
report ``"l2"`` in that case so core ranking maps the distances
correctly; reporting ``"cosine"`` here would reintroduce the
floor-every-result-to-zero misranking this property exists to fix.
"""
space = str(self.metadata.get("hnsw:space", "") or "").lower()
if space in ("cosine", "l2", "ip"):
return space
return "l2"
# ------------------------------------------------------------------
# Embedder identity (RFC 001)
#
# Stored in a small sidecar JSON in the palace dir rather than the Chroma
# collection metadata: ``collection.modify(metadata=...)`` replaces the
# whole dict and some Chroma versions reject re-passing the immutable
# ``hnsw:*`` construction keys, so mutating it on every open is fragile.
# The sidecar is keyed by collection name (a palace may hold several).
# This is complementary to Chroma's own embedding-function-name check —
# the core check runs at open time and yields the clean cross-backend
# error before Chroma's read-time rejection fires.
# ------------------------------------------------------------------
def _embedder_sidecar_path(self) -> Optional[str]:
if not self._palace_path:
return None
return os.path.join(self._palace_path, EMBEDDER_SIDECAR_FILENAME)
def get_stored_embedder_identity(self):
return read_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name())
def set_embedder_identity(self, identity) -> None:
write_embedder_sidecar(self._embedder_sidecar_path(), self._collection_name(), identity)
# ---------------------------------------------------------------------------
# Backend
@@ -1800,6 +1971,7 @@ class ChromaBackend(BaseBackend):
current_model = MempalaceConfig().embedding_model
except Exception:
current_model = "unknown"
rebuild_cmd = f"mempalace --palace {shlex.quote(palace_path)} repair rebuild-index"
return (
f"Embedding model mismatch reading palace at {palace_path!r}.\n"
f" Underlying ChromaDB error: {msg}\n"
@@ -1807,8 +1979,8 @@ class ChromaBackend(BaseBackend):
f" The palace was built with a different embedding model. Either:\n"
f" (a) revert the model: unset MEMPALACE_EMBEDDING_MODEL (or set "
f"the previous value), or\n"
f" (b) re-embed in place: `mempalace repair rebuild-index "
f"--palace {palace_path}` (writes new vectors with the current model)."
f" (b) re-embed in place: `{rebuild_cmd}` "
f"(writes new vectors with the current model)."
)
# ------------------------------------------------------------------
@@ -2067,7 +2239,26 @@ class ChromaBackend(BaseBackend):
@classmethod
def detect(cls, path: str) -> bool:
return os.path.isfile(os.path.join(path, "chroma.sqlite3"))
"""Return True when ``path`` looks like a chroma palace.
Verifies the SQLite magic header rather than file presence alone.
Bare ``sqlite3.connect()`` against a missing path leaves a 0-byte
file behind (the SQLite header is written on the first statement,
not on connection), so file-presence alone treats those artifacts
as real chroma palaces and breaks multi-backend resolution. The
16-byte ``SQLite format 3\\x00`` magic prefix is written as soon
as chromadb's ``PersistentClient`` does any work, so this check
accepts every real chroma palace while rejecting empty / garbage
files. See #1893.
"""
db_path = os.path.join(path, "chroma.sqlite3")
if not os.path.isfile(db_path):
return False
try:
with open(db_path, "rb") as f:
return f.read(16) == b"SQLite format 3\x00"
except OSError:
return False
# ------------------------------------------------------------------
# Legacy (pre-RFC 001) surface — retained while callers migrate.
+50
View File
@@ -49,6 +49,32 @@ class EmbeddingCollection(BaseCollection):
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)
@@ -129,6 +155,30 @@ class EmbeddingCollection(BaseCollection):
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:
File diff suppressed because it is too large Load Diff
+323 -25
View File
@@ -37,6 +37,8 @@ from urllib import parse as urlparse
import numpy as np
from ..config import strip_lone_surrogates
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BackendClosedError,
BackendError,
@@ -79,6 +81,42 @@ def _json_dumps(obj: Any) -> str:
return json.dumps(obj or {}, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def _strip_nul(value: Any) -> Any:
"""Recursively strip NUL (0x00) from strings, list/tuple items, and dict keys
and values so pgvector can store the result.
PostgreSQL cannot store NUL in ``text`` or ``jsonb``: psycopg rejects a raw
NUL in a text column ("PostgreSQL text fields cannot contain NUL (0x00)
bytes"), and a NUL in metadata serializes to a JSON unicode escape that the
``jsonb`` cast rejects ("unsupported Unicode escape sequence"). A single
transcript that captured NUL in tool output would otherwise abort the whole
mine run (#1829). ChromaDB and the SQLite backend store the byte verbatim,
so stripping only here keeps the same inputs ingestible.
Applied to id, document, and metadata in :meth:`_PgVectorClient.upsert_rows`
so the write path never carries a NUL into Postgres. Only ``str`` values are
rewritten; the ``int``/``float``/``bool``/``None`` scalars JSON metadata
normalizes to pass through unchanged. Stripping is not injective, so two keys
(or ids)
differing only by a NUL collapse to one (last wins); this does not occur in
practice because drawer ids are SHA-256 hashes and metadata keys are fixed
field names, so only transcript-derived values are ever actually changed.
Unlike ``config.sanitize_content`` (which rejects NUL in user-supplied
content), the bulk-mine path strips so one stray byte cannot abort a whole
backfill. ``str.replace`` returns the original string when it holds no NUL,
so a clean document is not reallocated.
"""
if isinstance(value, str):
return value.replace("\x00", "")
if isinstance(value, dict):
return {_strip_nul(key): _strip_nul(item) for key, item in value.items()}
if isinstance(value, list):
return [_strip_nul(item) for item in value]
if isinstance(value, tuple):
return tuple(_strip_nul(item) for item in value)
return value
def _tokenize(text: str) -> list[str]:
if not text:
return []
@@ -352,6 +390,30 @@ def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
# Session-level advisory-lock namespace for serializing HNSW index builds
# across daemon writers (RFC 001). classid is a fixed mempalace constant
# ("MEMP" in ASCII); objid is a stable per-table key. Both must fit a signed
# int4, which ``pg_advisory_lock(int4, int4)`` requires.
_MAINTENANCE_LOCK_CLASSID = 0x4D454D50 # "MEMP" — a positive, valid int4
def _advisory_objid(table: str) -> int:
"""Stable signed-int4 advisory key derived from the table name."""
raw = int(sha256(table.encode("utf-8")).hexdigest()[:8], 16) # 0 .. 2**32-1
return raw - 2**32 if raw >= 2**31 else raw
def _hnsw_index_name(table: str) -> str:
"""Deterministic, collision-safe index name for ``table``.
Routes through :func:`_pg_identifier`, which hashes the overflow when the
name exceeds Postgres' 63-byte limit. A naive ``[:63]`` truncation could
return the table name verbatim (tables and indexes share the ``pg_class``
namespace), which would fail with "relation already exists".
"""
return _pg_identifier(f"{table}_hnsw_idx")
def _field_sql(field: str, expression: Any, params: list) -> str:
"""Translate one field predicate to a JSONB containment expression."""
if isinstance(expression, dict):
@@ -440,11 +502,10 @@ class _PgVectorClient:
def __init__(self, config: _PgVectorConfig):
self._config = config
self._conn = None
self._closed = False
self._lock = threading.RLock()
def _connect(self):
if self._conn is not None and not getattr(self._conn, "closed", False):
return self._conn
try:
import psycopg
except ImportError as exc: # pragma: no cover - exercised only without the extra
@@ -452,15 +513,27 @@ class _PgVectorClient:
"pgvector backend requires the optional 'psycopg' dependency; "
"install mempalace[pgvector]"
) from exc
try:
self._conn = psycopg.connect(self._config.dsn)
except Exception as exc: # noqa: BLE001 - surface any driver failure uniformly
raise BackendError(f"pgvector connection failed: {exc}") from exc
return self._conn
# One client is shared across threads (PgVectorBackend caches a
# single instance per config), so the read-create-store on self._conn
# must hold the same lock _execute serializes on; unlocked, two
# first-connect threads each opened a connection and the loser leaked
# unclosed. The RLock makes the _execute -> _connect nesting safe. A
# stalled connect blocks peers under the lock the same way any
# in-flight query on this single shared connection already does.
with self._lock:
if self._closed:
raise BackendError("pgvector client has been closed")
if self._conn is not None and not getattr(self._conn, "closed", False):
return self._conn
try:
self._conn = psycopg.connect(self._config.dsn)
except Exception as exc: # noqa: BLE001 - surface any driver failure uniformly
raise BackendError(f"pgvector connection failed: {exc}") from exc
return self._conn
def _execute(self, sql: str, params=None, *, fetch: bool = False, many: bool = False):
conn = self._connect()
with self._lock:
conn = self._connect()
try:
with conn.cursor() as cur:
if many:
@@ -545,9 +618,21 @@ class _PgVectorClient:
)
params = [
(
row["id"],
row["document"],
_json_dumps(row.get("metadata")),
# Strip both unstorable byte classes Postgres rejects before
# binding, so one stray byte in a transcript cannot abort the
# whole mine (#1829 NUL, #1833 lone surrogate).
#
# Order matters for metadata: NUL must be stripped *before*
# serialization (json escapes it to \\u0000, which the jsonb cast
# rejects), while a lone surrogate must be stripped *after*
# serialization (json.dumps(ensure_ascii=False) leaves it raw, so
# one pass over the serialized string cleans it without walking
# the dict). id/document are plain strings, so the two passes
# commute there. ids are NUL- and surrogate-free in practice, so
# those passes are defensive no-ops on the ON CONFLICT key.
strip_lone_surrogates(_strip_nul(row["id"])),
strip_lone_surrogates(_strip_nul(row["document"])),
strip_lone_surrogates(_json_dumps(_strip_nul(row.get("metadata")))),
_vector_literal(row["embedding"]),
row.get("updated_at") or _utcnow(),
)
@@ -589,14 +674,34 @@ class _PgVectorClient:
*,
where: Optional[dict] = None,
with_embedding: bool = False,
with_document: bool = True,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[dict]:
qi = _quote_identifier(table)
params: list = []
where_sql = _where_to_sql(where, params) if where else "TRUE"
cols = "id, document, metadata"
# Project NULL into the document slot when the caller only needs
# metadata (e.g. mempalace_status's wing/room tally). Keeps the
# positional _row parser unchanged — document remains record[1] —
# while avoiding O(n × document_size) bytes over the wire on remote
# pgvector deployments. Follow-up to #1840.
cols = "id, document, metadata" if with_document else "id, NULL::text, metadata"
if with_embedding:
cols += ", embedding"
sql = f"SELECT {cols} FROM {qi} WHERE {where_sql}"
# Push pagination into SQL when a page is requested. ORDER BY the
# primary key gives OFFSET a stable order (an unordered scan may skip
# or repeat rows across pages); callers that scroll the whole table
# pass neither bound, leaving their SQL unchanged.
if limit is not None or offset:
sql += " ORDER BY id"
if limit is not None:
params.append(int(limit))
sql += " LIMIT %s"
if offset:
params.append(int(offset))
sql += " OFFSET %s"
rows = self._execute(sql, params, fetch=True)
return [
self._row(record, with_embedding=with_embedding, with_distance=False)
@@ -625,8 +730,46 @@ class _PgVectorClient:
def drop_table(self, table: str) -> None:
self._execute(f"DROP TABLE IF EXISTS {_quote_identifier(table)}")
# ------------------------------------------------------------------
# Maintenance (RFC 001)
# ------------------------------------------------------------------
def has_vector_index(self, table: str) -> bool:
rows = self._execute(
"SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() "
"AND tablename = %s AND indexdef ILIKE %s",
[table, "%using hnsw%"],
fetch=True,
)
return bool(rows)
def try_advisory_lock(self, classid: int, objid: int) -> bool:
rows = self._execute("SELECT pg_try_advisory_lock(%s, %s)", [classid, objid], fetch=True)
return bool(rows and rows[0] and rows[0][0])
def advisory_unlock(self, classid: int, objid: int) -> None:
self._execute("SELECT pg_advisory_unlock(%s, %s)", [classid, objid], fetch=True)
def create_hnsw_index(self, table: str) -> None:
qi = _quote_identifier(table)
idx = _quote_identifier(_hnsw_index_name(table))
# Non-concurrent build takes ACCESS EXCLUSIVE for the build duration;
# the advisory lock in the caller ensures only one session builds, so
# writes are blocked once rather than by every writer that crossed the
# threshold (the production wedge this serialization fixes).
self._execute(
f"CREATE INDEX IF NOT EXISTS {idx} ON {qi} USING hnsw (embedding vector_cosine_ops)"
)
def analyze_table(self, table: str) -> None:
self._execute(f"ANALYZE {_quote_identifier(table)}")
def close(self) -> None:
# Terminal: the only caller is PgVectorBackend.close(), after which
# the backend refuses to hand the client out again. Without the flag a
# stale reference would silently reconnect and leak a session nobody
# can ever close.
with self._lock:
self._closed = True
if self._conn is not None:
try:
self._conn.close()
@@ -686,6 +829,14 @@ class PgVectorCollection(BaseCollection):
def _marker_exists(self) -> bool:
return self._backend._marker_exists(self._palace)
def get_stored_embedder_identity(self):
return self._backend._get_embedder_identity(self._palace, self._collection_name)
def set_embedder_identity(self, identity) -> None:
# Sidecar-backed (see PgVectorBackend), so this records even on a
# brand-new palace whose mismatch marker doesn't exist yet.
self._backend._set_embedder_identity(self._palace, self._collection_name, identity)
def _ensure_table(self, dimension: int) -> None:
if dimension <= 0:
raise ValueError("embedding dimension must be positive")
@@ -710,13 +861,56 @@ class PgVectorCollection(BaseCollection):
)
self._known_dimension = existing_dim or dimension
def _scroll(self, *, where=None, with_embedding=False) -> list[dict]:
def _scroll(
self,
*,
where=None,
with_embedding=False,
with_document=True,
limit=None,
offset=None,
) -> list[dict]:
self._ensure_open()
if not self._table_exists():
if self._marker_exists():
raise CollectionNotInitializedError(self._collection_name)
return []
return self._client.scroll_rows(self._table, where=where, with_embedding=with_embedding)
return self._client.scroll_rows(
self._table,
where=where,
with_embedding=with_embedding,
with_document=with_document,
limit=limit,
offset=offset,
)
def get_all_metadata(self, where=None) -> list[dict]:
"""Single-pass metadata-only fetch — projects out the document column.
The base implementation pages through ``get(include=["metadatas"])``,
which routes here via ``_scroll`` and (pre-this-override) always sent
the ``document`` text over the wire even when nothing consumed it.
For pgvector deployments where the client is remote (TLS over WAN),
that meant ``mempalace_status`` transferred O(n × document_size)
bytes per call, dominating wall time. With ``with_document=False``
the SELECT replaces document with NULL, dropping the per-row payload
to id + metadata for every caller of this method.
Filtered fetches still need the ``_matches_where`` post-filter for
non-pushdown semantics (array/object values where ``metadata @> ...``
is broader than the exact match the caller asked for — same
correctness contract as #1840's filtered ``get`` path). Since that
post-filter only reads ``metadata``, we keep the single-scroll +
``with_document=False`` fast path and just apply the filter locally
on the metadata dicts before returning. This extends the wire-byte
win to filtered callers as well.
"""
_validate_where(where)
pushdown = None if _requires_local_filter(where) else where
rows = self._scroll(where=pushdown, with_document=False)
if where is None:
return [row["metadata"] for row in rows]
return [row["metadata"] for row in rows if _matches_where(row["metadata"], where)]
def _rows(
self,
@@ -935,16 +1129,40 @@ class PgVectorCollection(BaseCollection):
include=None,
) -> GetResult:
spec = _IncludeSpec.resolve(include, default_distances=False)
rows = self._rows(
ids=ids, where=where, where_document=where_document, with_embedding=spec.embeddings
# Fast path for the common unfiltered page fetch (e.g.
# prefetch_mined_set's sweep): push LIMIT/OFFSET into the scan instead
# of fetching the whole table and slicing in Python, which is the
# O(rows x pages) cost this avoids. Only the no-filter case is pushed:
# the "metadata @> ..." pushdown is broader than the exact
# _matches_where re-filter for array/object values, so any filtered get
# keeps the full-scan path where that re-filter still runs. ids, where,
# where_document and negative bounds all fall through to the unchanged
# path below. (The document column is still selected for metadata-only
# pages; projecting it out needs the positional _row parser to change,
# so it stays a separate follow-up.)
push_page = (
ids is None
and not where
and not where_document
and (limit is None or limit >= 0)
and (offset is None or offset >= 0)
and (limit is not None or offset)
)
if ids is not None:
by_id = {row["id"]: row for row in rows}
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
if offset:
rows = rows[offset:]
if limit is not None:
rows = rows[:limit]
if push_page:
rows = self._scroll(
where=None, with_embedding=spec.embeddings, limit=limit, offset=offset
)
else:
rows = self._rows(
ids=ids, where=where, where_document=where_document, with_embedding=spec.embeddings
)
if ids is not None:
by_id = {row["id"]: row for row in rows}
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
if offset:
rows = rows[offset:]
if limit is not None:
rows = rows[:limit]
return GetResult(
ids=[row["id"] for row in rows],
documents=[row["document"] for row in rows] if spec.documents else [],
@@ -1008,6 +1226,60 @@ class PgVectorCollection(BaseCollection):
return HealthStatus.unhealthy(str(exc))
return HealthStatus.healthy()
def maintenance_state(self) -> dict:
empty = {"row_count": 0, "vector_index": None, "index_build_complete": False}
self._ensure_open()
try:
if not self._table_exists():
return empty
rows = self._client.count_rows(self._table)
has_index = self._client.has_vector_index(self._table)
except Exception: # noqa: BLE001 - state report must not raise
logger.debug("pgvector maintenance state probe failed", exc_info=True)
return empty
return {
"row_count": rows,
"vector_index": "hnsw" if has_index else None,
"index_build_complete": has_index,
}
def run_maintenance(self, kind: str):
from .base import MaintenanceResult, UnsupportedMaintenanceKindError
if kind not in PgVectorBackend.maintenance_kinds:
raise UnsupportedMaintenanceKindError(
f"pgvector does not support maintenance kind {kind!r}"
)
self._ensure_open()
# Nothing to maintain on a not-yet-materialized table (collection opened
# create=True but never written) — return noop rather than letting a
# raw "relation does not exist" error escape.
if not self._table_exists():
return MaintenanceResult(kind=kind, status="noop", stats={"reason": "no table"})
if kind == "analyze":
self._client.analyze_table(self._table)
return MaintenanceResult(kind="analyze", status="ran")
# reindex → build the optional HNSW index. Opt-in: it makes search
# approximate, trading the exact-scan 100%-recall default for scale.
# Serialized with a session advisory lock so concurrent daemon writers
# learn "already_running" instead of each stacking an ACCESS EXCLUSIVE
# index build.
if self._client.has_vector_index(self._table):
return MaintenanceResult(kind="reindex", status="noop", stats={"vector_index": "hnsw"})
classid, objid = _MAINTENANCE_LOCK_CLASSID, _advisory_objid(self._table)
if not self._client.try_advisory_lock(classid, objid):
return MaintenanceResult(kind="reindex", status="already_running")
try:
if self._client.has_vector_index(self._table): # re-check under lock
return MaintenanceResult(
kind="reindex", status="noop", stats={"vector_index": "hnsw"}
)
self._client.create_hnsw_index(self._table)
return MaintenanceResult(kind="reindex", status="ran", stats={"vector_index": "hnsw"})
finally:
self._client.advisory_unlock(classid, objid)
class PgVectorBackend(BaseBackend):
name = "pgvector"
@@ -1020,9 +1292,15 @@ class PgVectorBackend(BaseBackend):
"supports_metadata_filters",
"supports_lexical_search",
"supports_namespace_isolation",
"supports_server_side_indexes",
"server_mode",
}
)
# "compact" is omitted: Postgres autovacuum reclaims space automatically,
# so a manual VACUUM kind would be redundant. "reindex" builds the optional
# HNSW index — an opt-in scale lever, NOT on by default, because it makes
# vector search approximate (the exact ``<=>`` scan is the 100%-recall path).
maintenance_kinds = frozenset({"analyze", "reindex"})
def __init__(self):
self._clients: dict[_PgVectorConfig, _PgVectorClient] = {}
@@ -1140,11 +1418,31 @@ class PgVectorBackend(BaseBackend):
except (OSError, NotImplementedError):
pass
# Embedder identity lives in a sidecar, NOT the backend marker: the marker's
# presence signals "palace initialized" (reads raise CollectionNotInitialized
# when the marker exists but the remote table doesn't), so recording identity
# at first empty open must not create it. The sidecar is unguarded — like the
# chroma sidecar — so a brand-new palace can record identity immediately.
@staticmethod
def _embedder_sidecar_path(palace: PalaceRef) -> Optional[str]:
if not palace.local_path:
return None
return os.path.join(palace.local_path, EMBEDDER_SIDECAR_FILENAME)
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
return read_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name)
def _set_embedder_identity(self, palace: PalaceRef, collection_name: str, identity) -> None:
write_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name, identity)
# ------------------------------------------------------------------
def _client(self, config: _PgVectorConfig) -> _PgVectorClient:
if self._closed:
raise BackendClosedError("PgVectorBackend has been closed")
with self._lock:
# Checked under the lock so a client cannot be created and stored
# concurrently with close() clearing the registry (mirrors
# SQLiteExactBackend._connect).
if self._closed:
raise BackendClosedError("PgVectorBackend has been closed")
client = self._clients.get(config)
if client is None:
client = _PgVectorClient(config)
+125 -2
View File
@@ -24,6 +24,7 @@ from urllib import request as urlrequest
import numpy as np
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BackendClosedError,
BackendMismatchError,
@@ -39,6 +40,7 @@ from .base import (
PalaceNotFoundError,
PalaceRef,
QueryResult,
UnsupportedCapabilityError,
UnsupportedFilterError,
_IncludeSpec,
)
@@ -52,6 +54,20 @@ _PAYLOAD_DOCUMENT = "document"
_PAYLOAD_METADATA = "metadata"
_POINT_NAMESPACE = uuid.UUID("c06c3fc7-5c14-4dc4-84c2-24a5f72d8dc1")
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
# Page size for Qdrant's /points/scroll cursor. 4096 (up from the original
# 256) cuts REST round-trips ~16x for any full-collection walk (#1796).
# Qdrant's own docs suggest larger scroll batches are safe, and this is well
# below typical REST payload-size limits for metadata-only (with_vector=False)
# scrolls such as get_all_metadata().
#
# This constant also governs vector-bearing scrolls (with_vector=True), used
# by _rows()/get() when embeddings are requested and by _query_local_exact()
# for the $or/$contains local-filter query fallback. At 4096 rows per page,
# high-dimensional embeddings make those particular responses tens of MB --
# Qdrant handles it and round-trips still drop overall, but this is a real
# trade-off, not a metadata-only optimization. (Noted in maintainer review
# on #1832.)
_SCROLL_PAGE_SIZE = 4096
_SUPPORTED_OPERATORS = frozenset(
{"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains", "$gt", "$gte", "$lt", "$lte"}
)
@@ -479,7 +495,7 @@ class _QdrantRESTClient:
collection: str,
*,
qdrant_filter: Optional[dict] = None,
limit: int = 256,
limit: int = _SCROLL_PAGE_SIZE,
offset: Any = None,
with_vector: bool = False,
) -> tuple[list[dict], Any]:
@@ -528,6 +544,38 @@ class _QdrantRESTClient:
result = response.get("result") or {}
return int(result.get("count") or 0)
def facet_counts(
self,
collection: str,
*,
field: str,
qdrant_filter: Optional[dict] = None,
limit: int = 1000,
) -> dict[str, int]:
body: dict[str, Any] = {
"key": field,
"exact": True,
"limit": limit,
}
if qdrant_filter:
body["filter"] = qdrant_filter
response = self.request(
"POST",
f"/collections/{urlparse.quote(collection, safe='')}/facet",
body=body,
)
result = response.get("result") or {}
hits = result.get("hits") or []
return {
str(hit["value"]): int(hit.get("count") or 0)
for hit in hits
if hit.get("value") is not None
}
def delete_collection(self, collection: str) -> None:
self.request("DELETE", f"/collections/{urlparse.quote(collection, safe='')}")
@@ -661,6 +709,14 @@ class QdrantCollection(BaseCollection):
def _marker_exists(self) -> bool:
return self._backend._marker_exists(self._palace)
def get_stored_embedder_identity(self):
return self._backend._get_embedder_identity(self._palace, self._collection_name)
def set_embedder_identity(self, identity) -> None:
# Sidecar-backed (see QdrantBackend), so this records even on a
# brand-new palace whose mismatch marker doesn't exist yet.
self._backend._set_embedder_identity(self._palace, self._collection_name, identity)
def _remote_dimension(self) -> Optional[int]:
try:
info = self._client.get_collection_info(self._remote_collection)
@@ -723,7 +779,7 @@ class QdrantCollection(BaseCollection):
points, offset = self._client.scroll_points(
self._remote_collection,
qdrant_filter=qdrant_filter,
limit=256,
limit=_SCROLL_PAGE_SIZE,
offset=offset,
with_vector=with_vector,
)
@@ -991,6 +1047,55 @@ class QdrantCollection(BaseCollection):
embeddings=[row["embedding"] or [] for row in rows] if spec.embeddings else None,
)
def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
"""Return every matching record's metadata in one cursor pass (#1796).
Overrides the default offset-paginated implementation, which would
call self.get(limit=, offset=) in a loop -- and since self.get() is
backed by a full _scroll_all() materialization, each page of that
loop would re-walk the entire collection from the start just to
discard everything outside its slice (O(n^2) over collection size).
Delegates to self._rows(), the same single-scroll-plus-local-filter
helper that backs get()/delete(). With ids=None and
where_document=None, _rows() reduces to exactly one _scroll_all()
pass followed by an unconditional _matches_where() re-check on every
row -- the same filter logic get(), delete(), and lexical_search()
already use, so this can't independently drift from those call
sites. (Maintainer review on #1832: avoid duplicating the filter
dance inline.)
"""
rows = self._rows(where=where)
return [row["metadata"] for row in rows]
def facet_counts(
self,
field: str,
where: Optional[dict] = None,
limit: int = 1000,
) -> dict[str, int]:
self._ensure_open()
# Validate the filter before the existence short-circuit so an
# unsupported local-only filter raises regardless of whether the
# collection has been materialized yet — matching the order used by
# get()/lexical_search() above (#1835 review).
_validate_where(where)
if _requires_local_filter(where):
raise UnsupportedCapabilityError("facet_counts does not support local-only filters")
if not self._remote_exists():
if self._marker_exists():
raise CollectionNotInitializedError(self._collection_name)
return {}
q_filter = _qdrant_filter(where)
return self._client.facet_counts(
self._remote_collection,
field=f"{_PAYLOAD_METADATA}.{field}",
qdrant_filter=q_filter,
limit=limit,
)
def delete(self, *, ids=None, where=None):
_validate_where(where)
if not self._remote_exists():
@@ -1081,6 +1186,7 @@ class QdrantBackend(BaseBackend):
"supports_embeddings_out",
"supports_metadata_filters",
"supports_lexical_search",
"supports_metadata_facets",
"supports_namespace_isolation",
"server_mode",
}
@@ -1175,6 +1281,23 @@ class QdrantBackend(BaseBackend):
except (OSError, NotImplementedError):
pass
# Embedder identity lives in a sidecar, NOT the backend marker: the marker's
# presence signals "palace initialized" (reads raise CollectionNotInitialized
# when the marker exists but the remote collection doesn't), so recording
# identity at first empty open must not create it. The sidecar is unguarded,
# so a brand-new palace can record identity immediately.
@staticmethod
def _embedder_sidecar_path(palace: PalaceRef) -> Optional[str]:
if not palace.local_path:
return None
return os.path.join(palace.local_path, EMBEDDER_SIDECAR_FILENAME)
def _get_embedder_identity(self, palace: PalaceRef, collection_name: str):
return read_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name)
def _set_embedder_identity(self, palace: PalaceRef, collection_name: str, identity) -> None:
write_embedder_sidecar(self._embedder_sidecar_path(palace), collection_name, identity)
def _client(self, config: _QdrantConfig) -> _QdrantRESTClient:
if self._closed:
raise BackendClosedError("QdrantBackend has been closed")
+3
View File
@@ -207,6 +207,7 @@ 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
@@ -214,6 +215,8 @@ def _register_builtins() -> None:
# 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:
+182 -28
View File
@@ -322,6 +322,36 @@ class SQLiteExactCollection(BaseCollection):
row = cur.execute("SELECT value FROM meta WHERE key = 'fts5_available'").fetchone()
return bool(row and row[0] == "1")
def _embedder_meta_key(self) -> str:
return f"embedder_model:{self._collection_name}"
def get_stored_embedder_identity(self):
from .base import EmbedderIdentity
with self._cursor() as cur:
try:
cid = self._collection_id(cur)
except CollectionNotInitializedError:
return None
row = cur.execute(
"SELECT value FROM meta WHERE key = ?",
(self._embedder_meta_key(),),
).fetchone()
if not row or not row[0]:
return None
dim = self._collection_dimension(cur, cid) or 0
return EmbedderIdentity(model_name=str(row[0]), dimension=int(dim))
def set_embedder_identity(self, identity) -> None:
if not identity or not identity.model_name:
return
with self._cursor() as cur:
cur.execute(
"INSERT INTO meta(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(self._embedder_meta_key(), str(identity.model_name)),
)
def _replace_fts(self, cur, collection_id: int, doc_id: str, document: str) -> None:
if not self._fts_available(cur):
return
@@ -466,19 +496,32 @@ class SQLiteExactCollection(BaseCollection):
)
self._replace_fts(cur, collection_id, doc_id, doc)
def _rows(self, cur, *, where=None, where_document=None) -> list[dict]:
def _rows(self, cur, *, where=None, where_document=None, limit=None, offset=None) -> list[dict]:
_validate_where(where)
_validate_where(where_document)
collection_id = self._collection_id(cur)
rows = cur.execute(
"""
SELECT id, document, metadata_json, embedding
FROM documents
WHERE collection_id = ?
ORDER BY rowid
""",
(collection_id,),
).fetchall()
sql = (
"SELECT id, document, metadata_json, embedding\n"
"FROM documents\n"
"WHERE collection_id = ?\n"
"ORDER BY rowid"
)
params = [collection_id]
# Emit SQL LIMIT/OFFSET only on an unfiltered page. With a
# where/where_document the post-filter loop below drops rows *after*
# this scan, so a SQL LIMIT/OFFSET would cut the wrong rows; those
# callers scan in full and paginate in Python. SQLite requires a LIMIT
# before OFFSET, so an offset-only page uses "LIMIT -1" (unbounded).
if where is None and where_document is None and (limit is not None or offset):
if limit is not None:
sql += "\nLIMIT ?"
params.append(int(limit))
elif offset:
sql += "\nLIMIT -1"
if offset:
sql += "\nOFFSET ?"
params.append(int(offset))
rows = cur.execute(sql, params).fetchall()
out = []
for doc_id, doc, meta_json, emb_blob in rows:
meta = _json_loads(meta_json)
@@ -573,15 +616,33 @@ class SQLiteExactCollection(BaseCollection):
include=None,
) -> GetResult:
spec = _IncludeSpec.resolve(include, default_distances=False)
# Fast path for the common unfiltered page (e.g. the prefetch_mined_set
# and status sweeps): push LIMIT/OFFSET into the scan instead of
# materializing the whole collection and slicing in Python. Safe only
# with no post-filter (ids/where/where_document drop rows after the
# scan) and non-negative bounds: SQLite does not honor a negative LIMIT
# or OFFSET the way a Python slice does, so those keep the slice path.
push_page = (
ids is None
and where is None
and where_document is None
and (limit is None or limit >= 0)
and (offset is None or offset >= 0)
and (limit is not None or offset)
)
with self._cursor() as cur:
rows = self._rows(cur, where=where, where_document=where_document)
if ids is not None:
by_id = {row["id"]: row for row in rows}
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
if offset:
rows = rows[offset:]
if limit is not None:
rows = rows[:limit]
if push_page:
rows = self._rows(cur, limit=limit, offset=offset)
else:
rows = self._rows(cur, where=where, where_document=where_document)
if not push_page:
if ids is not None:
by_id = {row["id"]: row for row in rows}
rows = [by_id[doc_id] for doc_id in ids if doc_id in by_id]
if offset:
rows = rows[offset:]
if limit is not None:
rows = rows[:limit]
return GetResult(
ids=[row["id"] for row in rows],
documents=[row["document"] for row in rows] if spec.documents else [],
@@ -710,6 +771,63 @@ class SQLiteExactCollection(BaseCollection):
return HealthStatus.unhealthy("collection closed")
return HealthStatus.healthy()
def maintenance_state(self) -> dict:
try:
rows = self.count()
except Exception:
rows = 0
# vector_index is null by design — exact cosine over every row, no ANN.
state = {"row_count": rows, "vector_index": None}
try:
with self._cursor() as cur:
page_count = cur.execute("PRAGMA page_count").fetchone()
freelist = cur.execute("PRAGMA freelist_count").fetchone()
state["page_count"] = int(page_count[0]) if page_count else 0
state["freelist_pages"] = int(freelist[0]) if freelist else 0
except Exception:
pass
return state
def run_maintenance(self, kind: str):
from .base import MaintenanceResult, UnsupportedMaintenanceKindError
if kind not in SQLiteExactBackend.maintenance_kinds:
raise UnsupportedMaintenanceKindError(
f"sqlite_exact does not support maintenance kind {kind!r}"
)
if kind == "analyze":
# Refresh planner stats. Concurrent runs serialize on the handle lock.
with self._cursor() as cur:
cur.execute("ANALYZE")
return MaintenanceResult(kind="analyze", status="ran")
# compact → VACUUM. It cannot run inside a transaction, so flip the
# connection to autocommit for the duration. The handle lock serializes
# concurrent runs in-process; SQLite's own write lock serializes across
# processes.
before = self.maintenance_state()
with self._handle.lock:
self._ensure_open()
conn = self._handle.conn
prev_isolation = conn.isolation_level
try:
conn.commit()
conn.isolation_level = None
conn.execute("VACUUM")
finally:
conn.isolation_level = prev_isolation
after = self.maintenance_state()
reclaimed = max(0, before.get("page_count", 0) - after.get("page_count", 0))
return MaintenanceResult(
kind="compact",
status="ran",
stats={
"pages_before": before.get("page_count", 0),
"pages_after": after.get("page_count", 0),
"pages_reclaimed": reclaimed,
},
)
class SQLiteExactBackend(BaseBackend):
name = "sqlite_exact"
@@ -724,6 +842,9 @@ class SQLiteExactBackend(BaseBackend):
"local_mode",
}
)
# "reindex" is intentionally omitted: sqlite_exact does exact cosine over
# every row (no ANN index to build), so it has no analogue for it.
maintenance_kinds = frozenset({"analyze", "compact"})
def __init__(self):
self._clients: dict[str, _SQLiteExactHandle] = {}
@@ -746,19 +867,31 @@ class SQLiteExactBackend(BaseBackend):
os.chmod(palace_path, 0o700)
except (OSError, NotImplementedError):
pass
# Hold the registry lock across cache-check + connect + schema init:
# two threads first-opening the same palace must not each create a
# connection (the loser leaked unclosed and outlived close()) nor run
# _init_schema concurrently on a fresh file, which surfaces transient
# "database is locked" errors before WAL mode is established. Only
# first-open pays for the I/O under the lock; cache hits are a dict
# probe.
with self._clients_lock:
if self._closed:
raise BackendClosedError("SQLiteExactBackend has been closed")
cached = self._clients.get(palace_path)
if cached is not None and not cached.closed:
return cached
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
lock = threading.RLock()
handle = _SQLiteExactHandle(conn, lock)
with handle.lock:
self._init_schema(conn)
with self._clients_lock:
conn = sqlite3.connect(db_path, check_same_thread=False)
try:
conn.row_factory = sqlite3.Row
lock = threading.RLock()
handle = _SQLiteExactHandle(conn, lock)
with handle.lock:
self._init_schema(conn)
except BaseException:
conn.close()
raise
self._clients[palace_path] = handle
return handle
return handle
def _init_schema(self, conn: sqlite3.Connection) -> None:
conn.executescript(
@@ -889,14 +1022,19 @@ class SQLiteExactBackend(BaseBackend):
cached.conn.close()
def close(self) -> None:
# Flip _closed under the registry lock so a concurrent _connect either
# sees the flag or finishes before the handle snapshot is taken; a
# connection can no longer slip into the registry after close().
# Unlocked readers of _closed elsewhere are advisory fast-fails; the
# locked recheck in _connect is the authoritative gate.
with self._clients_lock:
handles = list(self._clients.values())
self._clients.clear()
self._closed = True
for handle in handles:
with handle.lock:
handle.closed = True
handle.conn.close()
self._closed = True
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
if self._closed:
@@ -907,7 +1045,23 @@ class SQLiteExactBackend(BaseBackend):
@classmethod
def detect(cls, path: str) -> bool:
return os.path.isfile(os.path.join(path, _DB_FILENAME))
"""Return True when ``path`` looks like a sqlite_exact palace.
Verifies the SQLite magic header rather than file presence alone, for
the same reason as :py:meth:`mempalace.backends.chroma.ChromaBackend.detect`:
bare ``sqlite3.connect()`` against a missing path leaves a 0-byte file
behind because the SQLite header is written on the first statement,
not on connection. The 16-byte ``SQLite format 3\\x00`` magic prefix
accepts every real palace while rejecting empty / garbage files. See #1893.
"""
db_path = os.path.join(path, _DB_FILENAME)
if not os.path.isfile(db_path):
return False
try:
with open(db_path, "rb") as f:
return f.read(16) == b"SQLite format 3\x00"
except OSError:
return False
def create_collection(self, palace_path: str, collection_name: str) -> SQLiteExactCollection:
return self.get_collection(palace_path, collection_name, create=True)
+74
View File
@@ -0,0 +1,74 @@
"""Retention pruning for timestamped 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).
"""
import glob
import os
import shutil
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
+559 -12
View File
@@ -53,6 +53,12 @@ _PASS_ZERO_LLM_PER_SAMPLE = 2_000 # for Tier 2 LLM call only
_PASS_ZERO_LLM_MAX_SAMPLES = 20 # caps the LLM-tier sample count
_EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
# Keep parser construction lightweight for --version and hook commands.
# This mirrors miner.MAX_CHUNKS_PER_FILE without importing miner here;
# importing miner pulls in Chroma dependencies before argparse can handle
# lightweight exits such as --version.
_CLI_MAX_CHUNKS_PER_FILE_DEFAULT = 50_000
def _backend_arg(args):
"""Return a CLI-selected backend from subcommand or global flags."""
@@ -253,14 +259,16 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
if not (project_path / ".git").exists():
return False
gitignore = project_path / ".gitignore"
existing = gitignore.read_text() if gitignore.exists() else ""
# Force UTF-8: Windows defaults to GBK and chokes on non-ASCII .gitignore
# comments, killing auto-init even though the file is valid UTF-8.
existing = gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.exists() else ""
existing_lines = {line.strip() for line in existing.splitlines()}
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
if not missing:
return False
prefix = "" if not existing or existing.endswith("\n") else "\n"
block = prefix + "\n# MemPalace per-project files (issue #185)\n" + "\n".join(missing) + "\n"
with open(gitignore, "a") as f:
with open(gitignore, "a", encoding="utf-8") as f:
f.write(block)
print(f" Added {', '.join(missing)} to {gitignore.name}")
return True
@@ -307,7 +315,16 @@ def cmd_init(args):
endpoint=getattr(args, "llm_endpoint", None),
api_key=getattr(args, "llm_api_key", None),
)
ok, msg = candidate.check_available()
if (
provider_name == "openai-compat"
and getattr(candidate, "api_key_source", None) == "env"
and candidate.is_external_service
):
ok = False
msg = "external openai-compat init requires explicit --llm-api-key"
print(f" LLM skipped: {msg}")
else:
ok, msg = candidate.check_available()
if ok:
llm_provider = candidate
print(f" LLM enabled: {provider_name}/{provider_model}")
@@ -534,6 +551,27 @@ def cmd_mine(args):
for raw in args.include_ignored or []:
include_ignored.extend(part.strip() for part in raw.split(",") if part.strip())
if getattr(args, "background", False) and not getattr(args, "daemon", False):
print("mempalace: --background requires --daemon", file=sys.stderr)
sys.exit(2)
if getattr(args, "daemon", False):
payload = {
"source": args.dir,
"mode": args.mode,
"wing": args.wing,
"agent": args.agent,
"limit": args.limit,
"dry_run": args.dry_run,
"extract": args.extract,
"no_gitignore": args.no_gitignore,
"include_ignored": include_ignored,
"max_chunks_per_file": getattr(args, "max_chunks_per_file", None),
"redetect_origin": getattr(args, "redetect_origin", False),
}
_submit_daemon_cli_job("mine", payload, args, background=getattr(args, "background", False))
return
# --redetect-origin re-runs corpus_origin on the current corpus state
# and overwrites <palace>/.mempalace/origin.json before mining proceeds.
# Heuristic-only by design — full LLM detection lives on `mempalace init`.
@@ -655,14 +693,28 @@ def cmd_sweep(args):
def cmd_sync(args):
"""Prune drawers whose source files are gitignored, deleted, or moved (#1252)."""
from .mcp_server import _wal_log
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
if getattr(args, "background", False) and not getattr(args, "daemon", False):
print("mempalace: --background requires --daemon", file=sys.stderr)
sys.exit(2)
if getattr(args, "daemon", False):
payload = {
"dir": args.dir,
"root": list(args.root or []),
"wing": args.wing,
"dry_run": args.dry_run,
}
_submit_daemon_cli_job("sync", payload, args, background=getattr(args, "background", False))
return
from .palace import MineAlreadyRunning
from .wal import _wal_log
from .backends import detect_backend_for_path
from .palace import _backend_artifact_label, resolve_backend_name
from .sync import sync_palace
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
if not os.path.isdir(palace_path):
print(f"\n No palace found at {palace_path}")
return
@@ -745,6 +797,133 @@ def cmd_sync(args):
print(f"\n{'=' * 55}\n")
def _submit_daemon_cli_job(kind: str, payload: dict, args, *, background: bool) -> None:
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
backend = _backend_arg(args)
from .daemon import DaemonError, submit_job
try:
job = submit_job(
kind,
payload,
palace_path=palace_path,
backend=backend,
wait=not background,
auto_start=True,
)
except DaemonError as exc:
print(f"mempalace: daemon submission failed: {exc}", file=sys.stderr)
sys.exit(1)
if background:
print(f"Submitted daemon job {job['id']} ({kind})")
return
result = job.get("result") or {}
from .service import print_job_result
exit_code = print_job_result(result)
if job.get("state") != "succeeded" and exit_code == 0:
error = job.get("error") or {}
print(
f"mempalace: daemon job failed: {error.get('message', 'unknown error')}",
file=sys.stderr,
)
exit_code = 1
if exit_code:
sys.exit(exit_code)
def cmd_daemon(args):
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
backend = _backend_arg(args)
from .daemon import (
TERMINAL_STATES,
DaemonError,
QueueStore,
get_client_if_running,
job_to_dict,
queue_path,
start_daemon,
stop_daemon,
)
action = getattr(args, "daemon_action", None)
try:
if action == "start":
if args.foreground:
start_daemon(palace_path, backend=backend, foreground=True)
return
client = start_daemon(palace_path, backend=backend, foreground=False)
health = client.health()
print(f"MemPalace daemon running on 127.0.0.1:{client.port}")
print(f" Palace: {health.get('palace_path')}")
print(f" PID: {health.get('pid')}")
return
if action == "stop":
if stop_daemon(palace_path):
print("MemPalace daemon stopping")
else:
print("MemPalace daemon is not running")
return
if action == "status":
client = get_client_if_running(palace_path)
if client is None:
print("MemPalace daemon is not running")
sys.exit(1)
health = client.health()
print("MemPalace daemon is running")
print(f" Palace: {health.get('palace_path')}")
print(f" PID: {health.get('pid')}")
print(f" Active: {health.get('active_job_id') or '-'}")
print(f" Jobs: {health.get('counts') or {}}")
return
if action == "jobs":
client = get_client_if_running(palace_path)
if client is not None:
jobs = client.list_jobs(limit=args.limit)
else:
qpath = queue_path(palace_path)
if not qpath.exists():
jobs = []
else:
jobs = [
job_to_dict(job, include_payload=False)
for job in QueueStore(qpath).list(args.limit)
]
for job in jobs:
print(f"{job['id']} {job['state']:<9} {job['kind']:<10} {job['created_at']}")
return
if action == "wait":
client = get_client_if_running(palace_path)
if client is not None:
job = client.wait(args.job_id)
else:
qpath = queue_path(palace_path)
if not qpath.exists():
raise DaemonError("daemon is not running")
job = job_to_dict(QueueStore(qpath).get(args.job_id))
if job.get("state") not in TERMINAL_STATES:
raise DaemonError(f"daemon is not running; job {args.job_id} is {job['state']}")
result = job.get("result") or {}
from .service import print_job_result
exit_code = print_job_result(result)
if job.get("state") != "succeeded" and exit_code == 0:
print(f"mempalace: daemon job failed: {job.get('error')}", file=sys.stderr)
exit_code = 1
if exit_code:
sys.exit(exit_code)
return
except DaemonError as exc:
print(f"mempalace: daemon error: {exc}", file=sys.stderr)
sys.exit(1)
def cmd_search(args):
from .searcher import search, SearchError
@@ -824,6 +1003,29 @@ def cmd_migrate_wings(args):
)
def cmd_hallways(args):
"""List within-wing entity hallways (the auto-built associative graph)."""
from .hallways import list_hallways
palace_path = (
os.path.expanduser(args.palace)
if getattr(args, "palace", None)
else MempalaceConfig().palace_path
)
rows = list_hallways(
getattr(args, "wing", None),
config=MempalaceConfig(palace_path=palace_path),
)
if not rows:
print("No hallways yet — they are built from drawer entities when you mine.")
return
rows.sort(key=lambda h: h.get("co_occurrence_count", 0), reverse=True)
print(f" {len(rows)} hallway(s):")
for h in rows[: max(0, args.limit)]:
label = h.get("label") or f"{h.get('entity_a', '?')} <-> {h.get('entity_b', '?')}"
print(f" {label}")
def cmd_status(args):
from .miner import status
@@ -831,6 +1033,53 @@ def cmd_status(args):
status(palace_path=palace_path)
def cmd_palace_set_embedder(args):
"""Record (or force-override) a palace's embedder identity (RFC 001).
Resolves the ``unknown`` state for a legacy palace, or records a specific
model with ``--model``. It records identity on the palace only; it does not
change the configured model — when the two differ it prints how to align
``MEMPALACE_EMBEDDING_MODEL``. ``--force`` overwrites an existing,
differently-named identity.
"""
from .backends.base import EmbedderIdentityMismatchError
from .palace import set_palace_embedder_identity
config = MempalaceConfig()
palace_path = os.path.abspath(
os.path.expanduser(args.palace) if args.palace else config.palace_path
)
model = getattr(args, "model", None)
try:
old, new = set_palace_embedder_identity(
palace_path,
model=model,
force=getattr(args, "force", False),
backend=_backend_arg(args),
)
except EmbedderIdentityMismatchError as exc:
print(f" ✗ {exc}")
raise SystemExit(2) from exc
if old is None:
print(f" ✓ recorded embedder identity: {new.model_name} (dim={new.dimension})")
elif old.model_name == new.model_name:
print(f" ✓ embedder identity unchanged: {new.model_name} (dim={new.dimension})")
else:
print(
f" ✓ embedder identity changed: {old.model_name} → {new.model_name} "
f"(dim={new.dimension})"
)
# set-embedder records the palace's identity; it does not change the
# configured model. If they differ, the next normal open would mismatch —
# tell the user how to align them.
configured = config.embedding_model
if new.model_name and configured and new.model_name != configured:
print(
f" ⚠ configured model is {configured!r}; set MEMPALACE_EMBEDDING_MODEL="
f"{new.model_name} (or run onboarding) so normal opens of this palace match."
)
def cmd_repair_status(args):
"""Read-only HNSW capacity health check (#1222)."""
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
@@ -842,7 +1091,12 @@ def cmd_repair_status(args):
def cmd_repair(args):
"""Rebuild palace vector index from SQLite metadata."""
"""Rebuild palace vector index from SQLite metadata.
On success the palace SQLite file is VACUUMed and the FTS5 index is
rebuilt, so the next repair's integrity preflight reads a consistent
database (#1747).
"""
config = MempalaceConfig()
collection_name = config.collection_name
palace_path = os.path.abspath(
@@ -859,13 +1113,20 @@ def cmd_repair(args):
TruncationDetected,
_close_chroma_handles,
_extract_drawers,
_post_rebuild_cleanup,
_rebuild_collection_via_temp,
check_extraction_safety,
index_read_recovery_guidance,
maybe_autoheal_fts5_index,
maybe_repair_poisoned_max_seq_id_before_rebuild,
print_sqlite_integrity_abort,
sqlite_integrity_errors,
)
if getattr(args, "repair_action", None) == "rebuild-index":
args.mode = "from-sqlite"
args.archive_existing = True
if getattr(args, "mode", "legacy") == "max-seq-id":
from .repair import repair_max_seq_id
@@ -881,7 +1142,7 @@ def cmd_repair(args):
if getattr(args, "mode", "legacy") == "from-sqlite":
from .migrate import confirm_destructive_action
from .repair import RebuildPartialError, rebuild_from_sqlite
from .repair import RebuildCleanupError, RebuildPartialError, rebuild_from_sqlite
source_path = getattr(args, "source", None)
source_path = (
@@ -919,6 +1180,13 @@ def cmd_repair(args):
f"Failed in collection: {exc.failed_collection}"
)
sys.exit(1)
except RebuildCleanupError:
# All rows may have landed, but rebuild_from_sqlite deliberately
# withholds success until FTS5 rebuild, VACUUM, and quick_check are
# clean. Its exception already includes the retained destination
# and archive/source recovery paths.
print("\n Rebuild cleanup failed — see recovery details above.")
sys.exit(1)
# An empty counts dict is rebuild_from_sqlite's documented signal
# for a validation refusal (missing source, existing dest,
# in-place without --archive-existing). The library already
@@ -947,6 +1215,8 @@ def cmd_repair(args):
# here so we can surface the clear recovery instructions and exit
# cleanly before chromadb's compactor touches the disk.
sqlite_errors = sqlite_integrity_errors(palace_path)
if sqlite_errors:
sqlite_errors = maybe_autoheal_fts5_index(palace_path, sqlite_errors)
if sqlite_errors:
print_sqlite_integrity_abort(palace_path, sqlite_errors)
sys.exit(1)
@@ -974,7 +1244,7 @@ def cmd_repair(args):
print(f" Drawers found: {total}")
except Exception as e:
print(f" Error reading palace: {e}")
print(" Cannot recover — palace may need to be re-mined from source files.")
print(index_read_recovery_guidance())
return
if total == 0:
@@ -1052,6 +1322,10 @@ def cmd_repair(args):
print(f" Backup location: {backup_path}")
sys.exit(1)
# The bulk delete + re-upsert cycle above leaves the FTS5 inverted index
# inconsistent, which fails the next repair's integrity preflight (#1747).
_post_rebuild_cleanup(palace_path, backend=backend, progress=print)
print(f"\n Repair complete. {filed} drawers rebuilt.")
print(f" Backup saved at {backup_path}")
print(f"\n{'=' * 55}\n")
@@ -1097,6 +1371,154 @@ def cmd_mcp(args):
print(f" {base_server_cmd} --palace /path/to/palace")
_SERVER_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
_SERVER_BIND_ALL_HOSTS = {"0.0.0.0", "::", "[::]"}
def _server_is_loopback(host: str) -> bool:
return (host or "").strip().lower() in _SERVER_LOOPBACK_HOSTS
def _server_token_path(palace_path: str) -> Path:
"""Per-palace location for the auto-generated server bearer token.
Distinct from the daemon's token dir; keyed by the canonical palace path so
one server per palace reuses a stable token across restarts.
"""
import hashlib
canonical = os.path.abspath(os.path.realpath(os.path.expanduser(palace_path)))
key = hashlib.sha256(os.path.normcase(canonical).encode("utf-8")).hexdigest()[:24]
return Path.home() / ".mempalace" / "server" / key / "token"
def _load_or_create_server_token(palace_path: str) -> tuple[str, bool]:
"""Return (token, created). Reuse an existing 0600 token or mint a new one."""
import secrets
token_path = _server_token_path(palace_path)
if token_path.exists():
existing = token_path.read_text(encoding="utf-8").strip()
if existing:
return existing, False
token = secrets.token_urlsafe(32)
token_path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(str(token_path.parent), 0o700)
except OSError:
pass
# O_CREAT with 0600 so the token is never briefly world-readable on disk.
fd = os.open(str(token_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(token + "\n")
return token, True
def cmd_serve(args):
"""Run a secure remote HTTP MCP server for a team to share one palace (#1877).
A turnkey wrapper over ``mempalace-mcp --transport http``: it resolves a
bearer token (auto-generating a strong one for non-loopback binds), prints a
ready-to-paste client config, then execs the real server in the foreground so
Docker/systemd own the process lifecycle. The token is passed via the
environment, never argv, so it can't leak through ``ps``.
"""
host = args.host
port = int(args.port)
loopback = _server_is_loopback(host)
palace_path = (
os.path.abspath(os.path.expanduser(args.palace))
if args.palace
else MempalaceConfig().palace_path
)
backend = _backend_arg(args)
tls_cert = os.path.expanduser(args.tls_cert) if args.tls_cert else None
tls_key = os.path.expanduser(args.tls_key) if args.tls_key else None
if bool(tls_cert) != bool(tls_key):
print("mempalace: --tls-cert and --tls-key must be given together", file=sys.stderr)
sys.exit(2)
for label, path in (("--tls-cert", tls_cert), ("--tls-key", tls_key)):
if path and not os.path.isfile(path):
print(f"mempalace: {label} file not found: {path}", file=sys.stderr)
sys.exit(2)
scheme = "https" if tls_cert else "http"
# Token resolution. Explicit flag > existing env > (non-loopback) auto-generated.
token = (args.token or os.environ.get("MEMPALACE_MCP_HTTP_TOKEN", "")).strip()
token_created = False
if not token and not loopback and not args.allow_insecure:
token, token_created = _load_or_create_server_token(palace_path)
# Build the child environment. Token rides in the env (never argv) so it
# stays out of the process table.
env = dict(os.environ)
env["MEMPALACE_PALACE_PATH"] = palace_path
if backend:
env["MEMPALACE_BACKEND"] = str(backend).strip().lower()
if token:
env["MEMPALACE_MCP_HTTP_TOKEN"] = token
if args.allow_insecure:
env["MEMPALACE_MCP_HTTP_ALLOW_INSECURE_NO_TOKEN"] = "1"
child = [
sys.executable,
"-m",
"mempalace.mcp_server",
"--transport",
"http",
"--host",
host,
"--port",
str(port),
]
if backend:
child += ["--backend", str(backend).strip().lower()]
child += ["--palace", palace_path]
if tls_cert:
child += ["--tls-cert", tls_cert, "--tls-key", tls_key]
if args.read_only:
child.append("--read-only")
# Client-facing address: 0.0.0.0/:: means "all interfaces" — clients dial a
# real reachable host, so show a placeholder rather than the bind wildcard.
client_host = "YOUR_SERVER_HOST" if host.strip().lower() in _SERVER_BIND_ALL_HOSTS else host
url = f"{scheme}://{client_host}:{port}/mcp"
print("Starting MemPalace remote MCP server")
print(f" palace : {palace_path}")
print(f" backend : {(backend or 'default').strip().lower() if backend else 'default'}")
print(f" bind : {host}:{port} ({'loopback' if loopback else 'network-exposed'})")
print(f" tls : {'on' if tls_cert else 'off (plaintext — terminate TLS at a proxy)'}")
print(f" read-only: {'yes' if args.read_only else 'no'}")
if token_created:
print("\n A new bearer token was generated and stored 0600 at:")
print(f" {_server_token_path(palace_path)}")
print(" Store it securely — clients need it to connect:")
print(f" {token}")
print("\nConnect a client:")
if token:
print(
f" claude mcp add --transport http mempalace {url} "
f'--header "Authorization: Bearer {token if token_created else "$MEMPALACE_MCP_HTTP_TOKEN"}"'
)
else:
print(f" claude mcp add --transport http mempalace {url}")
print(f" curl {scheme}://{client_host}:{port}/healthz # liveness (no auth)\n")
sys.stdout.flush()
# Foreground: hand the process to the real server so signals (SIGTERM from
# Docker/systemd) reach it directly. exec on POSIX; subprocess on Windows
# (no exec semantics) propagating the exit code.
if os.name == "posix":
os.execve(sys.executable, child, env)
else:
import subprocess
completed = subprocess.run(child, env=env)
sys.exit(completed.returncode)
def cmd_compress(args):
"""Compress drawers in a wing using AAAK Dialect."""
from .dialect import Dialect
@@ -1423,13 +1845,22 @@ def main():
p_mine.add_argument(
"--dry-run", action="store_true", help="Show what would be filed without filing"
)
p_mine.add_argument(
"--daemon",
action="store_true",
help="Submit this mine to the opt-in local daemon queue",
)
p_mine.add_argument(
"--background",
action="store_true",
help="With --daemon, return a job id immediately instead of waiting",
)
p_mine.add_argument(
"--extract",
choices=["exchange", "general"],
default="exchange",
help="Extraction strategy for convos mode: 'exchange' (default) or 'general' (5 memory types)",
)
from . import miner as _miner_for_default
p_mine.add_argument(
"--max-chunks-per-file",
@@ -1438,7 +1869,7 @@ def main():
metavar="N",
help=(
f"Per-file chunk cap; files producing more chunks are skipped with a "
f"summary counter. Default {_miner_for_default.MAX_CHUNKS_PER_FILE} "
f"summary counter. Default {_CLI_MAX_CHUNKS_PER_FILE_DEFAULT} "
f"(or MEMPALACE_MAX_CHUNKS_PER_FILE). Set 0 to disable. Lower this on "
f"Windows if you hit ONNX bad_alloc (#1455)."
),
@@ -1486,6 +1917,16 @@ def main():
action="store_false",
help="Actually delete drawers (overrides --dry-run; requires --wing or a project root)",
)
p_sync.add_argument(
"--daemon",
action="store_true",
help="Submit this sync to the opt-in local daemon queue",
)
p_sync.add_argument(
"--background",
action="store_true",
help="With --daemon, return a job id immediately instead of waiting",
)
# search
p_search = sub.add_parser("search", help="Find anything, exact words")
@@ -1548,7 +1989,7 @@ def main():
p_hook_run.add_argument(
"--hook",
required=True,
choices=["session-start", "stop", "precompact"],
choices=["session-start", "stop", "session-end", "precompact"],
help="Hook name to run",
)
p_hook_run.add_argument(
@@ -1578,6 +2019,15 @@ def main():
p_repair.add_argument(
"--yes", action="store_true", help="Skip confirmation for destructive changes"
)
p_repair.add_argument(
"repair_action",
nargs="?",
choices=["rebuild-index"],
help=(
"Re-embed the palace from SQLite using the current embedding model "
"(alias for --mode from-sqlite --archive-existing)."
),
)
p_repair.add_argument(
"--confirm-truncation-ok",
action="store_true",
@@ -1648,6 +2098,27 @@ def main():
help="Compare sqlite vs HNSW element counts (read-only; never opens a chromadb client)",
)
# daemon
p_daemon = sub.add_parser("daemon", help="Manage the opt-in long-lived daemon")
daemon_sub = p_daemon.add_subparsers(dest="daemon_action")
p_daemon_start = daemon_sub.add_parser("start", help="Start the daemon")
p_daemon_start.add_argument(
"--foreground",
action="store_true",
help="Run in the foreground for debugging or process supervisors",
)
p_daemon_start.add_argument(
"--backend",
default=None,
help="Storage backend for this daemon (default: config/env/detected/chroma)",
)
daemon_sub.add_parser("stop", help="Stop the daemon")
daemon_sub.add_parser("status", help="Show daemon status")
p_daemon_jobs = daemon_sub.add_parser("jobs", help="List recent daemon jobs")
p_daemon_jobs.add_argument("--limit", type=int, default=20, help="Max jobs to show")
p_daemon_wait = daemon_sub.add_parser("wait", help="Wait for a daemon job")
p_daemon_wait.add_argument("job_id", help="Job id returned by --background")
# mcp
p_mcp = sub.add_parser(
"mcp",
@@ -1659,6 +2130,38 @@ def main():
help="Storage backend to include in the MCP startup command",
)
# serve — turnkey remote HTTP MCP server (#1877)
p_serve = sub.add_parser(
"serve",
help="Run a secure remote HTTP MCP server for a team to share one palace",
)
p_serve.add_argument(
"--host", default="127.0.0.1", help="Bind address (use 0.0.0.0 for remote clients)"
)
p_serve.add_argument("--port", type=int, default=8765, help="Bind port (default: 8765)")
p_serve.add_argument(
"--backend", default=None, help="Storage backend (default: config/env/detected)"
)
p_serve.add_argument("--palace", default=None, help="Palace path (overrides config/env)")
p_serve.add_argument(
"--token",
default=None,
help="Bearer token clients must present. Default: reuse/auto-generate one for "
"non-loopback binds (stored 0600 under ~/.mempalace/server/).",
)
p_serve.add_argument("--tls-cert", default=None, help="PEM certificate to enable TLS")
p_serve.add_argument("--tls-key", default=None, help="PEM private key matching --tls-cert")
p_serve.add_argument(
"--read-only",
action="store_true",
help="Expose recall only: mutating tools are hidden and refused",
)
p_serve.add_argument(
"--allow-insecure",
action="store_true",
help="Permit a non-loopback bind with no token (only behind a trusted proxy)",
)
# status
# migrate
p_migrate = sub.add_parser(
@@ -1686,6 +2189,9 @@ def main():
)
p_migrate_wings.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
p_hallways = sub.add_parser("hallways", help="List entity hallways (associative graph)")
p_hallways.add_argument("--wing", default=None, help="Filter to one wing")
p_hallways.add_argument("--limit", type=int, default=50, help="Max hallways to show")
p_status = sub.add_parser("status", help="Show what's been filed")
p_status.add_argument(
"--backend",
@@ -1693,6 +2199,31 @@ def main():
help="Storage backend to use for status (default: config/env/detected/chroma)",
)
p_palace = sub.add_parser("palace", help="Palace maintenance commands")
palace_sub = p_palace.add_subparsers(dest="palace_action")
p_set_embedder = palace_sub.add_parser(
"set-embedder",
help="Record/override the palace's embedder identity (resolve 'unknown', or switch models)",
)
p_set_embedder.add_argument(
"--model",
default=None,
help="Embedder model to record (default: current configured model). "
"Records identity on the palace only; does not change the configured "
"model (prints how to align MEMPALACE_EMBEDDING_MODEL if they differ).",
)
p_set_embedder.add_argument(
"--force",
action="store_true",
help="Overwrite an existing identity that names a different model "
"(only if you know the stored vectors are compatible)",
)
p_set_embedder.add_argument(
"--backend",
default=None,
help="Storage backend (default: config/env/detected/chroma)",
)
args = parser.parse_args()
_apply_backend_arg(args)
@@ -1717,6 +2248,20 @@ def main():
cmd_instructions(args)
return
if args.command == "palace":
if getattr(args, "palace_action", None) == "set-embedder":
cmd_palace_set_embedder(args)
else:
p_palace.print_help()
return
if args.command == "daemon":
if not getattr(args, "daemon_action", None):
p_daemon.print_help()
return
cmd_daemon(args)
return
dispatch = {
"init": cmd_init,
"mine": cmd_mine,
@@ -1725,12 +2270,14 @@ def main():
"sweep": cmd_sweep,
"sync": cmd_sync,
"mcp": cmd_mcp,
"serve": cmd_serve,
"compress": cmd_compress,
"wake-up": cmd_wakeup,
"repair": cmd_repair,
"repair-status": cmd_repair_status,
"migrate": cmd_migrate,
"migrate-wings": cmd_migrate_wings,
"hallways": cmd_hallways,
"status": cmd_status,
}
dispatch[args.command](args)
+305 -2
View File
@@ -11,6 +11,14 @@ from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from .write_routing import (
ResolvedWriteRoutingPolicy,
RoutingPolicyCandidate,
WriteRoutingError,
WriteRoutingPolicy,
resolve_write_routing_policy,
)
# ── Input validation ──────────────────────────────────────────────────────────
# Shared sanitizers for wing/room/entity names. Prevents path traversal,
# excessively long strings, and special characters that could cause issues
@@ -31,6 +39,22 @@ def strip_lone_surrogates(text: str) -> str:
return _LONE_SURROGATE_RE.sub("�", text)
# Tool output mined from real transcripts routinely embeds a NUL character
# (U+0000) — e.g. captured Bash output where a reader raced a background
# writer, or genuine binary/NUL-delimited command output. A document
# containing one is otherwise valid, well-formed text (unlike a lone
# surrogate, which is invalid UTF-8), but handing it to ChromaDB's
# SQLite/FTS5 layer can corrupt the FTS5 inverted index for the *whole*
# collection (``PRAGMA quick_check`` reports "malformed inverted index for
# FTS5 table"), not just fail to store that one document. Stripping it
# before it reaches the chromadb client is the same defense-in-depth this
# module already applies to lone surrogates (#1235) — sanitize input we
# don't control before it reaches a datastore we don't control.
def strip_nul_bytes(text: str) -> str:
"""Replace embedded NUL characters with U+FFFD before ChromaDB storage."""
return text.replace("\x00", "�")
def normalize_wing_name(name: str) -> str:
"""Lower-case + collapse separators (`-`, ` `) to `_` for wing slugs.
@@ -197,6 +221,43 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str:
DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace")
DEFAULT_COLLECTION_NAME = "mempalace_drawers"
DEFAULT_BACKEND = "chroma"
DEFAULT_MILVUS_CONSISTENCY_LEVEL = "Strong"
_MILVUS_CONSISTENCY_LEVELS = {
"strong": "Strong",
"session": "Session",
"bounded": "Bounded",
"eventually": "Eventually",
}
# How many timestamped palace backups to retain before the oldest are
# pruned. Applies to the accumulating backups written by ``mempalace
# migrate`` and ``mempalace repair max-seq-id`` — see
# ``MempalaceConfig.max_backups``.
DEFAULT_MAX_BACKUPS = 10
def normalize_milvus_consistency_level(value) -> str:
raw = str(value).strip() if value else DEFAULT_MILVUS_CONSISTENCY_LEVEL
normalized = _MILVUS_CONSISTENCY_LEVELS.get(raw.lower())
if normalized:
return normalized
allowed = ", ".join(_MILVUS_CONSISTENCY_LEVELS.values())
raise ValueError(f"milvus_consistency_level must be one of: {allowed}")
def sqlite_read_uri(db_path: str) -> str:
"""Return a read-only ``file:`` URI for ``sqlite3.connect(..., uri=True)``.
A bare ``f"file:{db_path}?mode=ro"`` mis-parses paths containing spaces or
other URI-reserved characters — common in real home directories (a Windows
user folder like ``First Last``, many macOS paths). ``pathname2url``
percent-encodes the path and normalizes separators so the database opens on
every platform.
"""
from urllib.request import pathname2url
db_path = os.fspath(db_path)
return f"file:{pathname2url(db_path)}?mode=ro"
@lru_cache(maxsize=1)
@@ -289,18 +350,26 @@ class MempalaceConfig:
Load order: env vars > config file > defaults.
"""
def __init__(self, config_dir=None):
def __init__(self, config_dir=None, palace_path=None):
"""Initialize config.
Args:
config_dir: Override config directory (useful for testing).
Defaults to ~/.mempalace.
palace_path: Explicit palace data directory. This is primarily
used by CLI operations that received ``--palace``;
it takes precedence over environment and file config.
"""
self._config_dir = (
Path(config_dir) if config_dir else Path(os.path.expanduser("~/.mempalace"))
)
self._config_file = self._config_dir / "config.json"
self._people_map_file = self._config_dir / "people_map.json"
self._palace_path_override = (
os.path.abspath(os.path.expanduser(str(palace_path)))
if palace_path is not None
else None
)
self._file_config = {}
if self._config_file.exists():
@@ -313,19 +382,33 @@ class MempalaceConfig:
@property
def palace_path(self):
"""Path to the memory palace data directory."""
if self._palace_path_override is not None:
return self._palace_path_override
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
if env_val:
# Normalize: expand ~ and collapse .. to match the CLI --palace
# code path (mcp_server.py:62) and prevent surprise redirection
# when the env var contains unresolved components.
return os.path.abspath(os.path.expanduser(env_val))
return self._file_config.get("palace_path", DEFAULT_PALACE_PATH)
return os.path.expanduser(self._file_config.get("palace_path", DEFAULT_PALACE_PATH))
@property
def tunnel_file(self):
"""Path to the tunnel file, sibling of palace_path."""
return os.path.join(os.path.dirname(self.palace_path), "tunnels.json")
@property
def hallway_file(self):
"""Path to the hallway file, sibling of palace_path.
Mirrors ``tunnel_file`` so within-wing hallway state is scoped to the
configured palace and survives palace rebuilds (it does not live in
ChromaDB which can be recreated). Prior to this property the path was
hardcoded under ``~/.mempalace/hallways.json`` and multiple palaces on
one host silently shared one file (see ``hallways._legacy_hallway_file``).
"""
return os.path.join(os.path.dirname(self.palace_path), "hallways.json")
@property
def collection_name(self):
"""ChromaDB collection name."""
@@ -388,6 +471,56 @@ class MempalaceConfig:
timeout = 10.0
return timeout if timeout > 0 else 10.0
@property
def milvus_uri(self):
"""Milvus endpoint for the opt-in ``milvus`` backend.
Defaults to ``None`` so selecting Milvus uses per-palace Milvus Lite at
``<palace>/milvus.db``. Set this only to deliberately use a shared
Milvus server, Zilliz Cloud, or a custom local Lite file.
"""
env_val = os.environ.get("MEMPALACE_MILVUS_URI")
if env_val:
return env_val.strip()
value = self._file_config.get("milvus_uri")
return str(value).strip() if value else None
@property
def milvus_token(self):
"""Token for the opt-in ``milvus`` backend, if configured."""
env_val = os.environ.get("MEMPALACE_MILVUS_TOKEN")
if env_val:
return env_val
value = self._file_config.get("milvus_token")
return str(value) if value else None
@property
def milvus_db_name(self):
"""Optional Milvus database name for the opt-in ``milvus`` backend."""
env_val = os.environ.get("MEMPALACE_MILVUS_DB_NAME")
if env_val:
return env_val.strip()
value = self._file_config.get("milvus_db_name")
return str(value).strip() if value else None
@property
def milvus_namespace(self):
"""Optional Milvus collection namespace/prefix."""
env_val = os.environ.get("MEMPALACE_MILVUS_NAMESPACE")
if env_val:
return env_val.strip()
value = self._file_config.get("milvus_namespace")
return str(value).strip() if value else None
@property
def milvus_consistency_level(self):
"""Milvus read consistency level for the opt-in ``milvus`` backend."""
env_val = os.environ.get("MEMPALACE_MILVUS_CONSISTENCY_LEVEL")
if env_val:
return normalize_milvus_consistency_level(env_val)
value = self._file_config.get("milvus_consistency_level")
return normalize_milvus_consistency_level(value)
@property
def pgvector_dsn(self):
"""Postgres DSN for the opt-in ``pgvector`` backend.
@@ -627,6 +760,37 @@ class MempalaceConfig:
return env_val.strip().lower()
return str(self._file_config.get("embedding_model", "minilm")).strip().lower()
@property
def embedding_threads(self) -> int:
"""Cap on the embedder's ONNX Runtime intra-op thread pool (#1068).
ChromaDB's ONNX embedder builds its ``InferenceSession`` with no thread
cap, so the intra-op pool defaults to the physical core count and a
background ``mine`` pins every core — stacked Stop-hook fires turn into
thermal events. ``OMP_NUM_THREADS`` is inert here (ORT owns its own
pool), so the cap is applied via ``SessionOptions`` in
:mod:`mempalace.embedding`.
Read from env ``MEMPALACE_EMBEDDING_THREADS`` first, then
``embedding_threads`` in ``config.json``. Semantics:
- unset / ``"auto"`` → half the logical CPUs (min 1), so a background
mine leaves the machine usable out of the box.
- a positive integer → exactly that many intra-op threads.
- ``0`` or negative → uncapped: ORT's default (physical core count),
for users who want maximum indexing throughput.
"""
raw = os.environ.get("MEMPALACE_EMBEDDING_THREADS")
if raw is None:
raw = self._file_config.get("embedding_threads")
if raw is None or str(raw).strip().lower() in ("", "auto"):
return max(1, (os.cpu_count() or 2) // 2)
try:
val = int(str(raw).strip())
except (TypeError, ValueError):
return max(1, (os.cpu_count() or 2) // 2)
return val if val > 0 else 0
def set_embedding_model(self, model: str) -> None:
"""Persist the embedding-model choice to ``config.json``.
@@ -691,6 +855,36 @@ class MempalaceConfig:
parsed = 1
return max(1, parsed)
@property
def max_backups(self) -> int:
"""Number of timestamped palace backups to retain before pruning.
Applies to the accumulating, timestamped backups created by
``mempalace migrate`` (``<palace>.pre-migrate.<timestamp>``) and
``mempalace repair max-seq-id``
(``chroma.sqlite3.max-seq-id-backup-<timestamp>``). Each of those
commands writes a fresh full-size copy every run and historically
never deleted the old ones, so on a machine that mines or repairs on
a schedule the backup set could silently grow until it filled the
disk. After each backup is written, copies beyond this count (oldest
first) are removed.
Reads ``MEMPALACE_MAX_BACKUPS`` env first, then ``max_backups`` in
``config.json``, then the default of ``10``. A value of ``0`` disables
pruning and keeps every backup (use when an external retention policy
manages cleanup). Negative or non-numeric values fall back to the
default rather than crashing migrate/repair.
"""
env_val = os.environ.get("MEMPALACE_MAX_BACKUPS")
if env_val is not None:
coerced = self._try_coerce_int(env_val, minimum=0)
if coerced is not None:
return coerced
coerced = self._try_coerce_int(
self._file_config.get("max_backups", DEFAULT_MAX_BACKUPS), minimum=0
)
return DEFAULT_MAX_BACKUPS if coerced is None else coerced
@property
def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
@@ -701,6 +895,115 @@ class MempalaceConfig:
"""Whether the stop hook shows a desktop notification via notify-send."""
return self._file_config.get("hooks", {}).get("desktop_toast", False)
def resolve_write_routing(self, scope: str) -> ResolvedWriteRoutingPolicy:
"""Resolve the configured write policy for ``hooks`` or ``cli``.
Precedence is:
1. scope-specific environment variable;
2. global environment variable;
3. legacy hook environment variable;
4. scope-specific config value;
5. global config value;
6. legacy hook config value;
7. ``direct``.
This foundation does not change current hook or CLI behavior. The
policy-aware consumers are introduced by follow-up PRs.
"""
normalized_scope = str(scope).strip().lower()
env_names = {
"hooks": "MEMPALACE_HOOK_WRITE_ROUTING",
"cli": "MEMPALACE_CLI_WRITE_ROUTING",
}
if normalized_scope not in env_names:
raise WriteRoutingError("write routing scope must be 'hooks' or 'cli'")
routing_config = self._file_config.get("write_routing", {})
if routing_config is None:
routing_config = {}
if not isinstance(routing_config, dict):
raise WriteRoutingError("config write_routing must be an object")
candidates = [
RoutingPolicyCandidate(
env_names[normalized_scope],
os.environ.get(env_names[normalized_scope]),
),
RoutingPolicyCandidate(
"MEMPALACE_WRITE_ROUTING",
os.environ.get("MEMPALACE_WRITE_ROUTING"),
),
]
if normalized_scope == "hooks":
candidates.append(
RoutingPolicyCandidate(
"MEMPALACE_HOOKS_DAEMON (legacy)",
os.environ.get("MEMPALACE_HOOKS_DAEMON"),
legacy_boolean=True,
)
)
candidates.extend(
[
RoutingPolicyCandidate(
f"config write_routing.{normalized_scope}",
routing_config.get(normalized_scope),
),
RoutingPolicyCandidate(
"config write_routing.default",
routing_config.get("default"),
),
]
)
if normalized_scope == "hooks":
hooks_config = self._file_config.get("hooks", {})
if hooks_config is None:
hooks_config = {}
if not isinstance(hooks_config, dict):
raise WriteRoutingError("config hooks must be an object")
candidates.append(
RoutingPolicyCandidate(
"config hooks.daemon (legacy)",
hooks_config.get("daemon"),
legacy_boolean=True,
)
)
return resolve_write_routing_policy(candidates)
@property
def hook_write_routing(self) -> WriteRoutingPolicy:
"""Resolved future routing policy for hook-triggered writes."""
return self.resolve_write_routing("hooks").policy
@property
def cli_write_routing(self) -> WriteRoutingPolicy:
"""Resolved future routing policy for routine CLI writes."""
return self.resolve_write_routing("cli").policy
@property
def hook_use_daemon(self):
"""Whether hooks should submit save/mine work to the opt-in daemon."""
env_val = os.environ.get("MEMPALACE_HOOKS_DAEMON")
if env_val is not None:
return env_val.lower() in ("true", "1", "yes", "on")
value = self._file_config.get("hooks", {}).get("daemon", False)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() in ("true", "1", "yes", "on")
return value == 1
def set_hook_setting(self, key: str, value: bool):
"""Update a hook setting and write config to disk."""
if "hooks" not in self._file_config:
+240 -54
View File
@@ -10,7 +10,9 @@ Same palace as project mining. Different ingest strategy.
import os
import sys
import json
import logging
import stat
from pathlib import Path
from datetime import datetime
from collections import defaultdict
@@ -19,6 +21,7 @@ from typing import Optional
from .collision_scan import assert_no_collisions
from .ids import ID_RECIPE, make_convo_drawer_id, make_convo_sentinel_id
from .normalize import normalize
from .entities import entities_metadata
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
@@ -62,6 +65,16 @@ CONVO_EXTENSIONS = {
".jsonl",
}
# Directories inside conversation sources that never hold conversations.
# ``tool-results``: Claude Code pages large tool outputs to
# ``<session>/tool-results/*.txt`` inside ``~/.claude/projects/<slug>/``.
# They are raw machine dumps referenced from the transcript JSONL — mining
# them stores megabytes of command output as "memories" (field measurement:
# 12.8k drawers from tool-results files on one palace; a single file
# produced 3.6k). Extends the generic SKIP_DIRS set for the convo scanner
# only — project mining semantics are unchanged.
CONVO_SKIP_DIRS = SKIP_DIRS | {"tool-results"}
MIN_CHUNK_SIZE = 30
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
_LINE_GROUP_SIZE = 25 # lines per fallback group when no paragraph breaks
@@ -77,30 +90,67 @@ MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
# use also scales with source size.
def _path_within_root(path: Path, root: Path) -> bool:
try:
path.expanduser().resolve().relative_to(root.expanduser().resolve())
return True
except (OSError, ValueError):
return False
def _is_regular_source_file(filepath: Path, root: Path) -> bool:
if not _path_within_root(filepath, root):
return False
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
fd = -1
try:
fd = os.open(filepath, flags)
st = os.fstat(fd)
return stat.S_ISREG(st.st_mode) and st.st_size <= MAX_FILE_SIZE
except OSError:
return False
finally:
if fd != -1:
try:
os.close(fd)
except OSError:
pass
def _register_file(collection, source_file: str, wing: str, agent: str, extract_mode: str):
"""Write a sentinel so file_already_mined() returns True for 0-chunk files.
Without this, files that normalize to nothing or produce zero chunks are
re-read and re-processed on every mine run because nothing was written to
ChromaDB on the first pass.
Stamps source_mtime like every real drawer does, so a file that later
grows past the min-chunk-size floor (e.g. a short session that gets
extended) is correctly detected as changed on the next mine instead of
being skipped forever by this sentinel.
"""
try:
source_mtime = os.path.getmtime(source_file)
except OSError:
source_mtime = None
sentinel_id = make_convo_sentinel_id(source_file, extract_mode)
meta = {
"wing": wing,
"room": "_registry",
"source_file": source_file,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "registry",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
}
if source_mtime is not None:
meta["source_mtime"] = source_mtime
collection.upsert(
documents=[f"[registry] {source_file}"],
ids=[sentinel_id],
metadatas=[
{
"wing": wing,
"room": "_registry",
"source_file": source_file,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "registry",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
}
],
metadatas=[meta],
)
@@ -355,7 +405,7 @@ def scan_convos(convo_dir: str) -> list:
convo_path = Path(convo_dir).expanduser().resolve()
files = []
for root, dirs, filenames in os.walk(convo_path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
dirs[:] = [d for d in dirs if d not in CONVO_SKIP_DIRS]
for filename in filenames:
if filename.endswith(".meta.json"):
continue
@@ -369,10 +419,30 @@ def scan_convos(convo_dir: str) -> list:
except OSError:
pass
continue
# Skip files exceeding size limit, or those whose stat() raises
# (permission denied, racing delete, broken symlink that
# survived the earlier is_symlink check). Both branches log
# to stderr to match the SKIP: (symlink) line above; silent
# drops at this gate were the original #923 complaint.
try:
if filepath.stat().st_size > MAX_FILE_SIZE:
file_size = filepath.stat().st_size
if file_size > MAX_FILE_SIZE:
print(
f" SKIP: {filepath.name} ({file_size / (1024 * 1024):.1f} MB)"
f" exceeds {MAX_FILE_SIZE // (1024 * 1024)} MB limit",
file=sys.stderr,
)
continue
except OSError:
except OSError as exc:
# Prefer ``exc.strerror`` so the path isn't duplicated in
# the output (see the matching comment in
# ``miner.scan_project``).
print(
f" SKIP: {filepath.name} (stat error: {exc.strerror or exc})",
file=sys.stderr,
)
continue
if not _is_regular_source_file(filepath, convo_path):
continue
files.append(filepath)
return files
@@ -383,12 +453,51 @@ def scan_convos(convo_dir: str) -> list:
# =============================================================================
def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extract_mode):
def _extract_authored_at(filepath):
"""Most-recent message timestamp in a transcript, used as the drawer's authored date.
Both Claude Code and Codex JSONL transcripts carry a top-level ISO-8601
``timestamp`` on each line. We take the max so ``authored_at`` reflects when the
content was actually written, independent of when it was mined (``filed_at``).
This restores chronology: a session from days ago keeps its real date even when
re-mined today, instead of every drawer collapsing to ingest time. Returns None
for formats without per-line timestamps (e.g. plain ``.md``).
"""
path = Path(filepath)
if path.suffix != ".jsonl":
return None
latest = None
try:
with path.open(encoding="utf-8", errors="ignore") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
ts = json.loads(line).get("timestamp")
except (ValueError, TypeError, AttributeError):
continue
# ISO-8601 timestamps are strings; guard against a non-string
# ``timestamp`` so a malformed line can't raise TypeError on compare.
if isinstance(ts, str) and (latest is None or ts > latest):
latest = ts
except OSError:
return None
return latest
def _file_chunks_locked(
collection, source_file, chunks, wing, room, agent, extract_mode, authored_at=None
):
"""Lock the source file, purge stale drawers, and upsert fresh chunks.
Combines the per-file serialization that prevents concurrent agents from
duplicating work (via mine_lock) with the normalize-version rebuild
contract (purge-before-insert so pre-v2 drawers don't survive).
duplicating work (via mine_lock) with the rebuild contract
(purge-before-insert so stale drawers never survive) that fires on
either a normalize-version bump OR a changed/grown source file (mtime
differs from what's stored) -- transcripts are not assumed immutable,
since a Claude Code session keeps appending to its own file while
active and /compact or /clear can rewrite one in place.
Returns (drawers_added, room_counts_delta, skipped).
"""
@@ -396,14 +505,15 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
drawers_added = 0
with mine_lock(source_file):
# Re-check after lock — another agent may have just finished this file
# at the current schema. A stale-version hit here returns False, so we
# at the current schema/mtime. A stale hit here returns False, so we
# still fall through to the purge+rebuild path below.
if file_already_mined(collection, source_file, extract_mode=extract_mode):
if file_already_mined(collection, source_file, check_mtime=True, extract_mode=extract_mode):
return 0, room_counts_delta, True
# Purge stale drawers first. When the normalize schema bumps,
# file_already_mined() returned False for pre-v2 drawers — clean
# them out so the source doesn't end up with mixed old/new drawers.
# Purge stale drawers first. Fires both on a normalize-schema bump
# (file_already_mined() returned False for pre-v2 drawers) and on a
# changed/grown transcript (mtime differs) — clean them out so the
# source doesn't end up with mixed old/new drawers.
try:
delete_ids = _source_file_delete_ids(collection, source_file, extract_mode)
if delete_ids:
@@ -416,6 +526,10 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
# one filed_at per source file so all transcript drawers share an
# ingest timestamp.
filed_at = datetime.now().isoformat()
try:
source_mtime = os.path.getmtime(source_file)
except OSError:
source_mtime = None
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
batch_docs: list = []
batch_ids: list = []
@@ -429,21 +543,24 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
)
batch_docs.append(chunk["content"])
batch_ids.append(drawer_id)
batch_metas.append(
{
"wing": wing,
"room": chunk_room,
"hall": _detect_hall_cached(chunk["content"]),
"source_file": source_file,
"chunk_index": chunk["chunk_index"],
"added_by": agent,
"filed_at": filed_at,
"ingest_mode": "convos",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
}
)
meta = {
"wing": wing,
"room": chunk_room,
"hall": _detect_hall_cached(chunk["content"]),
"source_file": source_file,
"chunk_index": chunk["chunk_index"],
"added_by": agent,
"filed_at": filed_at,
"entities": entities_metadata(chunk["content"]),
"authored_at": authored_at if authored_at is not None else filed_at,
"ingest_mode": "convos",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
}
if source_mtime is not None:
meta["source_mtime"] = source_mtime
batch_metas.append(meta)
assert_no_collisions(list(zip(batch_ids, batch_metas)), collection)
try:
collection.upsert(
@@ -487,6 +604,28 @@ def _is_ai_tool_path(path: Path) -> bool:
return False
def _is_unchanged_since_last_mine(source_file: str, mined_mtimes: dict) -> bool:
"""True iff source_file was mined at the current schema AND its on-disk
mtime still matches what was stored -- the mtime-aware replacement for
"we've seen this source_file before" (transcripts are not immutable).
False (re-mine) whenever the file isn't in mined_mtimes at all, its
stored mtime is None (never recorded -- pre-mtime-tracking drawer, or
getmtime failed when it was written), or getmtime fails right now
(treat as changed rather than silently trusting stale data).
"""
if source_file not in mined_mtimes:
return False
stored_mtime = mined_mtimes[source_file]
if stored_mtime is None:
return False
try:
current_mtime = os.path.getmtime(source_file)
except OSError:
return False
return abs(stored_mtime - current_mtime) < 0.001
def _resolve_wing(convo_path: Path, wing: Optional[str]) -> str:
"""Determine the destination wing for ``mine_convos``.
@@ -566,6 +705,22 @@ def mine_convos(
)
def _compute_hallways_for_wing_safe(wing, collection, drawers_filed, config=None):
"""Auto-populate the associative graph from the entities just mined.
Best-effort: hallway computation must never fail an otherwise-good mine, and is
skipped when nothing new was filed.
"""
if drawers_filed <= 0:
return
try:
from .hallways import compute_hallways_for_wing
compute_hallways_for_wing(wing, col=collection, config=config)
except Exception as exc:
print(f" (hallways skipped: {exc})")
def _mine_convos_impl(
convo_dir: str,
palace_path: str,
@@ -577,7 +732,7 @@ def _mine_convos_impl(
):
from .config import MempalaceConfig
palace_config = MempalaceConfig()
palace_config = MempalaceConfig(palace_path=palace_path)
cfg_chunk_size = palace_config.chunk_size
# Only override convo_miner's MIN_CHUNK_SIZE when the user has set
# min_chunk_size explicitly. min_chunk_size_explicit returns the
@@ -594,15 +749,14 @@ def _mine_convos_impl(
wing = _resolve_wing(convo_path, wing)
files = scan_convos(convo_dir)
if limit > 0:
files = files[:limit]
print(f"\n{'=' * 55}")
print(" MemPalace Mine — Conversations")
print(f"{'=' * 55}")
print(f" Wing: {wing}")
print(f" Source: {convo_path}")
print(f" Files: {len(files)}")
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
print(f" Files: {len(files)}{limit_suffix}")
print(f" Palace: {palace_path}")
if dry_run:
print(" DRY RUN — nothing will be filed")
@@ -610,24 +764,39 @@ def _mine_convos_impl(
collection = get_collection(palace_path) if not dry_run else None
# Bulk pre-fetch already-mined set in one paginated pass instead of
# `len(files)` separate WHERE-source_file queries. On a 150k-drawer
# palace each per-file query costs ~2s, so a 2000-file sweep used to
# spend >1h just deciding to skip. prefetch_mined_set() does the same
# decisions in a single scan; loop body becomes an O(1) set check.
mined_set: set[str] = (
prefetch_mined_set(collection, extract_mode=extract_mode) if not dry_run else set()
# Bulk pre-fetch already-mined source_file -> stored mtime in one
# paginated pass instead of `len(files)` separate WHERE-source_file
# queries. On a 150k-drawer palace each per-file query costs ~2s, so a
# 2000-file sweep used to spend >1h just deciding to skip.
# prefetch_mined_set() does the same decisions in a single scan; loop
# body becomes an O(1) dict lookup + a cheap local mtime comparison.
mined_mtimes: dict = (
prefetch_mined_set(collection, extract_mode=extract_mode) if not dry_run else {}
)
total_drawers = 0
files_mined = 0
files_skipped = 0
files_processed = 0
room_counts = defaultdict(int)
for i, filepath in enumerate(files, 1):
files_processed = i
source_file = str(filepath)
# Skip if already filed at current NORMALIZE_VERSION
if not dry_run and source_file in mined_set:
# Skip only if already filed at the current NORMALIZE_VERSION AND
# unchanged on disk since. Transcripts are NOT assumed immutable:
# a Claude Code session keeps appending to the same file while
# active, and /compact or /clear can rewrite one in place -- so
# "we've seen this source_file before" alone is not sufficient.
# Falling through re-mines: _file_chunks_locked purges this
# source_file's stale drawers before inserting fresh ones, so this
# never leaves duplicates behind.
if not dry_run and _is_unchanged_since_last_mine(source_file, mined_mtimes):
files_skipped += 1
continue
if not _is_regular_source_file(filepath, Path(convo_dir).expanduser().resolve()):
files_skipped += 1
continue
@@ -684,6 +853,9 @@ def _mine_convos_impl(
room_counts[c.get("memory_type", "general")] += 1
else:
room_counts[room] += 1
files_mined += 1
if limit > 0 and files_mined >= limit:
break
continue
if extract_mode != "general":
@@ -692,7 +864,14 @@ def _mine_convos_impl(
# Lock + purge stale + file fresh chunks. Lock serializes concurrent
# agents; purge removes pre-v2 drawers so the schema bump applies.
drawers_added, room_delta, skipped = _file_chunks_locked(
collection, source_file, chunks, wing, room, agent, extract_mode
collection,
source_file,
chunks,
wing,
room,
agent,
extract_mode,
authored_at=_extract_authored_at(filepath),
)
if skipped:
files_skipped += 1
@@ -701,14 +880,21 @@ def _mine_convos_impl(
room_counts[r] += n
total_drawers += drawers_added
files_mined += 1
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
if limit > 0 and files_mined >= limit:
break
if not dry_run:
# Compute hallways before the FTS5 validation: the latter opens a direct sqlite
# connection to the Chroma DB, which can invalidate the live collection handle on
# some Chroma builds and make the hallway fetch fail.
_compute_hallways_for_wing_safe(wing, collection, total_drawers, config=palace_config)
_validate_palace_fts5_after_mine(palace_path)
print(f"\n{'=' * 55}")
print(" Done.")
print(f" Files processed: {len(files) - files_skipped}")
print(f" Files processed: {files_processed - files_skipped}")
print(f" Files skipped (already filed): {files_skipped}")
print(f" Drawers filed: {total_drawers}")
if room_counts:
+1164
View File
File diff suppressed because it is too large Load Diff
+244 -58
View File
@@ -32,6 +32,8 @@ rather than hard-failing — mining must still work on a laptop without CUDA.
from __future__ import annotations
import logging
import os
import threading
from typing import Optional
logger = logging.getLogger(__name__)
@@ -56,6 +58,10 @@ _AUTO_ORDER = [
]
_EF_CACHE: dict = {}
# Check-then-construct on the cache must be atomic: without it, two threads
# resolving the same key each keep their own EF instance, and each instance
# later lazy-loads its own copy of the model.
_EF_CACHE_LOCK = threading.Lock()
_WARNED: set = set()
@@ -107,6 +113,35 @@ def _resolve_providers(device: str) -> tuple[list, str]:
return (requested, device)
def _intra_op_session_options(intra_op_num_threads: int):
"""Build ORT ``SessionOptions`` capping the intra-op thread pool (#1068).
Returns ``None`` when ``intra_op_num_threads <= 0`` so the caller leaves
ORT at its default (≈ physical core count). ChromaDB's embedder ignores
``OMP_NUM_THREADS`` — ORT owns its own intra-op pool, settable only via
``SessionOptions`` at session construction — so a cap has to be threaded
through here rather than via the environment.
"""
if not intra_op_num_threads or intra_op_num_threads <= 0:
return None
import onnxruntime as ort
so = ort.SessionOptions()
so.intra_op_num_threads = intra_op_num_threads
return so
def _resolve_intra_op_threads() -> int:
"""Read the configured ORT intra-op thread cap (``0`` = uncapped, #1068)."""
try:
from .config import MempalaceConfig
return MempalaceConfig().embedding_threads
except Exception:
logger.debug("embedding_threads resolution failed; leaving ORT default", exc_info=True)
return 0
def _build_ef_class():
"""Subclass ``ONNXMiniLM_L6_V2`` with name ``"default"``.
@@ -117,13 +152,51 @@ def _build_ef_class():
palaces created with ``DefaultEmbeddingFunction`` *and* palaces we
create ourselves, with the same GPU-capable ``preferred_providers``.
"""
from functools import cached_property
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
class _MempalaceONNX(ONNXMiniLM_L6_V2):
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
super().__init__(preferred_providers=preferred_providers)
self._intra_op_num_threads = intra_op_num_threads
@staticmethod
def name() -> str:
return "default"
@cached_property
def model(self):
# Upstream builds the InferenceSession with no intra-op thread cap,
# so ORT defaults its pool to the physical core count and a
# background mine pins every core (#1068). Rebuild the session the
# same way upstream does (same SessionOptions, same CoreML pruning,
# same model path) but with our cap applied. If upstream's
# internals shift, fall back to its uncapped build so embedding
# still works.
cap = getattr(self, "_intra_op_num_threads", 0)
if not cap or cap <= 0:
return super().model
try:
ort = self.ort
providers = self._preferred_providers or ort.get_available_providers()
providers = [p for p in providers if p != "CoreMLExecutionProvider"]
so = ort.SessionOptions()
so.log_severity_level = 3
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.intra_op_num_threads = cap
return ort.InferenceSession(
os.path.join(self.DOWNLOAD_PATH, self.EXTRACTED_FOLDER_NAME, "model.onnx"),
providers=providers,
sess_options=so,
)
except Exception:
logger.warning(
"thread-capped ORT session build failed; using ORT defaults",
exc_info=True,
)
return super().model
return _MempalaceONNX
@@ -135,6 +208,16 @@ _EMBEDDINGGEMMA_ONNX = "model_quantized.onnx"
_EMBEDDINGGEMMA_PREFIX = "task: sentence similarity | query: "
_EMBEDDINGGEMMA_DIM = 384 # Matryoshka truncation — first 384 dims of the 768
_EMBEDDINGGEMMA_MAX_LEN = 2048
# Default docs per session.run. The ONNX graph has no internal batching,
# so one unchunked run over a repair-scale batch (5000 docs, repair.py/
# cli.py) allocates attention buffers that grow with batch size and
# superlinearly with padded length (score tensors are batch x heads x
# len^2 per layer), and the kernel OOM-kills the process (#1770). 32
# matches the internal batch size of chromadb's ONNXMiniLM_L6_V2, whose
# chunked _forward survives the same call sites. embeddinggemma's
# sentence_embedding output is attention-masked, so sub-batch padding
# does not change any row's vector.
_EMBEDDINGGEMMA_BATCH_SIZE = 32
class EmbeddinggemmaONNX:
@@ -158,73 +241,115 @@ class EmbeddinggemmaONNX:
# when switching models. Keep it stable.
return "embeddinggemma_300m"
def __init__(self, preferred_providers=None):
def __init__(
self,
preferred_providers=None,
batch_size: int = _EMBEDDINGGEMMA_BATCH_SIZE,
intra_op_num_threads: int = 0,
):
if batch_size < 1:
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
self._providers = (
list(preferred_providers) if preferred_providers else ["CPUExecutionProvider"]
)
self._batch_size = batch_size
self._intra_op_num_threads = intra_op_num_threads
self._session = None
self._tokenizer = None
self._np = None
self._output_idx = None
# Instances are shared across threads via _EF_CACHE; serialize the
# one-time model load so concurrent cold calls cannot build (and
# transiently hold) two full model sessions.
self._load_lock = threading.Lock()
def _lazy_load(self) -> None:
if self._session is not None:
return
try:
import numpy as np
import onnxruntime as ort
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer
except ImportError as e:
raise ImportError(
"EmbeddinggemmaONNX requires huggingface_hub, tokenizers, and "
"numpy — these ship with mempalace core, so this error usually "
"means one was uninstalled or pinned to an incompatible version. "
"Reinstall with: pip install --upgrade --force-reinstall mempalace"
) from e
with self._load_lock:
if self._session is not None:
return
try:
import numpy as np
import onnxruntime as ort
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer
except ImportError as e:
raise ImportError(
"EmbeddinggemmaONNX requires huggingface_hub, tokenizers, and "
"numpy — these ship with mempalace core, so this error usually "
"means one was uninstalled or pinned to an incompatible version. "
"Reinstall with: pip install --upgrade --force-reinstall mempalace"
) from e
logger.info(
"Downloading %s/%s (cached after first run)…",
_EMBEDDINGGEMMA_REPO,
_EMBEDDINGGEMMA_ONNX,
)
model_path = hf_hub_download(
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX
)
hf_hub_download(
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX + "_data"
)
tok_path = hf_hub_download(_EMBEDDINGGEMMA_REPO, filename="tokenizer.json")
logger.info(
"Downloading %s/%s (cached after first run)…",
_EMBEDDINGGEMMA_REPO,
_EMBEDDINGGEMMA_ONNX,
)
model_path = hf_hub_download(
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX
)
hf_hub_download(
_EMBEDDINGGEMMA_REPO, subfolder="onnx", filename=_EMBEDDINGGEMMA_ONNX + "_data"
)
tok_path = hf_hub_download(_EMBEDDINGGEMMA_REPO, filename="tokenizer.json")
self._session = ort.InferenceSession(model_path, providers=self._providers)
out_names = [o.name for o in self._session.get_outputs()]
# Model card: sentence_embedding is the pooled output (last_hidden_state
# is the per-token output we don't want).
self._output_idx = (
out_names.index("sentence_embedding") if "sentence_embedding" in out_names else 1
)
session = ort.InferenceSession(
model_path,
sess_options=_intra_op_session_options(self._intra_op_num_threads),
providers=self._providers,
)
out_names = [o.name for o in session.get_outputs()]
# Model card: sentence_embedding is the pooled output (last_hidden_state
# is the per-token output we don't want).
output_idx = (
out_names.index("sentence_embedding") if "sentence_embedding" in out_names else 1
)
tokenizer = Tokenizer.from_file(tok_path)
tokenizer.enable_padding()
tokenizer.enable_truncation(max_length=_EMBEDDINGGEMMA_MAX_LEN)
self._tokenizer = tokenizer
self._np = np
tokenizer = Tokenizer.from_file(tok_path)
tokenizer.enable_padding()
tokenizer.enable_truncation(max_length=_EMBEDDINGGEMMA_MAX_LEN)
self._output_idx = output_idx
self._tokenizer = tokenizer
self._np = np
# Session is assigned last: the unlocked fast path above treats a
# non-None session as "fully loaded", so every other attribute
# must already be in place when it becomes visible.
self._session = session
def __call__(self, input): # noqa: A002 — ChromaDB EF protocol uses `input`
def __call__(self, input: str | list[str] | None) -> list[list[float]]: # noqa: A002 — ChromaDB EF protocol
if isinstance(input, str):
# A bare string would be iterated character by character below,
# silently producing one garbage vector per character.
input = [input]
if input is None or len(input) == 0:
# None or zero docs: nothing to embed; skip the lazy model
# download. len() over truthiness so an array-like documents
# sequence is not rejected by ambiguous-truth-value semantics.
return []
self._lazy_load()
np = self._np
texts = [_EMBEDDINGGEMMA_PREFIX + t for t in input]
encs = self._tokenizer.encode_batch(texts)
input_ids = np.asarray([e.ids for e in encs], dtype=np.int64)
attention_mask = np.asarray([e.attention_mask for e in encs], dtype=np.int64)
outputs = self._session.run(
None, {"input_ids": input_ids, "attention_mask": attention_mask}
)
sent_emb = outputs[self._output_idx][:, :_EMBEDDINGGEMMA_DIM]
# L2-normalize so cosine similarity == dot product (matches what the
# MTEB methodology assumes; ChromaDB's distance is configured for it).
norms = np.linalg.norm(sent_emb, axis=1, keepdims=True) + 1e-12
return (sent_emb / norms).tolist()
embeddings: list[list[float]] = []
# Tokenize and run per sub-batch, not over the whole input: padding
# is to the longest sequence in the sub-batch, and the ONNX runtime
# only ever holds batch_size rows of attention buffers at a time
# (#1770).
for start in range(0, len(input), self._batch_size):
chunk = input[start : start + self._batch_size]
texts = [_EMBEDDINGGEMMA_PREFIX + t for t in chunk]
encs = self._tokenizer.encode_batch(texts)
input_ids = np.asarray([e.ids for e in encs], dtype=np.int64)
attention_mask = np.asarray([e.attention_mask for e in encs], dtype=np.int64)
outputs = self._session.run(
None, {"input_ids": input_ids, "attention_mask": attention_mask}
)
sent_emb = outputs[self._output_idx][:, :_EMBEDDINGGEMMA_DIM]
# L2-normalize so cosine similarity == dot product (matches what the
# MTEB methodology assumes; ChromaDB's distance is configured for it).
norms = np.linalg.norm(sent_emb, axis=1, keepdims=True) + 1e-12
embeddings.extend((sent_emb / norms).tolist())
return embeddings
def embed_query(self, input: list[str]) -> list[list[float]]: # noqa: A002 — ChromaDB EF protocol
"""Embed query documents (ChromaDB EF protocol)."""
@@ -254,18 +379,23 @@ def get_embedding_function(device: Optional[str] = None, model: Optional[str] =
providers, effective = _resolve_providers(device)
cache_key = (model, tuple(providers))
cached = _EF_CACHE.get(cache_key)
cached = _EF_CACHE.get(cache_key) # lock-free fast path; dict.get is GIL-atomic
if cached is not None:
return cached
with _EF_CACHE_LOCK:
cached = _EF_CACHE.get(cache_key)
if cached is not None:
return cached
if model == "embeddinggemma":
ef = EmbeddinggemmaONNX(preferred_providers=providers)
else:
# Default: minilm (or anything we don't recognize — back-compat win).
ef_cls = _build_ef_class()
ef = ef_cls(preferred_providers=providers)
threads = _resolve_intra_op_threads()
if model == "embeddinggemma":
ef = EmbeddinggemmaONNX(preferred_providers=providers, intra_op_num_threads=threads)
else:
# Default: minilm (or anything we don't recognize — back-compat win).
ef_cls = _build_ef_class()
ef = ef_cls(preferred_providers=providers, intra_op_num_threads=threads)
_EF_CACHE[cache_key] = ef
_EF_CACHE[cache_key] = ef
logger.info(
"Embedding function initialized (model=%s device=%s providers=%s)",
model,
@@ -287,3 +417,59 @@ def describe_device(device: Optional[str] = None) -> str:
device = MempalaceConfig().embedding_device
_, effective = _resolve_providers(device)
return effective
# Probed vector widths, keyed by resolved model name. Populated once per
# process the first time an identity is resolved for a model.
_DIM_CACHE: dict = {}
def current_model_name(model: Optional[str] = None) -> str:
"""Resolve the canonical embedder model name (cheap, no model load).
This is the configured ``embedding_model`` (``"minilm"`` /
``"embeddinggemma"`` / ...), not the embedding function's internal
``name()`` (which is spoofed to ``"default"`` for ChromaDB compatibility).
"""
if model is not None:
return str(model).strip().lower()
from .config import MempalaceConfig
return MempalaceConfig().embedding_model
def probe_dimension(device: Optional[str] = None, model: Optional[str] = None) -> int:
"""Return the embedder's output dimension by embedding a short probe.
Model-agnostic — works for any model without a hardcoded table — and
cached per resolved model name so the probe is paid at most once per
process. Returns ``0`` if the probe fails (treated as "dimension unknown"
by the identity check, so a probe failure never blocks normal operation).
"""
name = current_model_name(model)
cached = _DIM_CACHE.get(name)
if cached is not None:
return cached
try:
ef = get_embedding_function(device=device, model=model)
vectors = ef(input=["probe"])
dim = len(vectors[0]) if vectors and vectors[0] is not None else 0
except Exception:
logger.debug("Embedding dimension probe failed for model=%s", name, exc_info=True)
dim = 0
_DIM_CACHE[name] = dim
return dim
def get_embedder_identity(device: Optional[str] = None, model: Optional[str] = None):
"""Resolve the current embedder identity (RFC 001).
``model_name`` from config (cheap); ``dimension`` from a cached one-time
probe. Returns an :class:`~mempalace.backends.base.EmbedderIdentity`.
"""
from .backends.base import EmbedderIdentity
return EmbedderIdentity(
model_name=current_model_name(model),
dimension=probe_dimension(device=device, model=model),
)
+71
View File
@@ -0,0 +1,71 @@
"""No-LLM structural entity extraction for the associative graph.
Pulls deterministic, *structural* tokens from text — author-quoted code spans, URLs,
file paths, qualified identifiers, and CamelCase symbols — to populate the ``entities``
drawer-metadata field that hallways/tunnels consume. Structural-only by design: no
wordlists, no NLP models, no domain vocabulary, so it stays language-neutral and
predictable, and biases to precision (only tokens that are unambiguously "a thing being
referred to") over recall.
The output format matches what ``hallways._parse_entities`` expects: a ``;``-joined string.
"""
import re
# Author-quoted code spans are the highest-signal structural marker: `foo`, `obj.method()`.
_BACKTICK = re.compile(r"`([^`\n]{2,64})`")
# URLs.
_URL = re.compile(r"https?://[^\s)>\]}\"']+")
# Paths with a separator and a short extension: rag/foo.py, a/b/c.tsx.
_PATH = re.compile(r"\b[\w.-]+/[\w./-]*\.[A-Za-z][A-Za-z0-9]{0,4}\b")
# Qualified dotted identifiers; each segment starts with a letter and is >=2 chars, so
# "1.2.3", "e.g", and "i.e" are excluded: module.func, pkg.Class.method.
_QUALIFIED = re.compile(r"\b[A-Za-z][A-Za-z0-9_]+(?:\.[A-Za-z][A-Za-z0-9_]+)+\b")
# CamelCase with >=2 humps — strongly code-specific: ChromaBackend, MemoryStack.
_CAMEL = re.compile(r"\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b")
# snake_case (must contain an underscore, so it can't match plain English): do_thing,
# _extract_authored_at. The optional leading/trailing `_?` matches dunder-style names
# whose underscore would otherwise fall outside the `\b` boundary (`_` is a word char).
_SNAKE = re.compile(r"\b_?[a-z][a-z0-9]*(?:_[a-z0-9]+)+_?\b")
_MAX_ENTITIES = 24
_MIN_LEN = 2
_MAX_LEN = 64
def _clean(token):
# `;` is the entities-metadata separator, so it must never survive inside an entity
# (e.g. a URL query string or a backtick span) or it would split the field.
return token.replace(";", " ").strip().strip("`.,:()[]{}<>\"'").strip()
def extract_structural_entities(text, max_entities=_MAX_ENTITIES):
"""Return up to ``max_entities`` structural entities from ``text``.
Deterministic and order-stable: entities are ranked by occurrence count (ties broken
by first appearance), deduplicated case-insensitively, preserving the first-seen
surface form.
"""
if not text:
return []
counts = {}
order = {}
seq = 0
for pattern in (_BACKTICK, _URL, _PATH, _QUALIFIED, _CAMEL, _SNAKE):
for match in pattern.finditer(text):
token = _clean(match.group(1) if pattern is _BACKTICK else match.group(0))
if not (_MIN_LEN <= len(token) <= _MAX_LEN):
continue
key = token.lower()
if key not in counts:
counts[key] = 0
order[key] = (seq, token)
seq += 1
counts[key] += 1
ranked = sorted(order, key=lambda k: (-counts[k], order[k][0]))
return [order[k][1] for k in ranked[:max_entities]]
def entities_metadata(text, max_entities=_MAX_ENTITIES):
"""``;``-joined entity string for drawer metadata, or ``""`` when none are found."""
return ";".join(extract_structural_entities(text, max_entities=max_entities))
+2
View File
@@ -215,6 +215,8 @@ PROSE_EXTENSIONS = {
".md",
".rst",
".csv",
".tex",
".bib",
}
READABLE_EXTENSIONS = {
+16 -8
View File
@@ -507,7 +507,7 @@ def scan_formats(directory: Union[Path, str]) -> list[Path]:
def _print_mine_summary(
files: list,
files_seen: int,
files_with_text: int,
files_skipped: int,
files_errored: int,
@@ -523,7 +523,7 @@ def _print_mine_summary(
print(f"\n{'=' * 55}")
print(" Summary")
print(f"{'-' * 55}")
print(f" Files seen: {len(files)}")
print(f" Files seen: {files_seen}")
print(f" Files extracted: {files_with_text}")
print(f" Files skipped: {files_skipped}")
print(f" Files errored: {files_errored}")
@@ -750,7 +750,7 @@ def mine_formats(
# min_chunk_size) are now threaded through chunk_text below, so users
# who customized their config see the effect in format-mode mining.
# Per PR #1555 review (Gemini #3).
palace_config = MempalaceConfig()
palace_config = MempalaceConfig(palace_path=palace_path)
format_path = Path(format_dir).expanduser().resolve()
if not wing:
@@ -789,9 +789,11 @@ def mine_formats(
files: list = []
collection = None
total_drawers = 0
files_mined = 0
files_skipped = 0
files_with_text = 0
files_errored = 0
files_processed = 0
status_counts: dict = defaultdict(int)
try:
@@ -799,15 +801,14 @@ def mine_formats(
# ``~/docs`` and relative inputs work consistently. Per PR #1555 review
# (Copilot #10).
files = scan_formats(format_path)
if limit > 0:
files = files[:limit]
print(f"\n{'=' * 55}")
print(" MemPalace Mine — Format extraction")
print(f"{'=' * 55}")
print(f" Wing: {wing}")
print(f" Source: {format_path}")
print(f" Files: {len(files)}")
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
print(f" Files: {len(files)}{limit_suffix}")
print(f" Palace: {palace_path}")
if dry_run:
print(" DRY RUN — nothing will be filed")
@@ -816,6 +817,7 @@ def mine_formats(
collection = get_collection(palace_path) if not dry_run else None
for i, filepath in enumerate(files, 1):
files_processed = i
source_file = str(filepath)
# Per-file try/except so one bad file can't crash the whole mine.
@@ -876,6 +878,9 @@ def mine_formats(
if dry_run:
print(f" [DRY RUN] {filepath.name} → {len(chunks)} drawers")
total_drawers += len(chunks)
files_mined += 1
if limit > 0 and files_mined >= limit:
break
continue
drawers_added, skipped = _file_chunks_locked(
@@ -893,7 +898,10 @@ def mine_formats(
continue
total_drawers += drawers_added
files_mined += 1
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
if limit > 0 and files_mined >= limit:
break
except Exception as exc:
# Log and continue — one malformed file shouldn't kill the
# whole mine. Mirrors miner.py's per-file recovery.
@@ -937,7 +945,7 @@ def mine_formats(
# skipped quietly.
if not dry_run:
try:
tunnels_added = _compute_topic_tunnels_for_wing(wing)
tunnels_added = _compute_topic_tunnels_for_wing(wing, config=palace_config)
if tunnels_added:
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
except Exception as exc:
@@ -973,7 +981,7 @@ def mine_formats(
logger.debug("mine_formats: _cleanup_mine_pid_file failed", exc_info=True)
_print_mine_summary(
files=files,
files_seen=files_processed,
files_with_text=files_with_text,
files_skipped=files_skipped,
files_errored=files_errored,
+71 -29
View File
@@ -46,10 +46,11 @@ from .dynamics import initialize_dynamics_fields
logger = logging.getLogger("mempalace_hallways")
# Persistence target. Mirrors ``palace_graph._TUNNEL_FILE`` so the storage
# pattern is uniform across the two related primitives. Tests override
# this via ``monkeypatch.setattr(hallways, "_HALLWAY_FILE", tmp_path/...)``.
_HALLWAY_FILE = os.path.join(os.path.expanduser("~"), ".mempalace", "hallways.json")
# Persistence target is resolved through ``_get_hallway_file`` below, which
# mirrors ``palace_graph._get_tunnel_file`` (the 3.3.6 palace-scoped pattern)
# so the storage layout is uniform across the two related primitives. Tests
# should monkey-patch ``_get_hallway_file`` and ``_legacy_hallway_file`` rather
# than poking a module-level constant.
_SCHEMA_VERSION = 1
@@ -62,36 +63,72 @@ __all__ = [
# ─────────────────────────────────────────────────────────────────────────────
# Persistence — JSON file at _HALLWAY_FILE, restricted perms (0600) on POSIX
# Persistence — JSON file resolved from MempalaceConfig.hallway_file,
# restricted perms (0600) on POSIX. Pre-3.3.6 behavior (hardcoded
# ~/.mempalace/hallways.json) is kept only as a one-time orphan detection
# fallback, matching the palace_graph tunnel-file migration pattern.
# ─────────────────────────────────────────────────────────────────────────────
def _load_hallways() -> list[dict]:
"""Read all hallway records. Returns ``[]`` if the file is missing or corrupt."""
if not os.path.exists(_HALLWAY_FILE):
def _get_hallway_file(config=None) -> str:
"""Return the path to the hallways.json file, derived from MempalaceConfig.palace_path."""
from .config import MempalaceConfig
config = config or MempalaceConfig()
return config.hallway_file
def _legacy_hallway_file() -> str:
"""The pre-palace-scoped hardcoded path. Kept only for one-time orphan detection."""
return os.path.join(os.path.expanduser("~"), ".mempalace", "hallways.json")
def _load_hallways(config=None) -> list[dict]:
"""Read all hallway records. Returns ``[]`` if the file is missing or corrupt.
Backwards-compatibility: prior to this migration the hallway file was
hardcoded at ``~/.mempalace/hallways.json`` regardless of the configured
palace_path. If the configured hallway file is missing but a legacy file
exists at a different path, log a one-line warning naming both paths so
users can move the file manually. We do NOT auto-migrate — auto-merging
hallway state across two locations is too magical for a bugfix and risks
clobbering newer data. Same posture as ``palace_graph._load_tunnels``.
"""
current_hallway_file = _get_hallway_file(config)
if os.path.exists(current_hallway_file):
try:
with open(current_hallway_file, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
logger.debug("hallways: load failed, treating as empty", exc_info=True)
return []
if isinstance(raw, dict) and "hallways" in raw:
return raw.get("hallways") or []
if isinstance(raw, list):
return raw
return []
try:
with open(_HALLWAY_FILE, encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError):
logger.debug("hallways: load failed, treating as empty", exc_info=True)
return []
if isinstance(raw, dict) and "hallways" in raw:
return raw.get("hallways") or []
if isinstance(raw, list):
return raw
legacy = _legacy_hallway_file()
if legacy != current_hallway_file and os.path.exists(legacy):
logger.warning(
"Legacy hallways file at '%s' is being ignored; configured location is '%s'. "
"Move or copy the legacy file to the configured path to recover its hallways.",
legacy,
current_hallway_file,
)
return []
def _save_hallways(hallways: list[dict]) -> None:
"""Atomically persist hallway records to _HALLWAY_FILE.
def _save_hallways(hallways: list[dict], config=None) -> None:
"""Atomically persist hallway records to the configured hallway file.
Uses an os.replace temp-file dance so a crash mid-write doesn't
corrupt the file. POSIX permission is restricted to 0600 because
hallways reveal within-wing entity connections that the user may
not want world-readable.
"""
directory = os.path.dirname(_HALLWAY_FILE)
hallway_file = _get_hallway_file(config)
directory = os.path.dirname(hallway_file)
os.makedirs(directory, exist_ok=True)
payload = {
"schema_version": _SCHEMA_VERSION,
@@ -106,7 +143,7 @@ def _save_hallways(hallways: list[dict]) -> None:
except OSError:
# Non-POSIX systems may not support chmod; not fatal.
pass
os.replace(tmp_path, _HALLWAY_FILE)
os.replace(tmp_path, hallway_file)
except Exception:
try:
os.unlink(tmp_path)
@@ -162,6 +199,7 @@ def compute_hallways_for_wing(
wing: str,
col=None,
min_count: int = 2,
config=None,
) -> list[dict]:
"""Compute entity-pair hallways for one wing.
@@ -192,6 +230,10 @@ def compute_hallways_for_wing(
hallway between two entities. Default 2 — single co-occurrences
are noise (entities mentioned together once in one drawer);
two or more is a real signal. Clamped to ``>=1``.
config: Optional ``MempalaceConfig`` selecting the palace-scoped
hallway sidecar. Callers using an explicit palace path must pass
the matching config so derived graph state cannot leak into the
default palace.
Returns:
List of hallway dicts created for this wing. Records for other
@@ -273,7 +315,7 @@ def compute_hallways_for_wing(
# across recomputes. Without this preservation, every mine wipes
# the connection weights accumulated through use — defeating the
# living-connection layer entirely.
existing = _load_hallways()
existing = _load_hallways(config)
existing_dynamics_lookup: dict = {}
for h in existing:
if h.get("wing") != wing:
@@ -323,7 +365,7 @@ def compute_hallways_for_wing(
# 4. Persist — preserve other-wing records, replace this wing's records.
preserved_other_wings = [h for h in existing if h.get("wing") != wing]
_save_hallways(preserved_other_wings + created)
_save_hallways(preserved_other_wings + created, config)
return created
@@ -333,19 +375,19 @@ def compute_hallways_for_wing(
# ─────────────────────────────────────────────────────────────────────────────
def list_hallways(wing: Optional[str] = None) -> list[dict]:
def list_hallways(wing: Optional[str] = None, config=None) -> list[dict]:
"""List hallway records. Filter by ``wing`` if specified."""
all_hallways = _load_hallways()
all_hallways = _load_hallways(config)
if wing is None:
return list(all_hallways)
return [h for h in all_hallways if h.get("wing") == wing]
def delete_hallway(hallway_id: str) -> bool:
def delete_hallway(hallway_id: str, config=None) -> bool:
"""Remove one hallway record by id. Returns True if a record was removed."""
hallways = _load_hallways()
hallways = _load_hallways(config)
filtered = [h for h in hallways if h.get("id") != hallway_id]
if len(filtered) == len(hallways):
return False
_save_hallways(filtered)
_save_hallways(filtered, config)
return True
+159
View File
@@ -0,0 +1,159 @@
"""Compatibility helpers for legacy shell hooks.
The shell hooks intentionally stay small and portable, but parsing Claude
hook JSON and counting UTF-8 JSONL transcripts is safer in Python than in
inline shell snippets. This module centralizes that behavior for both
hooks/mempal_save_hook.sh and hooks/mempal_precompact_hook.sh.
"""
from __future__ import annotations
import json
import re
import sys
_SESSION_ID_RE = re.compile(r"[^a-zA-Z0-9_-]")
_CONTROL_CHARS_RE = re.compile(r"[\x00\r\n]")
def sanitize_session_id(session_id: object) -> str:
"""Keep session ids safe for state-file names."""
sanitized = _SESSION_ID_RE.sub("", str(session_id or ""))
return sanitized or "unknown"
def normalize_transcript_path(path: object) -> str:
r"""Normalize a hook transcript path without destroying Windows paths.
Claude Code on Windows sends paths like:
C:\Users\me\.claude\projects\<project>\<session>.jsonl
The old shell sanitizer removed both the drive-letter colon and
backslashes. That turned a valid transcript path into a nonexistent path.
For transcript paths, we only remove control characters that would break
newline-delimited shell parsing, and normalize backslashes to forward
slashes so Git Bash can still address the same Windows file.
"""
normalized = str(path or "").replace("\\", "/")
return _CONTROL_CHARS_RE.sub("", normalized)
def _stop_hook_active(value: object) -> str:
"""Return the exact boolean string expected by the shell hook."""
if value is True:
return "True"
if str(value).strip().lower() in ("true", "1", "yes"):
return "True"
return "False"
def parse_stop_payload(payload: dict) -> tuple[str, str, str]:
return (
sanitize_session_id(payload.get("session_id", "")),
_stop_hook_active(payload.get("stop_hook_active", False)),
normalize_transcript_path(payload.get("transcript_path", "")),
)
def parse_precompact_payload(payload: dict) -> tuple[str, str]:
return (
sanitize_session_id(payload.get("session_id", "")),
normalize_transcript_path(payload.get("transcript_path", "")),
)
def count_human_messages(path: str) -> int:
"""Count user messages in a Claude transcript JSONL file.
Claude transcripts are UTF-8. Windows Python defaults to cp1252 in many
environments, so the encoding must be explicit. Invalid bytes are ignored
to match the hooks' fail-soft behavior.
"""
count = 0
with open(path, encoding="utf-8", errors="ignore") as fh:
for line in fh:
try:
entry = json.loads(line)
except Exception:
continue
msg = entry.get("message", {})
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
content = msg.get("content", "")
if isinstance(content, str) and "<command-message>" in content:
continue
count += 1
return count
def _load_stdin_json() -> dict:
raw = sys.stdin.read()
# Empty stdin is a legitimate hook state. Treat it as an empty payload so
# the sentinel is printed and the shell fail-loud guard does not spam disk.
if raw == "":
return {}
# For non-empty malformed input, intentionally let json.loads raise.
# The shell hooks capture this stderr in last_python_err.log and, because
# no sentinel is printed, write a bounded copy of the raw payload to
# last_input.log. That fail-loud contract is pinned by
# tests/test_hooks_bash_compat.py.
data = json.loads(raw)
if not isinstance(data, dict):
raise TypeError(f"hook input must be a JSON object, got {type(data).__name__}")
return data
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if not argv:
print(
"usage: python -m mempalace.hook_shell <parse-stop|parse-precompact|count-human-messages>",
file=sys.stderr,
)
return 2
command = argv[0]
if command == "parse-stop":
session_id, stop_hook_active, transcript_path = parse_stop_payload(_load_stdin_json())
print("__MEMPAL_PARSE_OK__")
print(session_id)
print(stop_hook_active)
print(transcript_path)
return 0
if command == "parse-precompact":
session_id, transcript_path = parse_precompact_payload(_load_stdin_json())
print("__MEMPAL_PARSE_OK__")
print(session_id)
print(transcript_path)
return 0
if command == "count-human-messages":
if len(argv) != 2:
print("count-human-messages requires a transcript path", file=sys.stderr)
return 2
try:
print(count_human_messages(argv[1]))
except Exception:
print(0)
return 0
print(f"unknown hook_shell command: {command}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+314 -13
View File
@@ -1,8 +1,8 @@
"""
Hook logic for MemPalace — Python implementation of session-start, stop, and precompact hooks.
Hook logic for MemPalace — Python implementation of session-start, stop, session-end, and precompact hooks.
Reads JSON from stdin, outputs JSON to stdout.
Supported hooks: session-start, stop, precompact
Supported hooks: session-start, stop, session-end, precompact
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
"""
@@ -25,7 +25,7 @@ PALACE_ROOT = Path.home() / ".mempalace"
def _detached_popen_kwargs() -> dict:
"""Kwargs that fully detach a Popen child so the hook process can exit.
"""Kwargs that give a Popen child a hidden console so the hook can exit.
Without these, Windows holds the parent open until the child closes the
inherited stdout/stderr handles — manifesting as "Stop hook hangs" at
@@ -36,7 +36,7 @@ def _detached_popen_kwargs() -> dict:
kwargs: dict = {"stdin": subprocess.DEVNULL, "close_fds": True}
if os.name == "nt":
flags = 0
for name in ("DETACHED_PROCESS", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
for name in ("CREATE_NO_WINDOW", "CREATE_NEW_PROCESS_GROUP", "CREATE_BREAKAWAY_FROM_JOB"):
flags |= getattr(subprocess, name, 0)
if flags:
kwargs["creationflags"] = flags
@@ -509,6 +509,73 @@ def _spawn_mine(cmd: list) -> None:
pass
def _hooks_daemon_enabled() -> bool:
try:
return MempalaceConfig().hook_use_daemon is True
except Exception:
return False
def _daemon_mine_dedupe_key(source: str, mode: str) -> str:
try:
source_key = str(Path(source).expanduser().resolve())
except OSError:
source_key = str(Path(source).expanduser())
return f"hook:mine:{mode}:{source_key}"
def _daemon_available() -> bool:
"""True iff a daemon is already running for the configured palace.
This is a fast localhost health check, not a spawn: the 500ms hook budget
forbids auto-starting a python subprocess from a hook (cold start is
~15s). Daemon mode for hooks requires the user to have started the daemon
explicitly via `mempalace daemon start`; when it isn't up, hooks fall back
to the existing direct (in-process / spawn) path instead of blocking.
"""
from .daemon import HOOK_PROBE_TIMEOUT, get_client_if_running
try:
return (
get_client_if_running(MempalaceConfig().palace_path, health_timeout=HOOK_PROBE_TIMEOUT)
is not None
)
except Exception:
return False
def _submit_daemon_job(
kind: str,
payload: dict,
*,
dedupe_key: str = None,
priority: int = 0,
wait: bool = False,
timeout: float = 60.0,
):
"""Submit to an already-running daemon. Never auto-starts (see _daemon_available).
Raises DaemonError on a real failure (job rejected, timeout, daemon died
mid-submit). Callers must NOT fall back to the direct path on such errors —
the daemon may already have accepted the job, and re-running it would
duplicate verbatim content. Only an absent daemon (handled by the caller's
_daemon_available() precheck) should fall back.
"""
from .daemon import submit_job
palace_path = MempalaceConfig().palace_path
return submit_job(
kind,
payload,
palace_path=palace_path,
dedupe_key=dedupe_key,
priority=priority,
wait=wait,
auto_start=False,
timeout=timeout,
)
def _maybe_auto_ingest():
"""Background-mine MEMPAL_DIR (project files) if set.
@@ -527,9 +594,26 @@ def _maybe_auto_ingest():
return
for mine_dir, mode in targets:
try:
if _hooks_daemon_enabled() and _daemon_available():
try:
_submit_daemon_job(
"mine",
{"source": mine_dir, "mode": mode, "agent": "mempalace"},
dedupe_key=_daemon_mine_dedupe_key(mine_dir, mode),
wait=False,
)
except Exception as exc:
# Daemon accepted context — don't fall back (would double-mine).
_log(f"Daemon mine submission failed: {exc}")
continue
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
except OSError:
pass
except Exception as exc:
# Non-daemon spawn path failed. Hooks must never crash the user's
# shell — log and continue. Do not label this a daemon failure: the
# daemon block above handles its own errors with its own message.
_log(f"mine hook failed: {exc}")
def _mine_sync():
@@ -546,6 +630,22 @@ def _mine_sync():
log_path = STATE_DIR / "hook.log"
for mine_dir, mode in targets:
try:
if _hooks_daemon_enabled() and _daemon_available():
try:
job = _submit_daemon_job(
"mine",
{"source": mine_dir, "mode": mode, "agent": "mempalace"},
dedupe_key=_daemon_mine_dedupe_key(mine_dir, mode),
wait=True,
timeout=60,
)
result = job.get("result") or {}
if job.get("state") != "succeeded" or not result.get("success", True):
_log(f"Daemon sync mine failed: {result.get('error', job.get('error'))}")
except Exception as exc:
# Daemon accepted context — don't fall back (would double-mine).
_log(f"Daemon sync mine submission failed: {exc}")
continue
with open(log_path, "a") as log_f:
subprocess.run(
[
@@ -560,9 +660,19 @@ def _mine_sync():
stdout=log_f,
stderr=log_f,
timeout=60,
# Windows: hide the conhost window this sync mine would
# otherwise flash on every fire. Mirrors the async paths
# (_spawn_mine / _desktop_toast) via _detached_popen_kwargs().
# 0 is a no-op off-Windows.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except (OSError, subprocess.TimeoutExpired):
pass
except Exception as exc:
# Non-daemon sync spawn path failed. Hooks must never crash the
# user's shell — log and continue (not a daemon failure; the daemon
# block above handles its own errors).
_log(f"mine hook failed: {exc}")
def _desktop_toast(body: str, title: str = "MemPalace"):
@@ -680,6 +790,41 @@ def _save_diary_direct(
)
try:
if _hooks_daemon_enabled() and _daemon_available():
try:
job = _submit_daemon_job(
"diary_write",
{
"agent_name": agent_name,
"entry": entry,
"topic": "checkpoint",
"wing": wing,
},
priority=10,
wait=True,
timeout=30,
)
except Exception as exc:
# Daemon accepted context — don't fall back (would double-write).
_log(f"Daemon diary checkpoint failed: {exc}")
return {"count": 0}
result = job.get("result") or {}
if job.get("state") == "succeeded" and result.get("success"):
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
try:
ack_file = STATE_DIR / "last_checkpoint"
ack_file.write_text(
json.dumps({"msgs": len(messages), "ts": now.isoformat()}),
encoding="utf-8",
)
except OSError:
pass
if toast:
_desktop_toast(f"Checkpoint saved - {len(messages)} messages archived")
return {"count": len(messages), "themes": themes}
_log(f"Daemon diary checkpoint failed: {result.get('error', job.get('error'))}")
return {"count": 0}
from .mcp_server import tool_diary_write
result = tool_diary_write(
@@ -711,8 +856,13 @@ def _save_diary_direct(
def _ingest_transcript(transcript_path: str):
"""Mine a Claude Code session transcript into the palace as a conversation."""
path = Path(transcript_path).expanduser()
if not path.is_file() or path.stat().st_size < 100:
path = _validate_transcript_path(transcript_path)
if path is None:
return
try:
if not path.is_file() or path.stat().st_size < 100:
return
except OSError:
return
try:
@@ -721,6 +871,25 @@ def _ingest_transcript(transcript_path: str):
return
try:
if _hooks_daemon_enabled() and _daemon_available():
try:
_submit_daemon_job(
"mine",
{
"source": str(path.parent),
"mode": "convos",
"wing": "sessions",
"agent": "mempalace",
},
dedupe_key=_daemon_mine_dedupe_key(str(path.parent), "convos"),
wait=False,
)
_log(f"Transcript ingest submitted to daemon: {path.name}")
except Exception as exc:
# Daemon accepted context — don't fall back (would double-mine).
_log(f"Daemon transcript ingest failed: {exc}")
return
# Route through ``_spawn_mine`` so the per-target PID guard kicks
# in here too — repeated Stop/PreCompact fires for the same
# transcript should not stack up parallel ingest mines.
@@ -740,6 +909,11 @@ def _ingest_transcript(transcript_path: str):
_log(f"Transcript ingest started: {path.name}")
except OSError:
pass
except Exception as exc:
# Non-daemon ingest spawn path failed. Hooks must never crash the
# user's shell — log and continue (not a daemon failure; the daemon
# block above handles its own errors).
_log(f"transcript ingest hook failed: {exc}")
SUPPORTED_HARNESSES = {"claude-code", "codex"}
@@ -787,6 +961,26 @@ _ENCODED_PARENT_PREFIXES = (
)
def _safe_wing_slug(name: str) -> str:
"""Normalize a project directory name into a wing slug ``sanitize_name`` accepts.
Builds on the historical space/hyphen handling: map characters outside
``sanitize_name``'s set to ``_`` while keeping ``.`` and ``'`` so existing wings
for names like ``my.app`` are preserved (renaming a wing would orphan diary
entries already filed under it), collapse ``..`` which the validator rejects as
path traversal, trim edge separators it won't accept, and cap the length so
``wing_<slug>`` stays within ``sanitize_name``'s 128-character limit. Without
this a folder containing e.g. a leading ``+`` produced ``wing_+project``, which
``sanitize_name`` rejects — silently breaking diary auto-save. Falls back to
``sessions`` when a name reduces to nothing (e.g. ``+``).
"""
slug = name.lower().replace(" ", "_").replace("-", "_")
slug = re.sub(r"[^\w.']+", "_", slug)
slug = re.sub(r"\.{2,}", ".", slug)
slug = slug[:120].strip("_.'")
return slug or "sessions"
def _wing_from_jsonl_cwd(transcript_path: str) -> Optional[str]:
"""Read ``cwd`` from the first JSONL line that records it.
@@ -820,8 +1014,7 @@ def _wing_from_jsonl_cwd(transcript_path: str) -> Optional[str]:
continue
project = cwd_norm.rsplit("/", 1)[-1]
if project:
slug = project.lower().replace(" ", "_").replace("-", "_")
return f"wing_{slug}"
return f"wing_{_safe_wing_slug(project)}"
except OSError:
pass
return None
@@ -878,15 +1071,12 @@ def _wing_from_transcript_path(transcript_path: str) -> str:
if encoded.startswith(prefix):
encoded = encoded[len(prefix) :]
break
project = encoded.lower().replace(" ", "_").replace("-", "_")
if project:
return f"wing_{project}"
return f"wing_{_safe_wing_slug(encoded)}"
# 3. Legacy — explicit -Projects-<name> segment
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
if match:
project = match.group(1).lower().replace(" ", "_").replace("-", "_")
return f"wing_{project}"
return f"wing_{_safe_wing_slug(match.group(1))}"
# 4. Default
return "wing_sessions"
@@ -1021,6 +1211,116 @@ def hook_session_start(data: dict, harness: str):
_output({})
def _clear_session_last_save(session_id: str) -> None:
"""Drop the per-session save marker once a session has ended.
``hook_stop`` writes ``{session_id}_last_save`` but never had a clean-exit
cleanup path, so the marker lingered. The session is over by the time
``hook_session_end`` runs, so removing it here keeps ``hook_state/`` from
accumulating dead markers. OS errors (including a missing marker, since
``FileNotFoundError`` is an ``OSError``) are swallowed — this is best-effort
cleanup, never a reason to fail the hook.
"""
try:
(STATE_DIR / f"{session_id}_last_save").unlink()
except OSError:
pass
def hook_session_end(data: dict, harness: str):
"""Session end hook: one final flush when a session exits cleanly.
Closes the gap (#1341) where a session that never crosses ``SAVE_INTERVAL``
on ``Stop`` and never triggers ``PreCompact`` exits with nothing saved —
the common case for short, useful sessions.
Why background instead of mine inline: Claude Code's hooks reference
documents a default SessionEnd timeout of 1.5 seconds, 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 this handler must never mine in the hook foreground. The
shell wrapper backgrounds it and returns immediately; the heavy capture is
spawned *detached* via ``_ingest_transcript`` / ``_maybe_auto_ingest`` (both
route through ``_spawn_mine`` / ``_detached_popen_kwargs``). On POSIX that
detached child reliably outlives the session (verified). On Windows only the
mine grandchild (spawned with detached-process flags) is designed to break
away from the session; the backgrounded hook process and the in-process
diary write are best-effort there (no Windows CI coverage yet). This
honors the "background everything / hooks under 500ms" budget. SessionEnd
has no decision control, so this only ever saves; it never emits a block
payload.
"""
if not _palace_root_exists():
_output({})
return
# Parse inside the try so a malformed payload (e.g. non-dict stdin that
# makes _parse_harness_input raise) still runs the finally cleanup below.
session_id = "unknown"
try:
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
transcript_path = parsed["transcript_path"]
# Read config defensively (mirror hook_stop): a corrupt or unreadable
# config must not lose the final save, so default to auto-save on and
# toasts off rather than crashing the hook.
try:
config = MempalaceConfig()
auto_save = config.hooks_auto_save
toast = config.hook_desktop_toast
except Exception:
auto_save = True
toast = False
# Respect auto_save config toggle (clean opt-out)
if not auto_save:
_output({})
return
_log(f"SESSION END for session {session_id}")
# Validate the harness-provided transcript path before touching it
# (extension + ".." traversal check), mirroring the read path that
# already runs through _validate_transcript_path. A rejected path skips
# the transcript captures but still lets the independent MEMPAL_DIR mine
# run.
valid_transcript = ""
if transcript_path:
try:
validated = _validate_transcript_path(transcript_path)
except OSError:
validated = None
if validated is None:
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
else:
valid_transcript = str(validated)
# Flush. The diary checkpoint (in-process ChromaDB write) runs FIRST,
# before any detached mine is spawned, so it never contends for the
# palace lock; this handler is already backgrounded by the wrapper, so it
# is not under the SessionEnd budget and has time to finish. The detached
# transcript ingest follows; re-mining a transcript ``Stop`` already
# captured is a near no-op (deterministic convo IDs + ``file_already_mined``
# short-circuit + upsert). ``reason`` is intentionally not branched on:
# every clean-exit reason (incl. ``/clear`` / ``resume``) warrants the
# flush. Order matches ``hook_stop``.
if valid_transcript:
_save_diary_direct(
valid_transcript,
session_id,
wing=_wing_from_transcript_path(valid_transcript),
toast=toast,
agent_name=_diary_agent_for_harness(harness),
)
_ingest_transcript(valid_transcript)
_maybe_auto_ingest()
_output({})
finally:
_clear_session_last_save(session_id)
def hook_precompact(data: dict, harness: str):
"""Precompact hook: mine transcript synchronously, then allow compaction.
@@ -1064,6 +1364,7 @@ def run_hook(hook_name: str, harness: str):
hooks = {
"session-start": hook_session_start,
"stop": hook_stop,
"session-end": hook_session_end,
"precompact": hook_precompact,
}
+2 -2
View File
@@ -22,7 +22,7 @@ import hashlib
# as legacy ``v1`` (pre-delimiter recipe), drawers with ``id_recipe="v2"``
# are guaranteed collision-safe within the v2 generation. The constant is
# exported so call sites use ``ids.ID_RECIPE`` rather than a magic string.
ID_RECIPE: str = "v2"
ID_RECIPE: str = "v3"
# '|' is reserved in Windows filenames and cannot appear in source paths
# on any supported platform, making it strictly safer than ':' (which
@@ -49,7 +49,7 @@ def _delimited_sha256(parts: tuple[object, ...], truncate: int) -> str:
e.g. ``valid_from=None`` joins as the literal string ``"None"`` rather
than crashing.
"""
key = _DELIM.join(str(p) for p in parts).encode()
key = "".join(f"{len(part)}:{part}" for part in map(str, parts)).encode()
return hashlib.sha256(key).hexdigest()[:truncate]
+1
View File
@@ -29,6 +29,7 @@ AI memory system. Store everything, find anything. Local, free, no API key.
### Palace (write)
- mempalace_add_drawer -- Add a new memory (drawer)
- mempalace_checkpoint -- Save a whole session in one call (dedup + file + diary)
- mempalace_delete_drawer -- Delete a memory (drawer)
### Knowledge Graph
+129 -2
View File
@@ -39,7 +39,7 @@ import json
import os
import sqlite3
import threading
from datetime import date, datetime
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Optional
from .config import sanitize_iso_temporal
@@ -113,6 +113,14 @@ def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
This keeps legacy date-only facts working when callers query with
canonical UTC datetimes such as '2026-05-06T15:00:00Z'.
The upper bound is *strict* (``valid_to > as_of``): a fact whose
``valid_to`` equals the query instant has already ended at that instant,
so the interval is treated as half-open ``[valid_from, valid_to)``. This
is what lets a fact and its successor share a boundary instant without an
as-of query returning both. Date-only ``valid_to`` still expands to the
end of that day (``T23:59:59Z``), so a standalone date-only fact stays
valid through its whole final day exactly as before.
"""
as_of_key = _temporal_start_key(as_of)
@@ -121,7 +129,7 @@ def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
return (
f" AND (t.valid_from IS NULL OR {valid_from_expr} <= ?) "
f"AND (t.valid_to IS NULL OR {valid_to_expr} >= ?)",
f"AND (t.valid_to IS NULL OR {valid_to_expr} > ?)",
[as_of_key, as_of_key],
)
@@ -359,6 +367,125 @@ class KnowledgeGraph:
(ended, sub_id, pred, obj_id),
)
def supersede(
self,
subject: str,
predicate: str,
old_obj: str,
new_obj: str,
at: str = None,
confidence: float = 1.0,
source_closet: str = None,
source_file: str = None,
source_drawer_id: str = None,
adapter_name: str = None,
):
"""Atomically replace one fact with another at a single shared boundary.
Closes the currently-open ``(subject, predicate, old_obj)`` triple with
``valid_to = at`` and opens ``(subject, predicate, new_obj)`` with
``valid_from = at`` in one transaction, at a single shared instant.
Paired with the half-open upper bound in ``_temporal_filter_sql``, an
as-of query at that instant returns only the successor.
This is the primitive for a value change. Hand-rolling a handover as
``invalidate(ended=D)`` + ``add_triple(valid_from=D)`` with date-only
``D`` leaves two facts sharing the whole day ``D`` (``valid_to`` expands
to ``T23:59:59Z`` while ``valid_from`` expands to ``T00:00:00Z``), so an
as-of query on ``D`` returns both. ``supersede`` avoids this by writing
one identical precise instant to both sides.
``at`` defaults to the current UTC instant. A date-only ``at`` is
normalized to ``<date>T00:00:00Z`` so both sides carry the same precise
value rather than the asymmetric whole-day expansion.
Returns the new triple's id. If no open ``old_obj`` triple exists the
successor is still opened, so ``supersede`` degrades to ``add_triple``.
"""
if at is None:
boundary = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
elif _is_date_only_temporal(at):
boundary = f"{at}T00:00:00Z"
else:
boundary = at
boundary = sanitize_iso_temporal(boundary, "at")
sub_id = self._entity_id(subject)
old_id = self._entity_id(old_obj)
new_id = self._entity_id(new_obj)
pred = predicate.lower().replace(" ", "_")
with self._lock:
conn = self._conn()
with conn:
# Only create entities we actually open a fact for. old_obj is
# matched by id in the UPDATE below whether or not its row
# exists, so inserting it would just orphan an entity when no
# open old fact is present (the degrade-to-add path).
for name, eid in ((subject, sub_id), (new_obj, new_id)):
conn.execute(
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
(eid, name),
)
# Reject a boundary that precedes the old fact's start — an
# inverted interval would be invisible to every KG query.
rows = conn.execute(
"SELECT valid_from FROM triples "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, old_id),
).fetchall()
for row in rows:
valid_from = row["valid_from"]
if valid_from is not None and _temporal_end_key(boundary) < _temporal_start_key(
valid_from
):
raise ValueError(
f"at={boundary!r} is before valid_from={valid_from!r}; "
"an inverted interval would be invisible to every KG query"
)
# Close the open old fact at the shared boundary.
conn.execute(
"UPDATE triples SET valid_to=? "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(boundary, sub_id, pred, old_id),
)
# Open the successor at the same instant (idempotent if already open).
existing = conn.execute(
"SELECT id FROM triples "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, new_id),
).fetchone()
if existing:
return existing["id"]
triple_id = make_triple_id(
sub_id, pred, new_id, boundary, datetime.now().isoformat()
)
conn.execute(
"""INSERT INTO triples (
id, subject, predicate, object, valid_from, valid_to,
confidence, source_closet, source_file,
source_drawer_id, adapter_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
triple_id,
sub_id,
pred,
new_id,
boundary,
None,
confidence,
source_closet,
source_file,
source_drawer_id,
adapter_name,
),
)
return triple_id
# ── Query operations ──────────────────────────────────────────────────
def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing"):
+32 -9
View File
@@ -23,7 +23,12 @@ from collections import defaultdict
from .config import MempalaceConfig
from .palace import get_collection as _get_collection
from .searcher import _first_or_empty, build_where_filter
from .searcher import (
_distance_to_similarity,
_first_or_empty,
_metric_for_collection,
build_where_filter,
)
# ---------------------------------------------------------------------------
@@ -121,12 +126,21 @@ class Layer1:
if not docs:
return "## L1 — No memories yet."
# Score each drawer: prefer high importance, recent filing
# Score each drawer: prefer high importance, then most-recent filing.
# NOTE: the ingest pipeline (miner, convo_miner, diary, add_drawer)
# records provenance metadata — wing/room/source/chunk/filed_at — but
# never an evaluative importance/weight field. So `importance` is
# absent on virtually every drawer and ties at the default, which used
# to collapse the sort to insertion order (oldest first). `filed_at`
# is present on every drawer, so it is the *effective* ordering signal:
# newest first. This keeps importance as the primary key for the day a
# scoring pass populates it, while making the "recent filing" half of
# the promise true today with data we already have.
scored = []
for doc, meta in zip(docs, metas):
meta = meta or {}
doc = doc or ""
importance = 3
importance = 3.0
# Try multiple metadata keys that might carry weight info
for key in ("importance", "emotional_weight", "weight"):
val = meta.get(key)
@@ -136,11 +150,15 @@ class Layer1:
except (ValueError, TypeError):
pass
break
scored.append((importance, meta, doc))
# filed_at is an ISO-8601 string; ISO strings sort lexicographically
# in chronological order. Coerce to str so a missing/odd value sorts
# oldest rather than raising during the comparison.
recency = str(meta.get("filed_at") or "")
scored.append((importance, recency, meta, doc))
# Sort by importance descending, take top N
scored.sort(key=lambda x: x[0], reverse=True)
top = scored[: self.MAX_DRAWERS]
# Sort by importance desc, then recency (filed_at) desc; take top N.
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
top = [(imp, meta, doc) for imp, _recency, meta, doc in scored[: self.MAX_DRAWERS]]
# Group by room for readability
by_room = defaultdict(list)
@@ -283,11 +301,12 @@ class Layer3:
if not docs:
return "No results found."
metric = _metric_for_collection(col)
lines = [f'## L3 — SEARCH RESULTS for "{query}"']
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
meta = meta or {}
doc = doc or ""
similarity = round(max(0.0, 1 - dist), 3)
similarity = round(_distance_to_similarity(dist, metric), 3)
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
@@ -300,6 +319,9 @@ class Layer3:
lines.append(f" {snippet}")
if source:
lines.append(f" src: {source}")
authored = (meta.get("authored_at") or "")[:10]
if authored:
lines.append(f" authored: {authored}")
return "\n".join(lines)
@@ -327,6 +349,7 @@ class Layer3:
except Exception:
return []
metric = _metric_for_collection(col)
hits = []
for doc, meta, dist in zip(
_first_or_empty(results, "documents"),
@@ -346,7 +369,7 @@ class Layer3:
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(meta.get("source_file", "?")).name,
"similarity": round(1 - dist, 3),
"similarity": round(_distance_to_similarity(dist, metric), 3),
"metadata": meta,
}
)
+1 -1
View File
@@ -323,7 +323,7 @@ class OpenAICompatProvider(LLMProvider):
base = base.removesuffix("/chat/completions").removesuffix("/v1")
try:
req = Request(f"{base}/v1/models")
if self.api_key:
if self.api_key and (self.api_key_source != "env" or not self.is_external_service):
req.add_header("Authorization", f"Bearer {self.api_key}")
with urlopen(req, timeout=5):
pass
+2608 -255
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -19,6 +19,7 @@ Usage:
"""
import errno
import glob
import os
import shutil
import sqlite3
@@ -28,6 +29,9 @@ from collections import defaultdict
from contextlib import closing
from datetime import datetime
from .backups import prune_backups
from .config import MempalaceConfig
def _restore_stale_palace(palace_path: str, stale_path: str) -> None:
"""Roll back a failed swap.
@@ -291,7 +295,17 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{palace_path}.pre-migrate.{timestamp}"
print(f"\n Backing up to {backup_path}...")
shutil.copytree(palace_path, backup_path)
shutil.copytree(palace_path, backup_path, symlinks=True)
# Enforce backup retention so repeated migrations cannot fill the disk
# with full-palace copies. The backup we just created is the newest, so
# it survives; only older ``.pre-migrate.*`` siblings beyond the limit
# are removed. Best-effort — never let cleanup fail the migration.
prune_backups(
glob.escape(palace_path) + ".pre-migrate.*",
MempalaceConfig().max_backups,
log=print,
)
# Build fresh palace in a temp directory (avoids chromadb reading old state).
# Wrap the whole import-and-swap dance in try/finally so the temp dir is
+207 -32
View File
@@ -14,6 +14,7 @@ import shlex
import hashlib
import fnmatch
import logging
import stat
from pathlib import Path
from datetime import datetime
from collections import defaultdict
@@ -47,6 +48,66 @@ from .ids import ID_RECIPE, make_drawer_id_from_chunk
logger = logging.getLogger("mempalace_mcp")
def _path_within_root(path: Path, root: Path) -> bool:
try:
path.expanduser().resolve().relative_to(root.expanduser().resolve())
return True
except (OSError, ValueError):
return False
def _read_text_no_follow(filepath: Path, root: Path) -> Optional[str]:
if not _path_within_root(filepath, root):
return None
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
fd = -1
try:
fd = os.open(filepath, flags)
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE_SIZE:
return None
with os.fdopen(fd, "r", encoding="utf-8", errors="replace") as f:
fd = -1
return f.read()
except OSError:
return None
finally:
if fd != -1:
try:
os.close(fd)
except OSError:
pass
PHP_EXTENSIONS = {
# Compound Blade templates such as ``view.blade.php`` are covered by the
# final ``.php`` suffix.
".php",
".php3",
".php4",
".php5",
".php7",
".php8",
".phtml",
".phps",
".phpt",
".inc",
".aw",
".fcgi",
".ctp",
".module",
".install",
".profile",
".theme",
".engine",
".twig",
".blade",
".tpl",
".latte",
".volt",
}
READABLE_EXTENSIONS = {
".txt",
".md",
@@ -64,12 +125,23 @@ READABLE_EXTENSIONS = {
".java",
".go",
".rs",
".swift",
".kt",
".kts",
".rb",
".sh",
".csv",
".sql",
".toml",
}
".tex",
".bib",
# C# / .NET
".cs",
".csproj",
".sln",
".razor",
".cshtml",
} | PHP_EXTENSIONS
SKIP_FILENAMES = {
"entities.json",
@@ -172,20 +244,16 @@ class GitignoreMatcher:
self.base_dir = base_dir
self.rules = rules
@classmethod
def from_dir(cls, dir_path: Path):
gitignore_path = dir_path / ".gitignore"
if not gitignore_path.is_file():
return None
try:
lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception:
return None
@staticmethod
def _parse_rules(lines):
"""Parse an iterable of raw pattern strings into a list of rule dicts.
Shared by :meth:`from_dir` and :meth:`from_patterns` so both paths
stay in sync with the gitignore parsing semantics.
"""
rules = []
for raw_line in lines:
line = raw_line.strip()
line = str(raw_line).strip()
if not line:
continue
@@ -217,12 +285,46 @@ class GitignoreMatcher:
"negated": negated,
}
)
return rules
if not rules:
@classmethod
def from_dir(cls, dir_path: Path):
gitignore_path = dir_path / ".gitignore"
if not gitignore_path.is_file():
return None
try:
lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception:
return None
rules = cls._parse_rules(lines)
if not rules:
return None
return cls(dir_path, rules)
@classmethod
def from_patterns(cls, base_dir: Path, patterns):
"""Create a matcher from an explicit list of gitignore-inspired pattern strings.
Supports a gitignore-inspired subset of pattern syntax for
``exclude_patterns`` in ``mempalace.yaml``. Patterns are parsed by
the same rule parser used for ``.gitignore`` files, so familiar
constructs (trailing ``/`` for dir-only, leading ``/`` for anchoring,
``!`` negation, ``**`` globs) work as expected. Full gitignore
semantics are not guaranteed for every edge case.
``patterns`` must be a list of strings. A single string is coerced
to a one-element list for convenience. Non-string entries are
converted via ``str()``.
"""
if isinstance(patterns, str):
patterns = [patterns]
rules = cls._parse_rules(patterns)
if not rules:
return None
return cls(base_dir, rules)
def matches(self, path: Path, is_dir: bool = None):
try:
relative = path.relative_to(self.base_dir).as_posix().strip("/")
@@ -350,6 +452,29 @@ def is_force_included(path: Path, project_path: Path, include_paths: set) -> boo
return False
def _apply_exclude_patterns_to_prescanned_files(
files: list, project_path: Path, exclude_patterns: list, include_ignored: list
) -> list:
"""Filter a caller-provided (already-scanned) file list by exclude_patterns.
scan_project() applies exclude_patterns itself, but a caller that passes
its own pre-scanned ``files`` list bypasses that -- so callers reusing a
scan (e.g. the init double-scan) still need per-project exclusions
applied here. force_include paths are preserved, matching scan_project's
own precedence.
"""
include_paths = normalize_include_paths(include_ignored)
exclude_matcher = GitignoreMatcher.from_patterns(project_path, exclude_patterns)
if exclude_matcher is None:
return files
return [
file_path
for file_path in files
if is_force_included(file_path, project_path, include_paths)
or exclude_matcher.matches(file_path, is_dir=False) is not True
]
# =============================================================================
# CONFIG
# =============================================================================
@@ -1304,9 +1429,8 @@ def process_file(
if not dry_run and file_already_mined(collection, source_file, check_mtime=True):
return 0, "general", None
try:
content = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
content = _read_text_no_follow(filepath, project_path)
if content is None:
return 0, "general", None
content = content.strip()
@@ -1460,6 +1584,7 @@ def scan_project(
project_dir: str,
respect_gitignore: bool = True,
include_ignored: list = None,
exclude_patterns: list = None,
) -> list:
"""Return list of all readable file paths under ``project_dir``.
@@ -1472,6 +1597,7 @@ def scan_project(
active_matchers = []
matcher_cache = {}
include_paths = normalize_include_paths(include_ignored)
exclude_matcher = GitignoreMatcher.from_patterns(project_path, exclude_patterns or [])
for root, dirs, filenames in os.walk(project_path):
root_path = Path(root)
@@ -1499,6 +1625,13 @@ def scan_project(
if is_force_included(root_path / d, project_path, include_paths)
or not is_gitignored(root_path / d, active_matchers, is_dir=True)
]
if exclude_matcher:
dirs[:] = [
d
for d in dirs
if is_force_included(root_path / d, project_path, include_paths)
or exclude_matcher.matches(root_path / d, is_dir=True) is not True
]
for filename in filenames:
filepath = root_path / filename
@@ -1512,6 +1645,15 @@ def scan_project(
if respect_gitignore and active_matchers and not force_include:
if is_gitignored(filepath, active_matchers, is_dir=False):
continue
# Skip files matching project-level exclude_patterns (mempalace.yaml).
# force_include paths bypass this check so callers can selectively
# re-include files that would otherwise be excluded.
if (
not force_include
and exclude_matcher
and exclude_matcher.matches(filepath, is_dir=False)
):
continue
# Skip symlinks — prevents following links to /dev/urandom, etc.
if filepath.is_symlink():
rel = filepath.relative_to(project_path).as_posix()
@@ -1520,11 +1662,30 @@ def scan_project(
except OSError:
pass
continue
# Skip files exceeding size limit
# Skip files exceeding size limit, or those whose stat() raises
# (permission denied, racing delete, broken symlink that survived
# the earlier is_symlink check). Both branches log to stderr to
# match the SKIP: (symlink) line above; silent drops at this
# gate were the original #923 complaint.
try:
if filepath.stat().st_size > MAX_FILE_SIZE:
file_size = filepath.stat().st_size
if file_size > MAX_FILE_SIZE:
print(
f" SKIP: {filepath.name} ({file_size / (1024 * 1024):.1f} MB)"
f" exceeds {MAX_FILE_SIZE // (1024 * 1024)} MB limit",
file=sys.stderr,
)
continue
except OSError:
except OSError as exc:
# Prefer ``exc.strerror`` so the path isn't duplicated in the
# output (PermissionError stringifies to
# ``[Errno 13] Permission denied: '<path>'`` and the path is
# already in the SKIP prefix). Falls back to the default repr
# when strerror is unset.
print(
f" SKIP: {filepath.name} (stat error: {exc.strerror or exc})",
file=sys.stderr,
)
continue
files.append(filepath)
return files
@@ -1616,15 +1777,19 @@ def _mine_impl(
wing = wing_override or config["wing"]
rooms = config.get("rooms", [{"name": "general", "description": "All project files"}])
exclude_patterns = config.get("exclude_patterns", [])
if files is None:
files = scan_project(
project_dir,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
exclude_patterns=exclude_patterns,
)
elif exclude_patterns:
files = _apply_exclude_patterns_to_prescanned_files(
files, project_path, exclude_patterns, include_ignored
)
if limit > 0:
files = files[:limit]
from .embedding import describe_device
@@ -1633,7 +1798,8 @@ def _mine_impl(
print(f"{'=' * 55}")
print(f" Wing: {wing}")
print(f" Rooms: {', '.join(r['name'] for r in rooms)}")
print(f" Files: {len(files)}")
limit_suffix = f" (limit: {limit} new)" if limit > 0 else ""
print(f" Files: {len(files)}{limit_suffix}")
print(f" Palace: {palace_path}")
print(f" Device: {describe_device()}")
if dry_run:
@@ -1652,6 +1818,7 @@ def _mine_impl(
closets_col = None
total_drawers = 0
files_mined = 0
files_skipped = 0
files_skipped_chunk_cap = 0
files_processed = 0
@@ -1699,16 +1866,22 @@ def _mine_impl(
else:
total_drawers += drawers
room_counts[room] += 1
files_mined += 1
if not dry_run:
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
if limit > 0 and files_mined >= limit:
break
if not dry_run:
from .config import MempalaceConfig
graph_config = MempalaceConfig(palace_path=palace_path)
# Cross-wing topic tunnels: after every file in this wing has been
# processed, link this wing to any other wing that shares a
# confirmed TOPIC label. Out of scope for v1: manifest-dependency
# overlap, per-topic allow/deny lists, search-result surfacing.
try:
tunnels_added = _compute_topic_tunnels_for_wing(wing)
tunnels_added = _compute_topic_tunnels_for_wing(wing, config=graph_config)
if tunnels_added:
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
except Exception as e:
@@ -1724,7 +1897,9 @@ def _mine_impl(
# must never fail a mine; it's a derived analytic, not load-bearing
# for the drawer write that already committed above.
try:
hallways_created = compute_hallways_for_wing(wing, col=collection)
hallways_created = compute_hallways_for_wing(
wing, col=collection, config=graph_config
)
if hallways_created:
print(f"\n Hallways: +{len(hallways_created)} within-wing entity link(s)")
except Exception as e:
@@ -1740,7 +1915,7 @@ def _mine_impl(
# ``kind="entity"`` / ``kind="topic"``. Same fault-tolerance
# pattern: never fail a mine over a derived analytic.
try:
entity_tunnels_added = _compute_entity_tunnels_for_wing(wing)
entity_tunnels_added = _compute_entity_tunnels_for_wing(wing, config=graph_config)
if entity_tunnels_added:
print(f"\n Entity tunnels: +{entity_tunnels_added} cross-wing entity link(s)")
except Exception as e:
@@ -1753,7 +1928,7 @@ def _mine_impl(
print(f"\n{'=' * 55}")
print(" Done.")
print(f" Files processed: {len(files) - files_skipped}")
print(f" Files processed: {files_processed - files_skipped}")
# The residual skip bucket label depends on mode: dry-run bypasses
# the already-mined check, so the only paths producing (0, room,
# None) under dry_run are OSError / too-short / post-lock re-check
@@ -1864,7 +2039,7 @@ def _cleanup_mine_pid_file() -> None:
pass
def _compute_topic_tunnels_for_wing(wing: str) -> int:
def _compute_topic_tunnels_for_wing(wing: str, config=None) -> int:
"""Drop tunnels between ``wing`` and every other wing that shares
confirmed topics, honoring the ``topic_tunnel_min_count`` config knob.
@@ -1877,13 +2052,13 @@ def _compute_topic_tunnels_for_wing(wing: str) -> int:
topics_map = get_topics_by_wing()
if not topics_map or wing not in topics_map:
return 0
cfg = MempalaceConfig()
cfg = config or MempalaceConfig()
min_count = cfg.topic_tunnel_min_count
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count)
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count, config=cfg)
return len(created)
def _compute_entity_tunnels_for_wing(wing: str) -> int:
def _compute_entity_tunnels_for_wing(wing: str, config=None) -> int:
"""Drop tunnels between ``wing`` and every other wing that shares an
entity via the within-wing hallway primitive.
@@ -1901,10 +2076,10 @@ def _compute_entity_tunnels_for_wing(wing: str) -> int:
from .hallways import list_hallways
from .palace_graph import entity_tunnels_for_wing
hallways = list_hallways()
hallways = list_hallways(config=config)
if not hallways:
return 0
created = entity_tunnels_for_wing(wing, hallways)
created = entity_tunnels_for_wing(wing, hallways, config=config)
return len(created)
+217 -8
View File
@@ -9,6 +9,9 @@ Supported:
- Claude Code JSONL (with tool_use/tool_result block capture)
- OpenAI Codex CLI JSONL
- Gemini CLI JSONL (~/.gemini/tmp/<project_hash>/chats/session-*.jsonl)
- Pi agent JSONL
- Gemini CLI / Google AI Studio JSON sessions (contents / messages / flat list)
- Continue.dev session JSON (~/.continue/sessions/*.json)
- Slack JSON export
- Plain text (pass through for paragraph chunking)
@@ -18,6 +21,7 @@ No API key. No internet. Everything local.
import json
import os
import re
import stat
from pathlib import Path
from typing import Optional
@@ -115,17 +119,28 @@ def normalize(filepath: str) -> str:
Load a file and normalize to transcript format if it's a chat export.
Plain text files pass through unchanged.
"""
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
if os.path.islink(filepath):
raise IOError(f"Could not read {filepath}: symlinked files are skipped")
fd = -1
try:
file_size = os.path.getsize(filepath)
except OSError as e:
raise IOError(f"Could not read {filepath}: {e}") from e
if file_size > 500 * 1024 * 1024: # 500 MB safety limit
raise IOError(f"File too large ({file_size // (1024 * 1024)} MB): {filepath}")
try:
with open(filepath, "r", encoding="utf-8-sig", errors="replace") as f:
fd = os.open(filepath, flags)
file_stat = os.fstat(fd)
if not stat.S_ISREG(file_stat.st_mode):
raise IOError(f"Could not read {filepath}: not a regular file")
if file_stat.st_size > 500 * 1024 * 1024: # 500 MB safety limit
raise IOError(f"File too large ({file_stat.st_size // (1024 * 1024)} MB): {filepath}")
with os.fdopen(fd, "r", encoding="utf-8-sig", errors="replace") as f:
fd = -1
content = f.read()
except OSError as e:
raise IOError(f"Could not read {filepath}: {e}") from e
finally:
if fd != -1:
try:
os.close(fd)
except OSError:
pass
if not content.strip():
return content
@@ -162,12 +177,22 @@ def _try_normalize_json(content: str) -> Optional[str]:
if normalized:
return normalized
normalized = _try_pi_jsonl(content)
if normalized:
return normalized
try:
data = json.loads(content)
except json.JSONDecodeError:
return None
for parser in (_try_claude_ai_json, _try_chatgpt_json, _try_slack_json):
for parser in (
_try_gemini_json,
_try_claude_ai_json,
_try_chatgpt_json,
_try_continue_json,
_try_slack_json,
):
normalized = parser(data)
if normalized:
return normalized
@@ -353,6 +378,135 @@ def _try_gemini_jsonl(content: str) -> Optional[str]:
return None
def _try_pi_jsonl(content: str) -> Optional[str]:
"""Pi agent sessions (~/.config/pi/agent/sessions/{cwd}/{timestamp}_{uuid}.jsonl).
Pi stores sessions as JSONL with a tree-structured message history.
User messages have role "user" with content as string or [{type, text}] blocks.
Assistant messages have role "assistant" with content as [{type, text}] blocks
(may also include "thinking" blocks which are skipped by _extract_content).
Tool results (role "toolResult") are skipped — operational, not conversation.
Format documented at github.com/badlogic/pi-mono session.md.
"""
lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
messages = []
has_session_header = False
for line in lines:
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
entry_type = entry.get("type", "")
if entry_type == "session" and "version" in entry:
has_session_header = True
continue
if entry_type != "message":
continue
message = entry.get("message", {})
if not isinstance(message, dict):
continue
role = message.get("role", "")
text = _extract_content(message.get("content", ""))
if role == "user" and text:
messages.append(("user", text))
elif role == "assistant" and text:
messages.append(("assistant", text))
if len(messages) >= 2 and has_session_header:
return _messages_to_transcript(messages)
return None
def _try_gemini_json(data) -> Optional[str]:
"""Gemini CLI / Google AI Studio JSON sessions.
Handles three layouts:
1. **Gemini API contents format** — used by Gemini CLI session files
(``~/.gemini/sessions/*.json``):
``{"contents": [{"role": "user", "parts": [{"text": "..."}]}, ...]}``
2. **Messages wrapper** — exports that wrap the conversation under a
``messages`` key:
``{"messages": [{"role": "user", "content": "..."}, {"role": "model", "content": "..."}]}``
3. **Flat messages list** — top-level array form:
``[{"role": "user", "content": "..."}, {"role": "model", "content": "..."}]``
Gemini uses ``"model"`` as the assistant role (not ``"assistant"``).
Detection requires at least one ``role="model"`` entry to disambiguate
from Claude/ChatGPT exports that use ``"assistant"``. This parser is
placed *before* ``_try_claude_ai_json`` in the dispatch chain so that
the layout-2 ``{"messages": [...]}`` wrapper does not get silently
claimed by the Claude parser, which would drop the model turns.
"""
contents = None
# Layout 1: {"contents": [...]}
if isinstance(data, dict) and "contents" in data:
contents = data["contents"]
# Layout 2a: {"messages": [...]}
elif isinstance(data, dict) and "messages" in data:
contents = data["messages"]
# Layout 2b: top-level list
elif isinstance(data, list):
contents = data
if not isinstance(contents, list) or len(contents) < 2:
return None
messages = []
has_model_role = False
for item in contents:
if not isinstance(item, dict):
continue
role = item.get("role", "")
# Extract text — try "parts" first (Gemini API), then "content" (flat).
text = ""
parts = item.get("parts")
if isinstance(parts, list):
text_parts = []
for p in parts:
if isinstance(p, str):
text_parts.append(p)
elif isinstance(p, dict) and "text" in p:
text_parts.append(p["text"])
text = " ".join(text_parts).strip()
else:
text = _extract_content(item.get("content", ""))
if not text:
continue
if role == "user":
messages.append(("user", text))
elif role == "model":
messages.append(("assistant", text))
has_model_role = True
elif role == "assistant":
# Defensive: some hand-crafted exports use "assistant" even
# for Gemini sessions. Accept but don't flip has_model_role.
messages.append(("assistant", text))
# Disambiguator: must have seen at least one role="model" entry.
# This prevents the Gemini parser from claiming Claude/ChatGPT data.
if not has_model_role:
return None
if len(messages) >= 2:
return _messages_to_transcript(messages)
return None
def _try_claude_ai_json(data) -> Optional[str]:
"""Claude.ai JSON export: flat messages list or privacy export with chat_messages."""
if isinstance(data, dict):
@@ -485,6 +639,61 @@ def _try_slack_json(data) -> Optional[str]:
return None
def _try_continue_json(data) -> Optional[str]:
"""Continue.dev session JSON (~/.continue/sessions/*.json).
Sessions contain a ``history`` array of ``{role, content}`` message objects,
plus optional metadata (``title``, ``sessionId``, ``dateCreated``).
System messages are skipped. Tool-call messages (role ``tool``) are
formatted inline when they contain text content.
"""
if not isinstance(data, dict) or "history" not in data:
return None
history = data["history"]
if not isinstance(history, list):
return None
messages = []
for item in history:
if not isinstance(item, dict):
continue
role = item.get("role", "")
content = item.get("content", "")
# Extract text from string or list-of-blocks content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
parts.append(block.get("text", ""))
elif isinstance(block, str):
parts.append(block)
text = "\n".join(p for p in parts if p).strip()
elif isinstance(content, str):
text = content.strip()
else:
continue
if not text:
continue
if role == "user":
messages.append(("user", text))
elif role == "assistant":
messages.append(("assistant", text))
elif role == "tool":
# Append tool output to the previous assistant turn if possible
if messages and messages[-1][0] == "assistant":
prev_role, prev_text = messages[-1]
messages[-1] = (prev_role, prev_text + "\n" + f"[tool] {text}")
# Skip system and other roles
if len(messages) >= 2:
return _messages_to_transcript(messages)
return None
def _extract_content(content, tool_use_map: dict = None) -> str:
"""Pull text from content — handles str, list of blocks, or dict.
+445 -96
View File
@@ -70,13 +70,109 @@ _EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
NORMALIZE_VERSION = 2
# (palace_id, collection_name, model_name) tuples already validated this
# process, so the identity check (one metadata read) runs at most once per
# collection per run — keeps the hot get_collection path cheap.
_VALIDATED_IDENTITY: set = set()
def _enforce_embedder_identity(collection, palace_path, collection_name, *, create) -> None:
"""Check (and, for a brand-new collection, record) embedder identity (RFC 001).
Check at open so a model swap fails fast — before any query silently
returns degraded results. Record only when the collection is brand-new and
empty: recording the *current* model on a legacy palace that already holds
vectors from an unknown model would mislabel it, so populated-but-unrecorded
collections warn instead and are resolved with
``mempalace palace set-embedder``.
Bookkeeping must never break memory operations: only the deliberate
identity/dimension mismatch propagates; every other error is swallowed.
"""
import warnings
from .backends.base import (
DimensionMismatchError,
EmbedderIdentity,
EmbedderIdentityMismatchError,
EmbedderIdentityUnknownWarning,
check_embedder_identity,
)
from .embedding import current_model_name
# A server_embedder backend embeds with its own model and ignores the
# injected/core embedder, so its effective identity — not the configured
# model — is what must be checked and recorded. Fall back to the configured
# model name for the normal (core-embedder) case.
current: Optional[EmbedderIdentity] = None
try:
effective = collection.effective_embedder_identity()
except Exception:
effective = None
if effective is not None and getattr(effective, "model_name", ""):
current = effective
else:
try:
model_name = current_model_name()
except Exception:
return
if not model_name:
return # nameless embedder — cannot enforce identity
current = EmbedderIdentity(model_name=model_name, dimension=0)
model_name = current.model_name
key = (str(palace_path), str(collection_name), model_name)
if key in _VALIDATED_IDENTITY:
return
try:
stored = collection.get_stored_embedder_identity()
except Exception:
logger.debug("embedder-identity read failed for %s", collection_name, exc_info=True)
return
try:
state = check_embedder_identity(stored, current)
except (EmbedderIdentityMismatchError, DimensionMismatchError):
raise # deliberate, user-facing — the whole point of the contract
except Exception:
return
if state == "unknown" and stored is None:
try:
count = collection.count()
except Exception:
count = None
if count == 0:
if create:
try:
collection.set_embedder_identity(current)
except Exception:
logger.debug("embedder-identity record failed", exc_info=True)
elif count:
warnings.warn(
f"palace collection {collection_name!r} has no recorded embedder "
f"identity; assuming the current model {model_name!r}. Run "
"`mempalace palace set-embedder --model <name>` to record it.",
EmbedderIdentityUnknownWarning,
stacklevel=2,
)
_VALIDATED_IDENTITY.add(key)
def get_collection(
palace_path: str,
collection_name: Optional[str] = None,
create: bool = True,
backend: Optional[str] = None,
_skip_identity_check: bool = False,
):
"""Get the palace collection through the backend layer."""
"""Get the palace collection through the backend layer.
``_skip_identity_check`` bypasses the embedder-identity enforcement so the
``set-embedder`` override path can open a palace whose recorded model
differs from the current one (the very state it exists to repair).
"""
if collection_name is None:
from .config import get_configured_collection_name
@@ -98,10 +194,71 @@ def get_collection(
create=create,
)
if "requires_explicit_embeddings" in getattr(backend_obj, "capabilities", frozenset()):
return EmbeddingCollection(collection)
collection = EmbeddingCollection(collection)
if not _skip_identity_check:
_enforce_embedder_identity(collection, palace_path, collection_name, create=create)
return collection
def set_palace_embedder_identity(
palace_path: str,
model: Optional[str] = None,
*,
force: bool = False,
backend: Optional[str] = None,
collection_name: Optional[str] = None,
):
"""Record (or force-override) a palace collection's embedder identity (RFC 001).
Backs ``mempalace palace set-embedder``. Returns ``(old, new)`` identities.
Without ``force``, refuses to overwrite an existing identity that names a
different model (the user must confirm they know the vectors are
compatible). Opens with the identity check skipped so a mismatched palace —
the exact state being repaired — can be opened at all.
"""
from .backends.base import EmbedderIdentity, EmbedderIdentityMismatchError
from .config import MempalaceConfig
from .embedding import get_embedder_identity
configured = MempalaceConfig().embedding_model
target = (model or configured or "").strip().lower()
if not target:
# No model given and none configured — there is nothing to record, and
# recording a nameless identity is a silent no-op in every backend.
raise ValueError(
"no embedder model to record: pass --model NAME or configure MEMPALACE_EMBEDDING_MODEL"
)
if target == (configured or "").strip().lower():
# Recording the in-use model — probe its dimension (already loaded).
new = get_embedder_identity()
else:
# Explicit override of a non-configured model: record the name only,
# never load a foreign model (which can be a large download) just to
# probe a dimension. The model-name check is the actual protection.
new = EmbedderIdentity(model_name=target, dimension=0)
collection = get_collection(
palace_path,
collection_name=collection_name,
create=True,
backend=backend,
_skip_identity_check=True,
)
try:
old = collection.get_stored_embedder_identity()
except Exception:
old = None
if old is not None and old.model_name != new.model_name and not force:
raise EmbedderIdentityMismatchError(
f"palace already records embedder {old.model_name!r}; pass --force to "
f"overwrite it with {new.model_name!r} (only if the vectors are compatible)"
)
collection.set_embedder_identity(new)
# Reset the per-process validation cache so a re-open re-checks against the
# newly recorded identity rather than a stale verdict.
_VALIDATED_IDENTITY.clear()
return old, new
def get_closets_collection(
palace_path: str,
create: bool = True,
@@ -568,38 +725,185 @@ def mine_lock(source_file: str):
Prevents multiple agents from mining the same file simultaneously,
which causes duplicate drawers when the delete+insert cycle interleaves.
"""
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
lock_path = os.path.join(
lock_dir, hashlib.sha256(source_file.encode()).hexdigest()[:16] + ".lock"
)
lf = open(lock_path, "w")
lock_path = _mine_lock_path(source_file)
lf = _acquire_mine_lock_file(lock_path)
try:
if os.name == "nt":
import msvcrt
msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(lf, fcntl.LOCK_EX)
yield
finally:
try:
if os.name == "nt":
import msvcrt
msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(lf, fcntl.LOCK_UN)
_unlock_mine_lock_file(lf)
except Exception:
logger.debug("Mine-lock release failed", exc_info=True)
try:
lf.close()
except Exception:
logger.debug("Mine-lock close failed", exc_info=True)
_cleanup_mine_lock_file(lock_path)
def _mine_lock_path(source_file: str) -> str:
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
return os.path.join(lock_dir, hashlib.sha256(source_file.encode()).hexdigest()[:16] + ".lock")
def _open_mine_lock_file(lock_path: str, *, create: bool):
flags = os.O_RDWR
if create:
flags |= os.O_CREAT
fd = os.open(lock_path, flags, 0o600)
return os.fdopen(fd, "r+b")
def _lock_mine_lock_file(lock_file, *, blocking: bool) -> bool:
lock_file.seek(0)
if os.name == "nt":
import msvcrt
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
try:
msvcrt.locking(lock_file.fileno(), mode, 1)
except OSError:
if not blocking:
return False
raise
return True
import fcntl
flags = fcntl.LOCK_EX
if not blocking:
flags |= fcntl.LOCK_NB
try:
fcntl.flock(lock_file, flags)
except BlockingIOError:
if not blocking:
return False
raise
return True
def _unlock_mine_lock_file(lock_file) -> None:
lock_file.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(lock_file, fcntl.LOCK_UN)
def _mine_lock_file_is_current(lock_file, lock_path: str) -> bool:
"""Return whether ``lock_file`` is still the inode reached by ``lock_path``.
POSIX advisory locks attach to the opened inode, not the pathname. If a
lock file is unlinked while a contender is waiting, that contender can later
acquire a lock on an inode no new process will use. We reject that stale
handle and retry on the current pathname.
"""
if os.name == "nt":
return True
try:
path_stat = os.stat(lock_path)
file_stat = os.fstat(lock_file.fileno())
except OSError:
return False
return (path_stat.st_dev, path_stat.st_ino) == (file_stat.st_dev, file_stat.st_ino)
def _acquire_open_mine_lock_file(lock_file, lock_path: str) -> bool:
"""Acquire ``lock_file`` and return False if cleanup made it stale."""
_lock_mine_lock_file(lock_file, blocking=True)
if _mine_lock_file_is_current(lock_file, lock_path):
return True
try:
_unlock_mine_lock_file(lock_file)
except Exception:
logger.debug("Mine-lock stale-handle release failed", exc_info=True)
return False
def _acquire_mine_lock_file(lock_path: str):
while True:
lf = _open_mine_lock_file(lock_path, create=True)
try:
if _acquire_open_mine_lock_file(lf, lock_path):
return lf
except Exception:
lf.close()
raise
lf.close()
def _cleanup_mine_lock_file(lock_path: str) -> None:
"""Best-effort removal that preserves flock rendezvous semantics.
A plain ``os.remove(lock_path)`` after closing the critical-section lock is
unsafe on POSIX: a waiter may already be blocked on the old inode while a
later process creates and locks a new inode at the same pathname. Instead,
cleanup briefly re-acquires the current file nonblocking. If it wins, it can
unlink that inode as cleanup-only work; waiters on the old inode will detect
the stale handle after waking and retry on the current path.
"""
try:
lf = _open_mine_lock_file(lock_path, create=False)
except FileNotFoundError:
return
except OSError:
logger.debug("Mine-lock cleanup open failed for %s", lock_path, exc_info=True)
return
acquired = False
closed = False
try:
try:
acquired = _lock_mine_lock_file(lf, blocking=False)
except OSError:
logger.debug("Mine-lock cleanup acquire failed for %s", lock_path, exc_info=True)
return
if not acquired:
return
if not _mine_lock_file_is_current(lf, lock_path):
return
if os.name == "nt":
# Windows generally cannot unlink an open locked file. Release and
# close first; if another process opens the file in the gap,
# os.remove should fail and we leave the rendezvous file in place.
try:
_unlock_mine_lock_file(lf)
except Exception:
logger.debug("Mine-lock cleanup release failed", exc_info=True)
acquired = False
return
acquired = False
lf.close()
closed = True
try:
os.remove(lock_path)
except OSError:
pass
return
try:
os.remove(lock_path)
except FileNotFoundError:
pass
except OSError:
logger.debug("Mine-lock cleanup remove failed for %s", lock_path, exc_info=True)
finally:
if not closed:
if acquired:
try:
_unlock_mine_lock_file(lf)
except Exception:
logger.debug("Mine-lock cleanup release failed", exc_info=True)
lf.close()
class MineAlreadyRunning(RuntimeError):
"""Raised when another `mempalace mine` already holds the per-palace lock."""
@@ -624,12 +928,19 @@ def _validate_palace_fts5_after_mine(palace_path: str) -> None:
Reuses the same primitive that `cmd_repair` already runs as preflight so the
operator sees the same recovery banner regardless of which command surfaces
the bug.
An isolated FTS5 inverted-index corruption (the common case after a
killed-mid-write mine, #1596) is auto-healed in place first via the same
`maybe_autoheal_fts5_index` rebuild `cmd_repair` already runs as its own
preflight step — so a normal `mine` self-heals the recoverable case
instead of forcing the operator to run `mempalace repair` by hand for a
derived index that regenerates from intact content.
"""
if resolve_backend_name(palace_path) != "chroma":
return
# Defer-import: keeps the repair module graph out of mine's hot import path.
from .repair import _close_chroma_handles, sqlite_integrity_errors
from .repair import _close_chroma_handles, maybe_autoheal_fts5_index, sqlite_integrity_errors
# Pass the live singleton so the writer's cached PersistentClient actually
# gets closed and WAL flushes before the read-only sqlite3 re-open.
@@ -639,49 +950,89 @@ def _validate_palace_fts5_after_mine(palace_path: str) -> None:
_close_chroma_handles(palace_path, backend=_DEFAULT_BACKEND)
errors = sqlite_integrity_errors(palace_path)
if errors:
# progress=logger.info, not the default print: this runs inside the
# MCP server process too (mcp_server.tool_mine -> miner.mine), where
# stdout is the JSON-RPC transport -- a stray print() here would
# corrupt the protocol stream and crash the connection.
errors = maybe_autoheal_fts5_index(palace_path, errors, progress=logger.info)
if errors:
raise MineValidationError(palace_path, errors)
# Per-thread record of palaces this thread already holds the lock for. Used by
# `mine_palace_lock` to short-circuit re-entrant acquisition from the same
# thread (e.g. miner.mine() acquires the outer lock then calls
# Process-wide record of palaces this PROCESS already holds the lock for. Used
# by `mine_palace_lock` to short-circuit re-entrant acquisition from the same
# process (e.g. miner.mine() acquires the outer lock then calls
# ChromaCollection.upsert which now also tries to acquire). Without this guard
# the inner call would block on its own outer flock (Linux fcntl locks are per
# open file description, so a same-thread second open of the lock file is a
# distinct lock and self-deadlocks).
# open file description, so a second open of the lock file from the same process
# is a distinct lock and self-conflicts / EWOULDBLOCKs).
#
# The holder set is tagged with ``pid`` so that a forked child does NOT
# inherit re-entrant credit from its parent: the OS-level flock IS NOT
# inherited as a "we hold it" semantically — the child must reacquire — but
# Python's ``threading.local`` IS inherited across fork. The pid check
# clears stale state so a forked child correctly hits the fcntl path.
_palace_lock_holders = threading.local()
# This MUST be process-wide, not thread-local: the MCP HTTP transport
# (ThreadingHTTPServer) acquires the long-lived writer-lease on one thread
# (`mcp_server._acquire_mcp_writer_lock`) but dispatches each write request on a
# different worker thread. A thread-local guard makes those handlers fail to see
# the process-held lease, re-acquire the flock, and self-conflict
# ("palace ... is held by PID <self>"). flock is per-process and HTTP writes are
# serialized by `_HTTP_REQUEST_LOCK`, so the process is the correct re-entrancy
# boundary.
#
# The holder set is tagged with ``pid`` so that a forked child does NOT inherit
# re-entrant credit from its parent: the OS-level flock IS NOT inherited as a
# "we hold it" semantically — the child must reacquire. The pid check clears
# stale state so a forked child correctly hits the fcntl path. Access is guarded
# by ``_palace_lock_guard`` because the set is now shared across threads.
#
# Fork safety: ``_palace_lock_guard`` is a real ``threading.Lock``, so a child
# forked while another thread held it would inherit it locked (the holder thread
# does not exist in the child) and deadlock on the next acquire. An at-fork
# handler (registered below) replaces the guard with a fresh unlocked lock and
# clears state in the child, which must reacquire the flock anyway.
_palace_lock_guard = threading.Lock()
_palace_lock_pid = None
_palace_lock_keys = set()
def _holder_state():
"""Return the per-thread (pid, keys) record, refreshing after fork."""
keys = getattr(_palace_lock_holders, "keys", None)
pid = getattr(_palace_lock_holders, "pid", None)
def _reset_palace_lock_state_after_fork() -> None:
"""Reset lock state in a forked child to avoid an inherited-locked deadlock."""
global _palace_lock_guard, _palace_lock_pid, _palace_lock_keys
_palace_lock_guard = threading.Lock()
_palace_lock_keys = set()
_palace_lock_pid = os.getpid()
# Availability: Unix (no-op elsewhere — Windows has no fork()).
if hasattr(os, "register_at_fork"):
os.register_at_fork(after_in_child=_reset_palace_lock_state_after_fork)
def _holder_keys_locked():
"""Return the process-wide held-key set, refreshing after fork.
Caller MUST hold ``_palace_lock_guard``.
"""
global _palace_lock_pid, _palace_lock_keys
current_pid = os.getpid()
if keys is None or pid != current_pid:
keys = set()
_palace_lock_holders.keys = keys
_palace_lock_holders.pid = current_pid
return keys
if _palace_lock_pid != current_pid:
_palace_lock_keys = set()
_palace_lock_pid = current_pid
return _palace_lock_keys
def _held_by_this_thread(lock_key: str) -> bool:
"""Return True if this thread already holds ``mine_palace_lock`` for ``lock_key``."""
return lock_key in _holder_state()
def _held_by_this_process(lock_key: str) -> bool:
"""Return True if this process already holds ``mine_palace_lock`` for ``lock_key``."""
with _palace_lock_guard:
return lock_key in _holder_keys_locked()
def _mark_held(lock_key: str) -> None:
_holder_state().add(lock_key)
with _palace_lock_guard:
_holder_keys_locked().add(lock_key)
def _mark_released(lock_key: str) -> None:
_holder_state().discard(lock_key)
with _palace_lock_guard:
_holder_keys_locked().discard(lock_key)
def _format_lock_holder(content: str) -> str:
@@ -760,11 +1111,13 @@ def mine_palace_lock(palace_path: str):
raise MineAlreadyRunning so the caller can exit cleanly instead of
piling up as a waiting worker.
Re-entrant: if the current thread already holds the lock for the same
Re-entrant: if the current process already holds the lock for the same
palace, the context manager passes through without re-acquiring. This
lets ChromaCollection write methods (which acquire the lock themselves
to protect MCP/direct callers) compose with miner.mine() (which holds
the outer lock for the entire mine pipeline) without self-deadlock.
the outer lock for the entire mine pipeline) without self-deadlock, and
lets the threaded MCP HTTP transport write from a worker thread while the
long-lived writer-lease is held on another thread of the same process.
"""
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
@@ -773,8 +1126,8 @@ def mine_palace_lock(palace_path: str):
palace_key = hashlib.sha256(lock_key_source.encode()).hexdigest()[:16]
lock_path = os.path.join(lock_dir, f"mine_palace_{palace_key}.lock")
if _held_by_this_thread(palace_key):
# Same thread already holds the lock for this palace — pass through.
if _held_by_this_process(palace_key):
# This process already holds the lock for this palace — pass through.
yield
return
@@ -825,8 +1178,14 @@ def mine_palace_lock(palace_path: str):
) from exc
# Record our own identity for any later contender's diagnostic message.
_write_lock_holder(lf)
_mark_held(palace_key)
# Mark the hold from inside the try so it always pairs with
# _mark_released. If it sat before the try, an async exception
# (a SIGINT/KeyboardInterrupt) landing in the gap would orphan
# palace_key in the holder set while the outer finally frees the
# flock, so the in-memory hold would outlive the OS lock and a later
# re-entrant acquire would pass through and write without the flock.
try:
_mark_held(palace_key)
yield
finally:
_mark_released(palace_key)
@@ -875,9 +1234,15 @@ def file_already_mined(
schema (triggers silent rebuild after a normalization upgrade)
- `check_mtime=True` and the file's mtime differs from the stored one
When check_mtime=True (used by project miner), also re-mines on content
change. When check_mtime=False (used by convo miner), transcripts are
assumed immutable, so only the version gate triggers a rebuild.
When check_mtime=True (used by the project miner, and by the convo
miner's in-lock recheck), also re-mines on content change. Conversation
transcripts are NOT assumed immutable: a Claude Code session keeps
appending to its own file while active, and /compact or /clear can
rewrite one in place. The convo miner's bulk skip-check uses
prefetch_mined_set()'s stored mtimes instead of calling this function
per file (same mtime-aware decision, without the O(n) per-file query
cost); this function's check_mtime=True path remains its per-file,
lock-held race-condition recheck.
When extract_mode is set (used by convo miner), idempotency is scoped to
that extraction mode so exchange-mode and general-mode drawers can coexist
@@ -934,42 +1299,25 @@ def file_already_mined(
return False
def bulk_check_mined(collection) -> dict[str, float]:
"""Pre-fetch source_file/source_mtime pairs for all documents in the collection.
def prefetch_mined_set(
collection, extract_mode: Optional[str] = None
) -> dict[str, Optional[float]]:
"""Pre-fetch source_file -> stored source_mtime for files already mined
at the current NORMALIZE_VERSION, in one bulk pass instead of one
ChromaDB query per file.
Returns a dict mapping source_file -> source_mtime (as float) for every
document that has both fields. Callers can check membership and compare
mtimes locally instead of issuing one ChromaDB query per file.
Fetches the full collection in paginated batches (like palace_graph.py)
since a WHERE-IN filter on thousands of paths is not supported by ChromaDB.
"""
mined: dict[str, float] = {}
try:
total = collection.count()
offset = 0
while offset < total:
batch = collection.get(limit=1000, offset=offset, include=["metadatas"])
for meta in batch["metadatas"]:
src = meta.get("source_file")
mtime = meta.get("source_mtime")
if src and mtime is not None:
mined[src] = float(mtime)
if not batch["ids"]:
break
offset += len(batch["ids"])
except Exception:
logger.warning("bulk_check_mined: partial fetch, %d files loaded", len(mined))
return mined
def prefetch_mined_set(collection, extract_mode: Optional[str] = None) -> set[str]:
"""Pre-fetch the set of source_files already mined at the current NORMALIZE_VERSION.
Mirrors file_already_mined()'s version-gate semantics (check_mtime=False
branch) but in one bulk pass instead of one ChromaDB query per file.
Returns a set of source_file paths whose stored drawers are at or above
NORMALIZE_VERSION; callers do `if path in result_set: skip`.
Return type is a dict rather than a bare set so callers get mtime
awareness "for free": conversation transcripts are not immutable once
mined (a Claude Code session keeps appending to the same file while
active, and /compact or /clear can rewrite one in place), so "we've
seen this source_file before" is not suffient to skip it -- the caller
must also confirm its current on-disk mtime still matches what was
stored. `if src in mined_set` still means the same thing as the old
set-based return (dict `in` checks keys); a caller that wants staleness
detection reads `mined_set[src]` and compares against
os.path.getmtime(src) itself. `None` means either no mtime was ever
stored (drawers written before this field existed) or getmtime failed
when the drawer was written -- both should be treated as stale.
When extract_mode is set, mirrors file_already_mined(..., extract_mode=...)
so conversation mines skip per extraction mode rather than per source file.
@@ -979,7 +1327,7 @@ def prefetch_mined_set(collection, extract_mode: Optional[str] = None) -> set[st
palace, making a 2000-file sweep take >1h of pure skip-checking. This
helper drops that to a single paginated scan plus O(1) lookups.
"""
mined: set[str] = set()
mined: dict[str, Optional[float]] = {}
try:
total = collection.count()
offset = 0
@@ -995,7 +1343,8 @@ def prefetch_mined_set(collection, extract_mode: Optional[str] = None) -> set[st
# Same default as file_already_mined: missing version == 1
version = meta.get("normalize_version", 1)
if version >= NORMALIZE_VERSION:
mined.add(src)
stored_mtime = meta.get("source_mtime")
mined[src] = float(stored_mtime) if stored_mtime is not None else None
if not batch["ids"]:
break
offset += len(batch["ids"])
+11 -1
View File
@@ -494,6 +494,7 @@ def create_tunnel(
source_drawer_id: str = None,
target_drawer_id: str = None,
kind: str = "explicit",
config=None,
):
"""Create an explicit (symmetric) tunnel between two locations in the palace.
@@ -520,6 +521,9 @@ def create_tunnel(
topical link where rooms are synthetic ``topic:<name>``
identifiers). Preserved on the stored dict so readers can
distinguish real-room traversals from topic connections.
config: Optional ``MempalaceConfig`` selecting the palace and its
tunnel sidecar. Explicit-path callers must pass the matching
config instead of falling back to the ambient default palace.
Returns:
The stored tunnel dict.
@@ -545,7 +549,7 @@ def create_tunnel(
# mempalace.yaml from disk; before this change the helpers each
# instantiated their own, triggering several redundant disk reads per
# create_tunnel call (flagged by gemini-code-assist on #1469).
config = MempalaceConfig()
config = config or MempalaceConfig()
# Validate room existence for explicit tunnels only. Use the verbatim wing
# slugs here so #1504's hyphen-preserving write path remains intact.
@@ -742,6 +746,7 @@ def compute_topic_tunnels(
topics_by_wing: dict,
min_count: int = 1,
label_prefix: str = "shared topic",
config=None,
) -> list[dict]:
"""Create tunnels for every pair of wings that share >= ``min_count`` topics.
@@ -817,6 +822,7 @@ def compute_topic_tunnels(
target_room=room,
label=f"{label_prefix}: {topic_name}",
kind="topic",
config=config,
)
created.append(tunnel)
return created
@@ -827,6 +833,7 @@ def topic_tunnels_for_wing(
topics_by_wing: dict,
min_count: int = 1,
label_prefix: str = "shared topic",
config=None,
) -> list[dict]:
"""Compute topic tunnels involving a single wing.
@@ -871,6 +878,7 @@ def topic_tunnels_for_wing(
slice_map,
min_count=min_count,
label_prefix=label_prefix,
config=config,
)
)
return created
@@ -880,6 +888,7 @@ def entity_tunnels_for_wing(
wing: str,
hallways: list,
label_prefix: str = "shared entity",
config=None,
) -> list:
"""Compute entity tunnels involving a single wing.
@@ -946,6 +955,7 @@ def entity_tunnels_for_wing(
target_room=room,
label=f"{label_prefix}: {entity}",
kind="entity",
config=config,
)
created.append(tunnel)
return created

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