240 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
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
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
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
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
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
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
136 changed files with 20540 additions and 784 deletions
+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 33 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 33 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. 33 MCP tools, auto-save hooks, guided setup.",
"version": "3.4.1",
"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.1",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 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"
},
@@ -53,6 +53,13 @@ a variable, fixing a typo). Recall is question-driven, not reflexive.
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.
+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 33 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.1",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 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 33 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": [
+2 -2
View File
@@ -1,6 +1,6 @@
# MemPalace Cursor Plugin
A Cursor IDE plugin that gives your agent a persistent memory system. Auto-registers the `mempalace-mcp` server (33 MCP tools), ships 5 slash commands, two model-invocable skills (setup/mining/search and a recall protocol), and an optional recall rule.
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.
@@ -87,7 +87,7 @@ This plugin ships `mcp.json` at the plugin root, so Cursor auto-loads the `mempa
}
```
All 33 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.
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`:
+1 -1
View File
@@ -8,7 +8,7 @@
{
"name": "mempalace",
"source": ".",
"description": "AI memory system — mine projects and conversations into a searchable palace. 33 MCP tools, slash commands, and a guided skill for Cursor.",
"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"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 33 MCP tools, slash commands, and a guided skill for Cursor.",
"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"
},
+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
+117 -1
View File
@@ -10,6 +10,120 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [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
@@ -516,7 +630,9 @@ Initial public release.
---
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.4.1...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
+17 -33
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
@@ -225,7 +209,7 @@ Usage and tool reference:
## MCP server
33 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).
@@ -285,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.1-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/
+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.
+22 -1
View File
@@ -18,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.
@@ -37,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",
@@ -48,9 +56,15 @@ 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)
@@ -90,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:
+6 -8
View File
@@ -163,14 +163,12 @@ _mempal_build_followup() {
import json, sys
wing = sys.argv[1] if len(sys.argv) > 1 else "cursor_session"
msg = (
"MemPalace save checkpoint. "
"(1) Call mempalace_check_duplicate on the key topics, decisions, "
"and verbatim quotes from this session. "
"(2) For each non-duplicate, call mempalace_add_drawer (wing="
+ wing + ", room=<short topic>, content=verbatim quote). "
"(3) Call mempalace_diary_write (agent_name=cursor-ide, wing="
+ wing + ", entry=AAAK-format summary). "
"Then stop."
"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"
+2 -2
View File
@@ -182,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 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
+2 -2
View File
@@ -265,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
+30
View File
@@ -67,10 +67,40 @@ question — not a system prompt or pasted conversation) plus optional
- **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
+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 — 33 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 |
+3
View File
@@ -36,6 +36,7 @@ from .base import (
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
@@ -67,6 +68,8 @@ __all__ = [
"LexicalHit",
"LexicalResult",
"MaintenanceResult",
"MilvusBackend",
"MilvusCollection",
"PalaceNotFoundError",
"PalaceRef",
"PgVectorBackend",
+42
View File
@@ -468,6 +468,48 @@ class BaseCollection(ABC):
"""
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.
+184 -39
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,7 @@ 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,
@@ -79,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")
@@ -98,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."""
@@ -334,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]:
@@ -457,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(
"""
@@ -606,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:
@@ -626,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(
@@ -649,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.
@@ -693,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.
@@ -715,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"
@@ -746,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(
"""
@@ -807,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).
@@ -1261,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
@@ -1275,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] = {
@@ -1570,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)
@@ -1846,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"
@@ -1853,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)."
)
# ------------------------------------------------------------------
@@ -2113,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.
+24
View File
@@ -155,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
+151 -15
View File
@@ -37,6 +37,7 @@ 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,
@@ -80,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 []
@@ -581,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(),
)
@@ -625,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)
@@ -792,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,
@@ -1017,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 [],
+99 -2
View File
@@ -40,6 +40,7 @@ from .base import (
PalaceNotFoundError,
PalaceRef,
QueryResult,
UnsupportedCapabilityError,
UnsupportedFilterError,
_IncludeSpec,
)
@@ -53,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"}
)
@@ -480,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]:
@@ -529,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='')}")
@@ -732,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,
)
@@ -1000,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():
@@ -1090,6 +1186,7 @@ class QdrantBackend(BaseBackend):
"supports_embeddings_out",
"supports_metadata_filters",
"supports_lexical_search",
"supports_metadata_facets",
"supports_namespace_isolation",
"server_mode",
}
+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:
+66 -19
View File
@@ -496,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)
@@ -603,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 [],
@@ -1014,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)
+469 -11
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
@@ -914,11 +1116,17 @@ def cmd_repair(args):
_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
@@ -934,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 = (
@@ -972,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
@@ -1000,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)
@@ -1027,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:
@@ -1154,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
@@ -1480,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",
@@ -1495,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)."
),
@@ -1543,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")
@@ -1605,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(
@@ -1635,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",
@@ -1705,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",
@@ -1716,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(
@@ -1743,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",
@@ -1806,6 +2255,13 @@ def main():
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,
@@ -1814,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)
+257 -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,13 @@ 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
@@ -205,6 +236,30 @@ DEFAULT_BACKEND = "chroma"
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)
def get_configured_collection_name() -> str:
"""Return the configured drawer collection name without repeated config-file reads."""
@@ -295,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():
@@ -319,13 +382,15 @@ 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):
@@ -406,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.
@@ -645,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``.
@@ -749,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:
+228 -50
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
@@ -609,13 +764,14 @@ 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
@@ -628,8 +784,19 @@ def _mine_convos_impl(
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
@@ -697,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
@@ -712,6 +886,10 @@ def _mine_convos_impl(
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}")
+1164
View File
File diff suppressed because it is too large Load Diff
+83 -4
View File
@@ -32,6 +32,7 @@ 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
@@ -112,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"``.
@@ -122,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
@@ -173,13 +241,19 @@ class EmbeddinggemmaONNX:
# when switching models. Keep it stable.
return "embeddinggemma_300m"
def __init__(self, preferred_providers=None, batch_size: int = _EMBEDDINGGEMMA_BATCH_SIZE):
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
@@ -221,7 +295,11 @@ class EmbeddinggemmaONNX:
)
tok_path = hf_hub_download(_EMBEDDINGGEMMA_REPO, filename="tokenizer.json")
session = ort.InferenceSession(model_path, providers=self._providers)
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).
@@ -309,12 +387,13 @@ def get_embedding_function(device: Optional[str] = None, model: Optional[str] =
if cached is not None:
return cached
threads = _resolve_intra_op_threads()
if model == "embeddinggemma":
ef = EmbeddinggemmaONNX(preferred_providers=providers)
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)
ef = ef_cls(preferred_providers=providers, intra_op_num_threads=threads)
_EF_CACHE[cache_key] = ef
logger.info(
+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 = {
+2 -2
View File
@@ -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:
@@ -945,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:
+12 -7
View File
@@ -199,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.
@@ -229,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
@@ -310,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:
@@ -360,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
@@ -370,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
+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,
}
+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"):
+22 -6
View File
@@ -126,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)
@@ -141,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)
@@ -306,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)
+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
+1909 -164
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -295,7 +295,7 @@ 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
+201 -28
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,13 +1777,20 @@ 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
)
from .embedding import describe_device
print(f"\n{'=' * 55}")
@@ -1705,12 +1873,15 @@ def _mine_impl(
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:
@@ -1726,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:
@@ -1742,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:
@@ -1866,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.
@@ -1879,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.
@@ -1903,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.
+116 -71
View File
@@ -928,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.
@@ -943,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:
@@ -1064,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)
@@ -1077,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
@@ -1129,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)
@@ -1179,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
@@ -1238,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.
@@ -1283,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
@@ -1299,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
+93 -8
View File
@@ -3,8 +3,8 @@ project_scanner.py — Detect projects and people from real signal.
For a codebase with build manifests or git history, this beats regex-based
entity detection by a wide margin: the project's own name is already written
down in package.json / pyproject.toml / Cargo.toml / go.mod, and the people
who worked on it are in `git log`.
down in package.json / pyproject.toml / Cargo.toml / go.mod / pom.xml /
Gradle manifests, and the people who worked on it are in `git log`.
This module is used as the primary signal in `mempalace init`. The regex
detector in entity_detector.py stays as a fallback for prose-only folders
@@ -21,6 +21,7 @@ import json
import os
import re
import subprocess
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@@ -164,11 +165,63 @@ def _parse_gomod(path: Path) -> Optional[str]:
return None
def _xml_local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
def _parse_pom(path: Path) -> Optional[str]:
try:
root = ET.parse(path).getroot()
except (ET.ParseError, OSError):
return None
for child in root:
if isinstance(child.tag, str) and _xml_local_name(child.tag) == "artifactId":
name = (child.text or "").strip()
return name or None
return None
_GRADLE_ROOT_PROJECT_NAME_PATTERNS = [
re.compile(r"""(?m)^\s*rootProject\.name\s*=\s*(["'])(?P<name>[^"']+)\1"""),
re.compile(r"""(?m)^\s*rootProject\.name\.set\(\s*(["'])(?P<name>[^"']+)\1\s*\)"""),
]
def _parse_gradle_root_project_name(path: Path) -> Optional[str]:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
for pattern in _GRADLE_ROOT_PROJECT_NAME_PATTERNS:
match = pattern.search(text)
if match:
name = match.group("name").strip()
return name or None
return None
def _parse_gradle(path: Path) -> Optional[str]:
if path.name.startswith("build.gradle"):
for settings_name in ("settings.gradle.kts", "settings.gradle"):
name = _parse_gradle_root_project_name(path.with_name(settings_name))
if name:
return name
name = _parse_gradle_root_project_name(path)
if name:
return name
return path.parent.name or None
MANIFEST_PRIORITY = {
"pyproject.toml": 0,
"package.json": 1,
"Cargo.toml": 2,
"go.mod": 3,
"pom.xml": 4,
"settings.gradle": 5,
"settings.gradle.kts": 6,
"build.gradle": 7,
"build.gradle.kts": 8,
}
# Sentinel so unknown manifests always sort after the known manifest types above.
UNKNOWN_MANIFEST_PRIORITY = max(MANIFEST_PRIORITY.values()) + 1
@@ -177,6 +230,18 @@ MANIFEST_PARSERS = {
"pyproject.toml": _parse_pyproject,
"Cargo.toml": _parse_cargo,
"go.mod": _parse_gomod,
"pom.xml": _parse_pom,
"settings.gradle": _parse_gradle,
"settings.gradle.kts": _parse_gradle,
"build.gradle": _parse_gradle,
"build.gradle.kts": _parse_gradle,
}
JAVA_MANIFESTS = {
"pom.xml",
"settings.gradle",
"settings.gradle.kts",
"build.gradle",
"build.gradle.kts",
}
@@ -461,10 +526,12 @@ def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
for repo in repos:
manifests = _collect_manifest_names(repo)
if manifests:
manifest_file, proj_name, _ = manifests[0]
root_manifest = next((entry for entry in manifests if entry[2] == repo), None)
if root_manifest:
manifest_file, proj_name, _ = root_manifest
else:
manifest_file, proj_name = None, repo.name
extra_manifests = [entry for entry in manifests if entry != root_manifest]
authors = _git_authors(repo)
non_bot_authors = [(name, email) for name, email in authors if not _is_bot(name, email)]
@@ -501,17 +568,35 @@ def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
if existing is None or proj.user_commits > existing.user_commits:
projects[proj_name] = proj
for extra_manifest, extra_name, extra_dir in extra_manifests:
if extra_manifest not in JAVA_MANIFESTS:
continue
existing = projects.get(extra_name)
if existing is not None and (
existing.manifest is not None or existing.repo_root != repo
):
continue
projects[extra_name] = ProjectInfo(
name=extra_name,
repo_root=extra_dir,
manifest=extra_manifest,
has_git=True,
total_commits=total_commits,
user_commits=user_commits,
is_mine=is_mine,
)
people = _dedupe_people(all_commits)
# Handle case: root has manifests but no git repo anywhere
if not repos:
manifests = _collect_manifest_names(root_path)
for manifest_file, proj_name, _dirpath in manifests:
for manifest_file, proj_name, dirpath in manifests:
if proj_name in projects:
continue
projects[proj_name] = ProjectInfo(
name=proj_name,
repo_root=root_path,
repo_root=dirpath,
manifest=manifest_file,
has_git=False,
)
@@ -605,8 +690,8 @@ def discover_entities(
plugs into ``confirm_entities`` unchanged.
Order of signal preference:
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod)
→ canonical project names
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod,
pom.xml, Gradle manifests) → canonical project names
2. Git commit authors → real people with real commit counts
3. Claude Code conversation dirs (~/.claude/projects/) → per-session
project names (pulled from each session's ``cwd`` metadata)
+371 -22
View File
@@ -33,6 +33,7 @@ import argparse
import os
import shutil
import sqlite3
import stat
import time
from collections import defaultdict
from contextlib import closing
@@ -43,6 +44,7 @@ from typing import Callable, Iterator, Optional
from chromadb.errors import NotFoundError as ChromaNotFoundError
from .backends.chroma import ChromaBackend, hnsw_capacity_status
from .config import sqlite_read_uri
COLLECTION_NAME = "mempalace_drawers"
@@ -56,6 +58,78 @@ REPAIR_TEMP_COLLECTION = f"{COLLECTION_NAME}__repair_tmp"
CLOSETS_COLLECTION_NAME = "mempalace_closets"
def _no_follow_flag() -> int:
return getattr(os, "O_NOFOLLOW", 0)
def _open_regular_file_no_follow(path: str) -> int:
if os.path.islink(path):
raise RuntimeError(f"Refusing symlinked file: {path}")
fd = os.open(path, os.O_RDONLY | _no_follow_flag())
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode):
raise RuntimeError(f"Refusing non-regular file: {path}")
return fd
except Exception:
os.close(fd)
raise
def _write_text_replace_no_follow(path: str, text: str) -> None:
directory = os.path.dirname(path) or "."
basename = os.path.basename(path)
tmp_path = os.path.join(
directory,
f".{basename}.{os.getpid()}.{int(time.time() * 1_000_000)}.tmp",
)
fd = os.open(
tmp_path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | _no_follow_flag(),
0o600,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def _copy_file_no_follow(src: str, dst: str, *, replace: bool = False) -> None:
src_fd = _open_regular_file_no_follow(src)
try:
src_f = os.fdopen(src_fd, "rb")
except Exception:
os.close(src_fd)
raise
# ``src_f`` now owns ``src_fd`` and closes it on exit.
with src_f:
flags = os.O_WRONLY | os.O_CREAT | _no_follow_flag()
flags |= os.O_TRUNC if replace else os.O_EXCL
dst_fd = os.open(dst, flags, 0o600)
try:
dst_f = os.fdopen(dst_fd, "wb")
except Exception:
os.close(dst_fd)
raise
with dst_f:
shutil.copyfileobj(src_f, dst_f)
try:
shutil.copystat(src, dst, follow_symlinks=False)
except OSError:
pass
def _unique_backup_path(path: str, label: str) -> str:
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{path}.{label}.{stamp}.{os.getpid()}"
def _drawers_collection_name() -> str:
"""Resolve the drawers collection name from user config, falling back
to the module default ``COLLECTION_NAME`` if config is unreadable.
@@ -315,9 +389,10 @@ def scan_palace(palace_path=None, only_wing=None, collection_name: Optional[str]
print(f" BAD: {len(bad_set):,} ({len(bad_set) / max(len(all_ids), 1) * 100:.1f}%)")
bad_file = os.path.join(palace_path, "corrupt_ids.txt")
with open(bad_file, "w") as f:
for bid in sorted(bad_set):
f.write(bid + "\n")
_write_text_replace_no_follow(
bad_file,
"".join(f"{bid}\n" for bid in sorted(bad_set)),
)
print(f"\n Bad IDs written to: {bad_file}")
return good_set, bad_set
@@ -476,7 +551,7 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None)
try:
import sqlite3
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True)
try:
row = conn.execute(
"""
@@ -498,6 +573,9 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None)
return None
_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS = 15.0
def sqlite_integrity_errors(palace_path: str) -> list[str]:
"""Return SQLite quick_check errors for chroma.sqlite3.
@@ -516,7 +594,16 @@ def sqlite_integrity_errors(palace_path: str) -> list[str]:
return []
try:
with sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) as conn:
# A writer holding SQLite's lock is contention, not corruption. The
# sqlite3 module defaults to five seconds, which is shorter than
# routine batch mines and curator writes on rollback-journal palaces.
# Give those writers a bounded grace period before surfacing BUSY to
# callers; genuine corruption still comes from PRAGMA quick_check.
with sqlite3.connect(
sqlite_read_uri(sqlite_path),
uri=True,
timeout=_SQLITE_INTEGRITY_BUSY_TIMEOUT_SECONDS,
) as conn:
rows = conn.execute("PRAGMA quick_check").fetchall()
except sqlite3.Error as e:
return [f"PRAGMA quick_check failed: {e}"]
@@ -563,6 +650,127 @@ def print_sqlite_integrity_abort(palace_path: str, errors: list[str]) -> None:
print(" 6. Re-run `mempalace repair --yes`.")
# quick_check's wording for a corrupt FTS5 inverted index differs by SQLite
# version — both forms describe the same recoverable condition:
# "malformed inverted index for FTS5 table main.embedding_fulltext_search"
# (older SQLite)
# "fts5: corruption found reading blob N from table \"embedding_fulltext_search\""
# (SQLite >= ~3.5x, confirmed on 3.53.2 / Python 3.13.7 — the exact
# message this repo's own test fixture produces on that build)
# Either failure is recoverable in place: the index is derived from the
# intact ``embedding_fulltext_search_content`` shadow table, so rebuilding it
# restores full-text search without touching any drawer rows. Concurrent
# killed-mid-write mines are the usual cause (#1596). A regex matching only
# the older phrasing would silently decline to auto-heal on newer SQLite —
# the exact failure this repo's own test suite caught (test_repair.py's two
# auto-heal tests failed on this machine until this pattern was widened).
_FTS5_MALFORMED_RE = re.compile(
r"malformed inverted index for fts5 table|fts5:\s*corruption found",
re.IGNORECASE,
)
def _errors_are_isolated_fts5(errors: list[str]) -> bool:
"""True when every quick_check error is a malformed FTS5 inverted index.
Only an isolated FTS5 failure is safe to auto-heal: the inverted index is
derived data that ``rebuild`` regenerates from the content shadow table. If
quick_check also reports page/row corruption, the data itself may be damaged
and rebuilding the index over it would mask real loss — that still aborts.
"""
return bool(errors) and all(_FTS5_MALFORMED_RE.search(e) for e in errors)
def maybe_autoheal_fts5_index(palace_path: str, errors: list[str], *, progress=print) -> list[str]:
"""Rebuild a malformed FTS5 inverted index in place; return remaining errors.
The repair preflight aborts when ``PRAGMA quick_check`` reports SQLite-layer
corruption. After concurrent killed-mid-write mines (#1596) the common
failure is an isolated ``malformed inverted index for FTS5 table``, which is
fully recoverable: the index rebuilds from the intact
``embedding_fulltext_search_content`` table without touching drawer rows.
When the errors are isolated to FTS5, rebuild the index under the palace
write lock (so a live mine cannot race the rebuild) and re-run quick_check.
Returns the remaining quick_check errors — empty when the heal succeeded.
Broader corruption, a lock held by another writer, or a rebuild failure
leaves ``errors`` unchanged so the caller still aborts with the banner.
"""
if not _errors_are_isolated_fts5(errors):
return errors
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.exists(sqlite_path):
return errors
# Lazy import: palace.py is heavier and importing it at module load would
# widen repair.py's import graph for callers that never hit this path.
from .palace import MineAlreadyRunning, mine_palace_lock
progress(
"\n Isolated FTS5 inverted-index corruption detected; attempting an\n"
" in-place rebuild from the intact content table before aborting."
)
try:
with mine_palace_lock(palace_path):
with closing(sqlite3.connect(sqlite_path, isolation_level=None)) as conn:
conn.execute(
"INSERT INTO embedding_fulltext_search"
"(embedding_fulltext_search) VALUES('rebuild')"
)
conn.commit()
except MineAlreadyRunning as exc:
progress(
f" Skipped FTS5 rebuild: palace is being written by another process ({exc}). "
"Stop it and re-run."
)
return errors
except Exception as exc:
progress(f" FTS5 rebuild failed (leaving palace untouched): {exc}")
return errors
remaining = sqlite_integrity_errors(palace_path)
if remaining:
progress(" FTS5 rebuild did not clear quick_check; aborting for safety.")
else:
progress(" FTS5 index rebuilt from intact content; quick_check is clean.")
return remaining
def index_read_recovery_guidance() -> str:
"""Recovery guidance for a failed drawer-index read in the legacy paths.
Both ``cmd_repair`` (cli.py) and :func:`rebuild_index` read the drawers
collection via ``Collection.count()`` as their first step. The common
reason that read raises is the chromadb compactor failing to apply the
WAL into the HNSW segment (``InternalError: Failed to apply logs to the
hnsw segment writer``, issues #1308 / #1843): the on-disk HNSW index is
corrupt while the rows stay intact in ``chroma.sqlite3``, so
:func:`rebuild_from_sqlite` (``repair --mode from-sqlite``) recovers them
and re-mining would needlessly drop drawers added through the MCP server
and diary entries that have no source file.
The other thing that strands this read is a live MemPalace server or
mine still holding the palace open, so the guidance says to stop it and
retry before assuming corruption. Worded conditionally because the bare
``except Exception`` cannot prove which case it caught. Returned as a
pre-indented block so the ``print``-based CLI path and the
``progress``-callable rebuild path emit it unchanged.
"""
return (
" If a MemPalace server or mine is still running against this palace,\n"
" stop it and retry. Otherwise the drawer index is likely corrupt\n"
" (for example a failed chromadb HNSW compaction) while your drawer\n"
" rows remain in chroma.sqlite3. Rebuild the index from SQLite rather\n"
" than re-mining:\n"
"\n"
" mempalace repair --mode from-sqlite --archive-existing\n"
"\n"
" (Re-mining from source files would drop drawers added via the MCP\n"
" server and diary entries, which have no source file.)"
)
def maybe_repair_poisoned_max_seq_id_before_rebuild(
palace_path: str,
*,
@@ -681,7 +889,12 @@ class _DefaultProgress:
return f" (elapsed {_format_eta(elapsed)}, rate {rate:.1f}/s, ETA {_format_eta(eta)})"
def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
def _vacuum_and_rebuild_fts5(
palace_path: str,
progress=print,
*,
strict: bool = False,
) -> None:
"""VACUUM the palace SQLite file and rebuild the FTS5 index if present.
Repeated ``repair --yes`` runs delete and recreate the drawers collection,
@@ -690,12 +903,16 @@ def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
internally inconsistent after multiple collection deletes; the rebuild
command fixes it atomically without touching any row data.
Failures are non-fatal: a warning is printed and the caller continues.
The repair itself succeeded at this point — VACUUM/FTS5 are best-effort
cleanup, not correctness requirements.
Existing repair paths use the default best-effort behavior: failures log a
warning and return because their primary rebuild has already succeeded.
SQLite recovery passes ``strict=True`` because its bulk upserts can leave
this derived index malformed; that path must not report success until the
rebuild, VACUUM, and a final quick_check all complete.
"""
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.exists(sqlite_path):
if strict:
raise FileNotFoundError(f"recovered palace has no SQLite database: {sqlite_path}")
return
try:
with closing(sqlite3.connect(sqlite_path, isolation_level=None)) as conn:
@@ -711,7 +928,21 @@ def _vacuum_and_rebuild_fts5(palace_path: str, progress=print) -> None:
progress(" FTS5 index rebuilt.")
conn.execute("VACUUM")
progress(" SQLite VACUUM complete.")
if strict:
rows = conn.execute("PRAGMA quick_check").fetchall()
errors = [str(row[0]) for row in rows if row and str(row[0]).lower() != "ok"]
if errors:
raise sqlite3.DatabaseError(
"post-recovery quick_check failed: " + "; ".join(errors[:3])
)
progress(" SQLite quick_check clean.")
except Exception as exc:
if strict:
# Preserve the concrete SQLite/filesystem exception for callers
# that need to classify the failure. The strict recovery caller
# owns the user-facing error message, so logging here would also
# print the same failure twice.
raise
progress(f" Warning: post-repair cleanup failed (non-fatal): {exc}")
@@ -774,6 +1005,8 @@ def rebuild_index(
# corruption here lets us 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, progress=progress)
if sqlite_errors:
print_sqlite_integrity_abort(palace_path, sqlite_errors)
return
@@ -791,7 +1024,7 @@ def rebuild_index(
total = col.count()
except Exception as e:
progress(f" Error reading palace: {e}")
progress(" Palace may need to be re-mined from source files.")
progress(index_read_recovery_guidance())
return
progress(f" Drawers found: {total}")
@@ -823,10 +1056,10 @@ def rebuild_index(
# Back up ONLY the SQLite database, not the bloated HNSW files
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
backup_path = sqlite_path + ".backup"
backup_path = _unique_backup_path(sqlite_path, "backup")
if os.path.exists(sqlite_path):
progress(f" Backing up chroma.sqlite3 ({os.path.getsize(sqlite_path) / 1e6:.0f} MB)...")
shutil.copy2(sqlite_path, backup_path)
_copy_file_no_follow(sqlite_path, backup_path)
progress(f" Backup: {backup_path}")
# Rebuild with correct HNSW settings
@@ -850,7 +1083,7 @@ def rebuild_index(
try:
_close_chroma_handles(palace_path, backend=backend)
_delete_collection_if_exists(backend, palace_path, collection_name)
shutil.copy2(backup_path, sqlite_path)
_copy_file_no_follow(backup_path, sqlite_path, replace=True)
progress(" Backup restored. Palace is back to pre-repair state.")
except Exception as restore_error:
progress(f" Backup restore failed: {restore_error}")
@@ -895,6 +1128,29 @@ class RebuildPartialError(Exception):
self.archive_path = archive_path
class RebuildCleanupError(Exception):
"""Raised when all recoverable rows landed but final cleanup failed.
The destination is intentionally retained for inspection, and an in-place
rebuild's original archive remains untouched. Callers must not treat this
as success because the derived FTS5 index has not been verified clean.
"""
def __init__(
self,
message: str,
*,
counts: dict[str, int],
dest_palace: str,
archive_path: Optional[str],
):
super().__init__(message)
self.message = message
self.counts = counts
self.dest_palace = dest_palace
self.archive_path = archive_path
def _rebuild_one_collection(
*,
backend: ChromaBackend,
@@ -1013,7 +1269,7 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple
if not os.path.isfile(sqlite_path):
return
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True)
try:
seg_row = conn.execute(
"""
@@ -1059,6 +1315,40 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple
conn.close()
def _preserve_knowledge_graph_sqlite(source_palace: str, dest_palace: str) -> list[str]:
"""Copy KG SQLite sidecars when rebuilding a palace from chroma.sqlite3.
rebuild_from_sqlite reconstructs Chroma collections into a fresh
destination directory. The knowledge graph is a separate SQLite database,
so it must be copied explicitly or the repair succeeds while silently
dropping KG state (#1816).
"""
copied: list[str] = []
for suffix in ("", "-wal", "-shm"):
filename = f"knowledge_graph.sqlite3{suffix}"
src = os.path.join(source_palace, filename)
dst = os.path.join(dest_palace, filename)
if not os.path.isfile(src):
continue
if os.path.abspath(src) == os.path.abspath(dst):
continue
os.makedirs(dest_palace, exist_ok=True)
try:
_copy_file_no_follow(src, dst, replace=True)
except RuntimeError:
continue
copied.append(filename)
if copied:
print(" Preserved knowledge graph: " + ", ".join(copied))
return copied
def rebuild_from_sqlite(
source_palace: str,
dest_palace: str,
@@ -1110,7 +1400,9 @@ def rebuild_from_sqlite(
chromadb upsert fails partway through; the dest palace is left in
place so the user can inspect what landed, and the in-place archive
(when applicable) is reported in the error so the user can re-run
against it.
against it. Raises :class:`RebuildCleanupError` if all rows land but the
required FTS5 rebuild, VACUUM, or final quick_check fails; this prevents a
structurally unverified recovery from being reported as complete.
.. warning::
@@ -1184,7 +1476,30 @@ def rebuild_from_sqlite(
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
archive_path = f"{dest_palace}.pre-rebuild-{ts}"
print(f" Archiving {dest_palace} → {archive_path}")
shutil.move(dest_palace, archive_path)
# os.rename, NOT shutil.move. When any file inside the palace is
# held open by another process (MCP server, a running mine, another
# harness), renaming the directory fails atomically UP FRONT on
# Windows and the palace is left untouched. shutil.move's fallback
# for a failed rename is copytree + rmtree — and that rmtree
# deletes the live palace file-by-file until it hits the first
# locked file, leaving the palace partially gutted next to a
# partial archive copy. Observed live (Windows 11, 2026-07-05/06):
# two interrupted in-place repairs; the palace survived only
# because the locked chroma.sqlite3/*.bin themselves could not be
# unlinked. Same lesson as the source-validation comment above:
# fail cleanly before touching anything.
try:
os.rename(dest_palace, archive_path)
except OSError as exc:
print(
f"\n Cannot archive the existing palace: a file inside it "
f"is held open by another process (MCP server, running "
f"mine, another harness?).\n"
f" Close those processes and retry. The palace was left "
f"untouched.\n"
f" ({exc})"
)
return {}
source_palace = archive_path
src_db = os.path.join(source_palace, "chroma.sqlite3")
@@ -1205,6 +1520,7 @@ def rebuild_from_sqlite(
)
os.makedirs(dest_palace, exist_ok=True)
_preserve_knowledge_graph_sqlite(source_palace, dest_palace)
# Backend lifetime is wrapped in try/finally so the dest palace's
# PersistentClient handle (opened lazily inside ``create_collection``
@@ -1235,14 +1551,39 @@ def rebuild_from_sqlite(
else:
print(f" done: {upserted} rows in {cname}")
print(f"\n Rebuild complete. {sum(counts.values())} total rows.")
if archive_path is not None:
print(f" Original palace archived at: {archive_path}")
print(f"{'=' * 55}\n")
return counts
finally:
backend.close()
# Bulk Chroma upserts can leave the derived FTS5 index internally
# inconsistent even when all source rows landed. Rebuild it only after
# the backend releases its SQLite handle; otherwise VACUUM cannot obtain
# the exclusive lock it needs on Windows.
try:
_vacuum_and_rebuild_fts5(dest_palace, strict=True)
except Exception as exc:
message_parts = [
f"Post-recovery cleanup failed after {sum(counts.values())} rows were rebuilt: {exc}",
f"Recovered palace retained at: {dest_palace}",
]
if archive_path is not None:
message_parts.append(f"Original palace remains archived at: {archive_path}")
else:
message_parts.append(f"Source palace is unchanged at: {source_palace}")
message = "\n ".join(message_parts)
print(f"\n ERROR: {message}")
raise RebuildCleanupError(
message,
counts=dict(counts),
dest_palace=dest_palace,
archive_path=archive_path,
) from exc
print(f"\n Rebuild complete. {sum(counts.values())} total rows.")
if archive_path is not None:
print(f" Original palace archived at: {archive_path}")
print(f"{'=' * 55}\n")
return counts
def status(palace_path=None, collection_name: Optional[str] = None) -> dict:
"""Read-only health check: compare sqlite vs HNSW element counts.
@@ -1308,7 +1649,15 @@ def status(palace_path=None, collection_name: Optional[str] = None) -> dict:
print(f" note: {info['message']}")
if drawers["diverged"] or closets["diverged"]:
print("\n Recommended: run `mempalace repair` to rebuild the index.")
print(
"\n Recommended: rebuild the index from SQLite rather than re-mining:\n"
"\n mempalace repair --mode from-sqlite --archive-existing\n"
"\n A diverged index usually means the HNSW segment is out of sync with\n"
" chroma.sqlite3 (for example a failed chromadb HNSW compaction). The\n"
" drawer rows are intact in SQLite, so --mode from-sqlite recovers them.\n"
" Do not re-mine from source files: that would drop drawers added via\n"
" the MCP server and diary entries, which have no source file (#1843)."
)
print()
return {"drawers": drawers, "closets": closets}
+175 -21
View File
@@ -23,6 +23,7 @@ from .backends import (
PalaceNotFoundError,
UnsupportedCapabilityError,
)
from .config import sqlite_read_uri
from .palace import (
_open_collection_or_explain,
get_closets_collection,
@@ -220,20 +221,40 @@ def _hybrid_rank(
r["bm25_score"] = round(raw, 3)
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
scored.sort(key=lambda pair: pair[0], reverse=True)
# Break exact score ties toward the more recently authored drawer so equal-score
# candidates rank chronologically instead of in arbitrary backend order. ISO-8601
# ``authored_at`` strings sort chronologically; missing dates sort oldest.
# authored_at lives at the top level on the search_memories path and nested under
# "metadata" on the candidate-union path; check both so the tie-break works for each.
scored.sort(
key=lambda pair: (
pair[0],
pair[1].get("authored_at") or pair[1].get("metadata", {}).get("authored_at") or "",
),
reverse=True,
)
results[:] = [r for _, r in scored]
return results
def build_where_filter(wing: str = None, room: str = None) -> dict:
"""Build ChromaDB where filter for wing/room filtering."""
if wing and room:
return {"$and": [{"wing": wing}, {"room": room}]}
elif wing:
return {"wing": wing}
elif room:
return {"room": room}
return {}
def build_where_filter(wing: str = None, room: str = None, source_file: str = None) -> dict:
"""Build a ChromaDB where filter from optional wing/room/source_file.
ChromaDB needs a ``$and`` only when ≥2 clauses are present; a single
clause is returned bare and zero clauses yield an empty filter (#1815).
"""
clauses = []
if wing:
clauses.append({"wing": wing})
if room:
clauses.append({"room": room})
if source_file:
clauses.append({"source_file": source_file})
if not clauses:
return {}
if len(clauses) == 1:
return clauses[0]
return {"$and": clauses}
def _extract_drawer_ids_from_closet(closet_doc: str) -> list:
@@ -386,11 +407,109 @@ def _warn_if_legacy_metric(col) -> None:
)
def _hnsw_capacity_diverged(palace_path: str) -> bool:
"""Return True if HNSW divergence is severe enough to crash ChromaDB.
Thin, exception-safe wrapper around
:func:`mempalace.backends.chroma.hnsw_capacity_status`. Used by the
CLI search path to short-circuit to the BM25-only fallback before
opening a Chroma client. Client construction and collection identity
checks can themselves touch the damaged index, so guarding only
``col.query()`` is too late (#1222 covers the MCP path via the module-level
``_vector_disabled`` flag; this covers the CLI path).
A probe that raises falls through to ``False`` so the caller proceeds
to the normal vector path — the underlying query then either succeeds
(probe was a false negative) or raises its own diagnostic error. The
probe itself must never be the thing that crashes search.
"""
try:
from .backends.chroma import hnsw_capacity_status
from .config import get_configured_collection_name
info = hnsw_capacity_status(palace_path, get_configured_collection_name())
return bool(info.get("diverged"))
except Exception:
logger.debug("HNSW capacity probe raised; proceeding to vector path", exc_info=True)
return False
def _print_search_results_bm25_only(
query: str, palace_path: str, wing: str, room: str, n_results: int
) -> None:
"""CLI fallback printer for when HNSW divergence fences off vector search.
Mirrors the vector-path output shape so users get lexical matches in
the format they expect, plus a clear notice pointing at
``mempalace repair``. Replaces the silent SIGBUS users otherwise hit
when the CLI called ``col.query()`` against a diverged segment.
"""
result = _bm25_only_via_sqlite(
query=query,
palace_path=palace_path,
wing=wing,
room=room,
n_results=n_results,
)
hits = result.get("results", [])
print(
"\n NOTICE: vector search disabled — HNSW index has diverged from SQLite.\n"
" Showing BM25-only results. Run `mempalace repair` to restore "
"vector search.\n"
)
print(f"{'=' * 60}")
print(f' Results for: "{query}"')
if wing:
print(f" Wing: {wing}")
if room:
print(f" Room: {room}")
print(f"{'=' * 60}\n")
if not hits:
print(f' No results found for: "{query}"')
return
for i, hit in enumerate(hits, 1):
bm25 = hit.get("bm25_score", 0.0)
wing_name = hit.get("wing", "?")
room_name = hit.get("room", "?")
source = Path(hit.get("source_file", "?")).name
print(f" [{i}] {wing_name} / {room_name}")
print(f" Source: {source}")
print(f" Match: bm25={bm25} (vector disabled)")
print()
for line in (hit.get("text", "") or "").strip().split("\n"):
print(f" {line}")
print()
print(f" {'─' * 56}")
print()
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
"""
Search the palace. Returns verbatim drawer content.
Optionally filter by wing (project) or room (aspect).
"""
# Probe a Chroma palace before get_collection(). Opening the client can
# load native index state, and embedder-identity enforcement may call
# collection.count(); both happen before the old query-only guard and can
# hit the same native crash. Non-Chroma backends never use Chroma's HNSW
# files or sqlite-specific fallback and proceed normally.
try:
backend_name = resolve_backend_name(palace_path)
except (BackendMismatchError, KeyError):
# Preserve _open_collection_or_explain's state-specific diagnostics
# for mixed artifacts and unknown backend selections. This probe is
# only an early Chroma safety fence; it must not become a second,
# less-helpful backend validation path.
backend_name = None
if backend_name == "chroma" and _hnsw_capacity_diverged(palace_path):
return _print_search_results_bm25_only(query, palace_path, wing, room, n_results)
col = _open_collection_or_explain(palace_path, opener=get_collection)
if col is None:
if not os.path.isdir(palace_path):
@@ -475,6 +594,7 @@ def _bm25_only_via_sqlite(
palace_path: str,
wing: str = None,
room: str = None,
source_file: str = None,
n_results: int = 5,
max_candidates: int = 500,
_include_internal: bool = False,
@@ -510,7 +630,7 @@ def _bm25_only_via_sqlite(
def _metadata_filter_sql(row_id_expr: str) -> tuple[str, list[str]]:
clauses = []
params = []
for key, value in (("wing", wing), ("room", room)):
for key, value in (("wing", wing), ("room", room), ("source_file", source_file)):
if not value:
continue
clauses.append(
@@ -533,7 +653,7 @@ def _bm25_only_via_sqlite(
return "".join(clauses), params
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
except sqlite3.Error as e:
return {"error": f"sqlite open failed: {e}"}
@@ -623,7 +743,7 @@ def _bm25_only_via_sqlite(
if not candidate_ids:
return {
"query": query,
"filters": {"wing": wing, "room": room},
"filters": {"wing": wing, "room": room, "source_file": source_file},
"total_before_filter": 0,
"results": [],
"fallback": "bm25_only_via_sqlite",
@@ -659,6 +779,8 @@ def _bm25_only_via_sqlite(
continue
if room and meta.get("room") != room:
continue
if source_file and meta.get("source_file") != source_file:
continue
full_source = meta.get("source_file", "") or ""
candidates.append(
{
@@ -666,7 +788,9 @@ def _bm25_only_via_sqlite(
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(full_source).name if full_source else "?",
"source_path": full_source,
"created_at": meta.get("filed_at", "unknown"),
"authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")),
# No vector distance available in BM25-only mode.
"similarity": None,
"distance": None,
@@ -701,7 +825,7 @@ def _bm25_only_via_sqlite(
return {
"query": query,
"filters": {"wing": wing, "room": room},
"filters": {"wing": wing, "room": room, "source_file": source_file},
"total_before_filter": len(candidates),
"results": hits,
"fallback": "bm25_only_via_sqlite",
@@ -717,6 +841,7 @@ def _merge_bm25_union_candidates(
room: str,
n_results: int,
max_distance: float = 0.0,
source_file: str = None,
) -> None:
"""Append top-K backend lexical candidates into ``hits`` in place.
@@ -743,7 +868,7 @@ def _merge_bm25_union_candidates(
if max_distance > 0.0:
return
where = build_where_filter(wing, room)
where = build_where_filter(wing, room, source_file)
try:
lexical = drawers_col.lexical_search(
query=query,
@@ -766,7 +891,9 @@ def _merge_bm25_union_candidates(
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(full_source).name if full_source else "?",
"source_path": full_source,
"created_at": meta.get("filed_at", "unknown"),
"authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")),
"similarity": None,
"distance": None,
"effective_distance": None,
@@ -831,6 +958,7 @@ def _apply_candidate_strategy(
room: str,
n_results: int,
max_distance: float = 0.0,
source_file: str = None,
) -> None:
"""Dispatch to the registered merger for ``strategy``.
@@ -839,7 +967,16 @@ def _apply_candidate_strategy(
"""
merger = _CANDIDATE_MERGERS[strategy]
if merger is not None:
merger(hits, drawers_col, query, wing, room, n_results, max_distance=max_distance)
merger(
hits,
drawers_col,
query,
wing,
room,
n_results,
max_distance=max_distance,
source_file=source_file,
)
def _finalize_candidate_hits(
@@ -852,6 +989,7 @@ def _finalize_candidate_hits(
room: str,
n_results: int,
max_distance: float,
source_file: str = None,
) -> tuple:
try:
_apply_candidate_strategy(
@@ -863,6 +1001,7 @@ def _finalize_candidate_hits(
room,
n_results,
max_distance=max_distance,
source_file=source_file,
)
except UnsupportedCapabilityError:
return [], {
@@ -904,6 +1043,7 @@ def _vector_disabled_search(
room: str,
n_results: int,
collection_name: str,
source_file: str = None,
) -> dict:
try:
backend_name = resolve_backend_name(palace_path)
@@ -923,6 +1063,7 @@ def _vector_disabled_search(
palace_path,
wing=wing,
room=room,
source_file=source_file,
n_results=n_results,
collection_name=collection_name,
)
@@ -956,7 +1097,9 @@ def _open_search_collection(palace_path: str, collection_name: str):
}
def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results, wing, room):
def _query_drawers_with_filter_fallback(
drawers_col, dkwargs, query, n_results, wing, room, source_file=None
):
"""Run the filtered drawer query, falling back to an unfiltered query plus a
Python-side post-filter when ChromaDB raises on the filtered query.
@@ -964,7 +1107,7 @@ def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results,
"Error finding id" even when unfiltered search works fine — it happens when
drawers are ingested via two different paths (e.g. bulk import vs MCP tool
calls), leaving the vector index inconsistent with the metadata store. We
retry unfiltered (over-fetching) and re-apply the wing/room filter in Python.
retry unfiltered (over-fetching) and re-apply the wing/room/source_file filter in Python.
See #1245 / #1035.
"""
where = dkwargs.get("where")
@@ -993,6 +1136,8 @@ def _query_drawers_with_filter_fallback(drawers_col, dkwargs, query, n_results,
continue
if room and meta.get("room") != room:
continue
if source_file and meta.get("source_file") != source_file:
continue
fdocs.append(doc)
fmetas.append(meta)
fdists.append(dist)
@@ -1004,6 +1149,7 @@ def search_memories(
palace_path: str,
wing: str = None,
room: str = None,
source_file: str = None,
n_results: int = 5,
max_distance: float = 0.0,
vector_disabled: bool = False,
@@ -1019,6 +1165,8 @@ def search_memories(
palace_path: Path to the ChromaDB palace directory.
wing: Optional wing filter.
room: Optional room filter.
source_file: Optional exact source_file filter. Matches the full
stored source_file value verbatim (#1815).
n_results: Max results to return.
max_distance: Max cosine distance threshold. The palace collection uses
cosine distance (hnsw:space=cosine) — 0 = identical, 2 = opposite.
@@ -1058,6 +1206,7 @@ def search_memories(
room=room,
n_results=n_results,
collection_name=collection_name,
source_file=source_file,
)
drawers_col, open_error = _open_search_collection(palace_path, collection_name)
@@ -1065,7 +1214,7 @@ def search_memories(
return open_error
metric = _metric_for_collection(drawers_col)
where = build_where_filter(wing, room)
where = build_where_filter(wing, room, source_file)
# Hybrid retrieval: always query drawers directly (the floor), then use
# closet hits to boost rankings. Closets are a ranking SIGNAL, never a
@@ -1083,7 +1232,7 @@ def search_memories(
if where:
dkwargs["where"] = where
drawer_results = _query_drawers_with_filter_fallback(
drawers_col, dkwargs, query, n_results, wing, room
drawers_col, dkwargs, query, n_results, wing, room, source_file
)
except Exception as e:
return {"error": f"Search error: {e}"}
@@ -1155,8 +1304,12 @@ def search_memories(
"text": doc,
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
# source_file is the basename (display); source_path is the full
# stored value, the round-trippable key for the source_file filter.
"source_file": Path(source).name if source else "?",
"source_path": source,
"created_at": meta.get("filed_at", "unknown"),
"authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")),
"similarity": round(_distance_to_similarity(effective_dist, metric), 3),
"distance": round(dist, 4),
"effective_distance": round(effective_dist, 4),
@@ -1253,13 +1406,14 @@ def search_memories(
room=room,
n_results=n_results,
max_distance=max_distance,
source_file=source_file,
)
if strategy_error:
return strategy_error
return {
"query": query,
"filters": {"wing": wing, "room": room},
"filters": {"wing": wing, "room": room, "source_file": source_file},
"total_before_filter": len(_first_or_empty(drawer_results, "documents")),
"results": hits,
}
+405
View File
@@ -0,0 +1,405 @@
"""Shared service operations used by daemon-backed entry points.
The MCP server remains the owner of MCP transport details. This module owns the
small, transport-neutral execution surface the daemon needs: classify known
tools and execute durable background jobs without printing directly to the
caller's terminal.
"""
from __future__ import annotations
import contextlib
import io
import os
import sys
from typing import Any
from .config import MempalaceConfig
_EXPLICIT_BACKEND_ENV = "MEMPALACE_BACKEND_EXPLICIT"
_PALACE_PATH_ENV = "MEMPALACE_PALACE_PATH"
_BACKEND_ENV = "MEMPALACE_BACKEND"
# Env vars a job may mutate via _apply_backend / palace_path injection. They are
# snapshotted per job and restored afterward so a job that switches the backend
# (e.g. qdrant) cannot poison every later job in the same daemon process —
# including mcp_tool jobs, which read MempalaceConfig (and thus the leaked env).
_PER_JOB_ENV = (_PALACE_PATH_ENV, _BACKEND_ENV, _EXPLICIT_BACKEND_ENV)
READ_TOOLS = frozenset(
{
"mempalace_status",
"mempalace_list_wings",
"mempalace_list_rooms",
"mempalace_get_taxonomy",
"mempalace_get_aaak_spec",
"mempalace_traverse",
"mempalace_find_tunnels",
"mempalace_graph_stats",
"mempalace_list_tunnels",
"mempalace_list_hallways",
"mempalace_follow_tunnels",
"mempalace_search",
"mempalace_check_duplicate",
"mempalace_get_drawer",
"mempalace_list_drawers",
"mempalace_diary_read",
"mempalace_memories_filed_away",
"mempalace_kg_query",
"mempalace_kg_stats",
"mempalace_kg_timeline",
}
)
WRITE_TOOLS = frozenset(
{
"mempalace_add_drawer",
"mempalace_checkpoint",
"mempalace_delete_by_source",
"mempalace_delete_drawer",
"mempalace_update_drawer",
"mempalace_diary_write",
"mempalace_kg_add",
"mempalace_kg_invalidate",
"mempalace_create_tunnel",
"mempalace_delete_tunnel",
"mempalace_delete_hallway",
"mempalace_hook_settings",
}
)
MAINTENANCE_TOOLS = frozenset({"mempalace_mine", "mempalace_sync", "mempalace_reconnect"})
def classify_tool(name: str) -> str:
"""Return ``read``, ``write``, ``maintenance``, or ``unknown`` for an MCP tool."""
if name in READ_TOOLS:
return "read"
if name in WRITE_TOOLS:
return "write"
if name in MAINTENANCE_TOOLS:
return "maintenance"
return "unknown"
def _apply_backend(backend: str | None) -> None:
if not backend:
return
backend_name = str(backend).strip().lower()
from .backends import get_backend_class
get_backend_class(backend_name)
os.environ[_EXPLICIT_BACKEND_ENV] = backend_name
os.environ[_BACKEND_ENV] = backend_name
def _capture(fn):
stdout = io.StringIO()
stderr = io.StringIO()
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
result = fn()
return result, stdout.getvalue(), stderr.getvalue()
def execute_job(kind: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Execute one daemon job and return a JSON-serializable result."""
def _run():
if kind == "mine":
return run_mine(payload)
if kind == "sync":
return run_sync(payload)
if kind == "diary_write":
return run_diary_write(payload)
if kind == "mcp_tool":
return run_mcp_tool(payload)
return {"success": False, "error": f"unknown daemon job kind: {kind}", "exit_code": 2}
# Per-job env isolation: snapshot the backend/palace env vars and restore
# them after the job so one job's _apply_backend / palace_path injection
# can't leak into the next job in the same long-lived process.
saved_env = {key: os.environ.get(key) for key in _PER_JOB_ENV}
try:
result, stdout, stderr = _capture(_run)
finally:
for key, value in saved_env.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
if result is None:
result = {}
if not isinstance(result, dict):
result = {"success": True, "value": result}
result.setdefault("success", True)
result.setdefault("exit_code", 0 if result.get("success") else 1)
if stdout:
result["stdout"] = stdout
if stderr:
result["stderr"] = stderr
return result
def run_mine(payload: dict[str, Any]) -> dict[str, Any]:
"""Run the same mine operation as the CLI, without daemon transport concerns."""
palace_path = os.path.abspath(
os.path.expanduser(payload.get("palace_path") or MempalaceConfig().palace_path)
)
os.environ["MEMPALACE_PALACE_PATH"] = palace_path
_apply_backend(payload.get("backend"))
source = payload.get("source") or payload.get("dir")
mode = payload.get("mode") or "projects"
wing = payload.get("wing")
agent = payload.get("agent") or "mempalace"
limit = int(payload.get("limit") or 0)
dry_run = bool(payload.get("dry_run"))
if payload.get("redetect_origin"):
from .cli import _run_pass_zero
_run_pass_zero(project_dir=source, palace_dir=palace_path, llm_provider=None)
from .palace import MineAlreadyRunning, MineValidationError
try:
if mode == "convos":
from .convo_miner import mine_convos
mine_convos(
convo_dir=source,
palace_path=palace_path,
wing=wing,
agent=agent,
limit=limit,
dry_run=dry_run,
extract_mode=payload.get("extract") or "exchange",
)
elif mode == "extract":
from .format_miner import mine_formats
mine_formats(
format_dir=source,
palace_path=palace_path,
wing=wing,
agent=agent,
limit=limit,
dry_run=dry_run,
)
elif mode == "projects":
include_ignored = payload.get("include_ignored") or []
from .miner import mine
mine(
project_dir=source,
palace_path=palace_path,
wing_override=wing,
agent=agent,
limit=limit,
dry_run=dry_run,
respect_gitignore=not bool(payload.get("no_gitignore")),
include_ignored=include_ignored,
max_chunks_per_file=payload.get("max_chunks_per_file"),
)
else:
return {"success": False, "error": f"invalid mine mode: {mode}", "exit_code": 2}
except MineAlreadyRunning as exc:
return {
"success": False,
"error": str(exc),
"error_class": "LockHeldByOtherProcess",
"exit_code": 1,
}
except MineValidationError as exc:
return {
"success": False,
"error": str(exc),
"error_class": "MineValidationError",
"exit_code": 1,
}
except SystemExit as exc:
code = exc.code if isinstance(exc.code, int) else 1
return {
"success": code == 0,
"error": str(exc),
"error_class": "SystemExit",
"exit_code": code,
}
except Exception as exc:
return {"success": False, "error": f"mine failed: {exc}", "exit_code": 1}
return {"success": True, "kind": "mine", "mode": mode, "dry_run": dry_run, "exit_code": 0}
def run_sync(payload: dict[str, Any]) -> dict[str, Any]:
"""Run sync and render the same operator-facing summary shape as the CLI."""
palace_path = os.path.abspath(
os.path.expanduser(payload.get("palace_path") or MempalaceConfig().palace_path)
)
os.environ["MEMPALACE_PALACE_PATH"] = palace_path
_apply_backend(payload.get("backend"))
from .backends import detect_backend_for_path
from .palace import MineAlreadyRunning, _backend_artifact_label, resolve_backend_name
if not os.path.isdir(palace_path):
print(f"\n No palace found at {palace_path}")
return {"success": True, "exit_code": 0}
try:
backend_name = resolve_backend_name(palace_path)
except Exception as exc:
return {
"success": False,
"error": f"Could not resolve palace backend: {exc}",
"exit_code": 1,
}
if detect_backend_for_path(palace_path) is None:
print(
f"\n Palace dir at {palace_path} exists but has no "
f"{_backend_artifact_label(backend_name)} yet."
)
print(" Run: mempalace mine <dir>")
return {"success": True, "exit_code": 0}
project_dirs = []
if payload.get("dir"):
project_dirs.append(os.path.expanduser(str(payload["dir"])))
project_dirs.extend(os.path.expanduser(str(root)) for root in payload.get("root") or [])
project_dirs = project_dirs or None
dry_run = bool(payload.get("dry_run", True))
print(f"\n{'=' * 55}")
print(" MemPalace Sync — Gitignore-aware drawer prune")
print(f"{'=' * 55}")
print(f" Palace: {palace_path}")
if payload.get("wing"):
print(f" Wing: {payload['wing']}")
if project_dirs:
for project_dir in project_dirs:
print(f" Project: {project_dir}")
print(
" Mode: DRY RUN (no deletions)" if dry_run else " Mode: APPLY (deleting drawers)"
)
print(f"{'-' * 55}\n")
try:
from .sync import sync_palace
from .wal import _wal_log
report = sync_palace(
palace_path=palace_path,
project_dirs=project_dirs,
wing=payload.get("wing"),
dry_run=dry_run,
wal_log=_wal_log,
)
except MineAlreadyRunning as exc:
return {
"success": False,
"error": str(exc),
"error_class": "LockHeldByOtherProcess",
"exit_code": 1,
}
except ValueError as exc:
return {"success": False, "error": str(exc), "exit_code": 2}
except Exception as exc:
return {"success": False, "error": f"sync failed: {exc}", "exit_code": 1}
removed_suffix = "(would remove)" if dry_run else "(removed)"
print(f" Scanned: {report['scanned']}")
print(f" Kept: {report['kept']}")
print(f" Gitignored: {report['gitignored']} {removed_suffix}")
print(f" Missing: {report['missing']} {removed_suffix}")
print(f" No source: {report['no_source']} (kept)")
print(f" Out of scope: {report['out_of_scope']} (kept)")
by_source = report.get("by_source") or {}
if by_source:
top = sorted(by_source.items(), key=lambda kv: -kv[1])[:5]
label = "Top sources to remove" if dry_run else "Top sources removed"
print(f"\n {label}:")
for src, n in top:
print(f" {src} ({n})")
if dry_run:
if report["gitignored"] + report["missing"] > 0:
print("\n Re-run with --apply to commit these deletions.")
else:
print(
f"\n Removed {report['removed_drawers']} drawers, {report['removed_closets']} closets."
)
print(f"\n{'=' * 55}\n")
return {"success": True, "report": report, "exit_code": 0}
def run_diary_write(payload: dict[str, Any]) -> dict[str, Any]:
palace_path = payload.get("palace_path")
if palace_path:
os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(os.path.expanduser(palace_path))
_apply_backend(payload.get("backend"))
from .mcp_server import tool_diary_write
result = tool_diary_write(
agent_name=payload.get("agent_name") or "mempalace",
entry=payload.get("entry") or "",
topic=payload.get("topic") or "general",
wing=payload.get("wing") or "",
)
result.setdefault("exit_code", 0 if result.get("success") else 1)
return result
def run_mcp_tool(payload: dict[str, Any]) -> dict[str, Any]:
"""Execute an MCP tool by name over the daemon queue.
The daemon is a durable, retried write surface — not a general MCP transport.
Restrict ``mcp_tool`` to write-classified tools only: read tools would
exfiltrate verbatim palace content into the queue DB and the job result
(stored world-readable-by-default without the perms fix, and returned over
/jobs), and maintenance tools already have their own dedicated kinds
(mine/sync). No internal caller currently uses ``mcp_tool``; this allowlist
bounds the blast radius of the generic escape hatch.
"""
name = payload.get("name")
arguments = payload.get("arguments") or {}
if not isinstance(arguments, dict):
return {"success": False, "error": "arguments must be an object", "exit_code": 2}
classification = classify_tool(name) if name else "unknown"
if classification != "write":
return {
"success": False,
"error": f"daemon mcp_tool only accepts write tools; {name!r} is {classification}",
"exit_code": 2,
}
from .mcp_server import TOOLS
if name not in TOOLS:
return {"success": False, "error": f"unknown MCP tool: {name}", "exit_code": 2}
result = TOOLS[name]["handler"](**arguments)
if isinstance(result, dict):
# Several write tools signal failure with a bare {"error": ...} and no
# explicit success flag (e.g. tool_create_tunnel / tool_delete_tunnel
# validation paths). Infer failure from the "error" key so the daemon
# does not persist a failed write as succeeded with exit_code 0.
if "success" not in result:
result["success"] = "error" not in result
result.setdefault("exit_code", 0 if result.get("success") else 1)
return result
return {"success": True, "value": result, "exit_code": 0}
def print_job_result(result: dict[str, Any]) -> int:
"""Replay captured daemon job output and return the intended process exit code."""
stdout = result.get("stdout")
stderr = result.get("stderr")
if stdout:
print(stdout, end="")
if stderr:
print(stderr, end="", file=sys.stderr)
if not result.get("success", True) and result.get("error") and not stderr:
print(f"mempalace: {result['error']}", file=sys.stderr)
return int(result.get("exit_code", 0 if result.get("success", True) else 1) or 0)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""
__version__ = "3.4.1"
__version__ = "3.6.0"
+97
View File
@@ -0,0 +1,97 @@
"""Side-effect-free write-ahead log for MemPalace write operations.
This lives in its own module so callers that only need WAL audit logging — the
CLI ``sync`` path and the daemon's ``service`` layer — can obtain ``_wal_log``
without importing :mod:`mempalace.mcp_server`. Importing ``mcp_server`` runs its
module-level stdio protection (``os.dup2(2, 1)`` and ``sys.stdout = sys.stderr``,
required so the MCP stdio JSON stream isn't corrupted by C-level library
banners). In a non-MCP process — e.g. the daemon worker or ``mempalace sync`` —
that redirect is an unwanted import side effect that misroutes operator output,
so the WAL machinery is kept here, free of any such side effects.
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
_WAL_FILE = Path(os.path.expanduser("~/.mempalace/wal")) / "write_log.jsonl"
_WAL_INITIALIZED_DIR = None
# Keys whose values should be redacted in WAL entries to avoid logging sensitive content
_WAL_REDACT_KEYS = frozenset(
{"content", "content_preview", "document", "entry", "entry_preview", "query", "text"}
)
def _ensure_wal() -> None:
"""Create (and re-harden) the WAL directory lazily, on the first write.
This must NOT run at import time: a user who removed ``~/.mempalace`` has
engaged the documented kill-switch (``hooks_cli._palace_root_exists()``,
#1305), and recreating the directory just by importing this module would
silently re-arm the autosave/mining hooks they disabled (#1676). Creating
it on the first real write keeps the kill-switch contract intact.
It is deliberately not gated on ``_palace_root_exists()``: by the time a
write reaches here the palace is already being recreated by the ChromaDB/KG
layer regardless, so gating would only drop audit records, not prevent
recreation. Runtime kill-switch enforcement for MCP writes is the broader
question tracked in #504.
Hardening is attempted once per directory and the path cached in
``_WAL_INITIALIZED_DIR`` regardless of outcome (keyed on the path, so a
test repointing ``_WAL_FILE`` re-initialises), so a persistent failure on a
restricted filesystem does not retry on every write. ``mkdir`` runs only
when the initial ``chmod`` raises ``FileNotFoundError`` (EAFP). The parent
``~/.mempalace`` keeps its umask mode, like the other palace directories;
the WAL file is created atomically with mode 0o600 by ``_wal_log``.
"""
global _WAL_INITIALIZED_DIR
wal_dir = _WAL_FILE.parent
if _WAL_INITIALIZED_DIR == wal_dir:
return
try:
wal_dir.chmod(0o700)
except FileNotFoundError:
try:
wal_dir.mkdir(parents=True, exist_ok=True)
wal_dir.chmod(0o700)
except (OSError, NotImplementedError):
pass
except (OSError, NotImplementedError):
pass
# Cache regardless of outcome: one attempt per directory, so a persistent
# chmod/mkdir failure (restricted FS) is not retried on every write.
_WAL_INITIALIZED_DIR = wal_dir
def _wal_log(operation: str, params: dict, result: dict = None):
"""Append a write operation to the write-ahead log."""
# Redact sensitive content from params before logging
safe_params = {}
for k, v in params.items():
if k in _WAL_REDACT_KEYS:
safe_params[k] = f"[REDACTED {len(v)} chars]" if isinstance(v, str) else "[REDACTED]"
else:
safe_params[k] = v
entry = {
"timestamp": datetime.now().isoformat(),
"operation": operation,
"params": safe_params,
"result": result,
}
try:
# Dir setup shares the append's exception handler below: any WAL
# failure is logged and non-fatal, never crashing the tool call.
_ensure_wal()
fd = os.open(str(_WAL_FILE), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
with os.fdopen(fd, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, default=str) + "\n")
except Exception as e:
logger.error(f"WAL write failed: {e}")
+210
View File
@@ -0,0 +1,210 @@
"""Shared daemon-routing policy for MemPalace write callers.
The policy is deliberately transport-agnostic. Hook and CLI consumers decide
whether a daemon is already available and whether they are allowed to start
one; this module turns those facts plus a policy into one explicit route.
This module changes no caller defaults by itself. Hook and CLI adoption are
separate follow-up PRs.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Iterable
class WriteRoutingError(ValueError):
"""Raised when a write-routing policy is invalid or cannot be applied."""
class WriteRoutingPolicy(str, Enum):
"""User-selected policy for routine write operations."""
DIRECT = "direct"
PREFER = "prefer"
REQUIRE = "require"
class WriteRoutingTarget(str, Enum):
"""Concrete route selected for one write operation."""
DIRECT = "direct"
DAEMON = "daemon"
BLOCKED = "blocked"
@dataclass(frozen=True)
class RoutingPolicyCandidate:
"""One precedence-ordered source for a routing policy."""
source: str
value: Any
legacy_boolean: bool = False
@dataclass(frozen=True)
class ResolvedWriteRoutingPolicy:
"""A normalized policy plus the source that selected it."""
policy: WriteRoutingPolicy
source: str
@dataclass(frozen=True)
class WriteRoutingDecision:
"""Concrete routing decision for a single operation."""
policy: WriteRoutingPolicy
target: WriteRoutingTarget
auto_start_daemon: bool
reason: str
@property
def use_daemon(self) -> bool:
"""Whether this operation should use the daemon."""
return self.target is WriteRoutingTarget.DAEMON
@property
def blocked(self) -> bool:
"""Whether the operation must stop instead of writing directly."""
return self.target is WriteRoutingTarget.BLOCKED
_LEGACY_TRUE = {"1", "true", "yes", "on", "daemon"}
_LEGACY_FALSE = {"0", "false", "no", "off"}
def parse_write_routing_policy(
value: Any,
*,
legacy_boolean: bool = False,
) -> WriteRoutingPolicy:
"""Normalize one routing-policy value.
New policy settings accept only ``direct``, ``prefer``, and ``require``.
Legacy boolean settings additionally map truthy values to ``prefer`` and
falsy values to ``direct`` so existing ``hooks.daemon`` configurations
retain their historical behavior.
"""
if isinstance(value, WriteRoutingPolicy):
return value
if legacy_boolean and isinstance(value, bool):
return WriteRoutingPolicy.PREFER if value else WriteRoutingPolicy.DIRECT
if legacy_boolean and isinstance(value, int) and not isinstance(value, bool):
if value == 1:
return WriteRoutingPolicy.PREFER
if value == 0:
return WriteRoutingPolicy.DIRECT
if not isinstance(value, str):
raise WriteRoutingError("write routing policy must be one of: direct, prefer, require")
normalized = value.strip().lower()
try:
return WriteRoutingPolicy(normalized)
except ValueError:
pass
if legacy_boolean:
if normalized in _LEGACY_TRUE:
return WriteRoutingPolicy.PREFER
if normalized in _LEGACY_FALSE:
return WriteRoutingPolicy.DIRECT
raise WriteRoutingError(
f"invalid write routing policy {value!r}; expected direct, prefer, or require"
)
def resolve_write_routing_policy(
candidates: Iterable[RoutingPolicyCandidate],
*,
default: WriteRoutingPolicy = WriteRoutingPolicy.DIRECT,
) -> ResolvedWriteRoutingPolicy:
"""Return the first configured policy from ordered policy sources."""
for candidate in candidates:
if candidate.value is None:
continue
try:
policy = parse_write_routing_policy(
candidate.value,
legacy_boolean=candidate.legacy_boolean,
)
except WriteRoutingError as exc:
raise WriteRoutingError(f"{candidate.source}: {exc}") from exc
return ResolvedWriteRoutingPolicy(
policy=policy,
source=candidate.source,
)
return ResolvedWriteRoutingPolicy(
policy=default,
source="default",
)
def choose_write_route(
policy: WriteRoutingPolicy,
*,
daemon_available: bool,
daemon_can_start: bool,
) -> WriteRoutingDecision:
"""Choose direct, daemon, or blocked for one routine write.
``daemon_can_start`` is normally false for latency-sensitive hooks and
true for interactive CLI commands.
The key safety guarantee is that ``require`` never degrades to a direct
write when the daemon is unavailable.
"""
policy = parse_write_routing_policy(policy)
if policy is WriteRoutingPolicy.DIRECT:
return WriteRoutingDecision(
policy=policy,
target=WriteRoutingTarget.DIRECT,
auto_start_daemon=False,
reason="policy-direct",
)
if daemon_available:
return WriteRoutingDecision(
policy=policy,
target=WriteRoutingTarget.DAEMON,
auto_start_daemon=False,
reason="daemon-available",
)
if daemon_can_start:
return WriteRoutingDecision(
policy=policy,
target=WriteRoutingTarget.DAEMON,
auto_start_daemon=True,
reason="daemon-auto-start",
)
if policy is WriteRoutingPolicy.PREFER:
return WriteRoutingDecision(
policy=policy,
target=WriteRoutingTarget.DIRECT,
auto_start_daemon=False,
reason="daemon-unavailable-fallback",
)
return WriteRoutingDecision(
policy=policy,
target=WriteRoutingTarget.BLOCKED,
auto_start_daemon=False,
reason="daemon-required-unavailable",
)
+19 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.4.1"
version = "3.6.0"
description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required."
readme = "README.md"
requires-python = ">=3.9"
@@ -60,6 +60,7 @@ mempalace-mcp = "mempalace.mcp_server:main"
[project.entry-points."mempalace.backends"]
chroma = "mempalace.backends.chroma:ChromaBackend"
milvus = "mempalace.backends.milvus:MilvusBackend"
pgvector = "mempalace.backends.pgvector:PgVectorBackend"
qdrant = "mempalace.backends.qdrant:QdrantBackend"
sqlite_exact = "mempalace.backends.sqlite_exact:SQLiteExactBackend"
@@ -74,7 +75,11 @@ sqlite_exact = "mempalace.backends.sqlite_exact:SQLiteExactBackend"
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"ruff==0.15.15",
# Retries known-transient ChromaDB-on-Windows HNSW compaction failures in CI
# (wired only into the test-windows job in ci.yml, scoped to the specific
# error). Local/Linux/macOS runs never rerun, so real failures stay loud.
"pytest-rerunfailures>=12.0",
"ruff==0.15.20",
"psutil>=5.9",
# Property-based testing — generates hundreds of random inputs per
# test to find counterexamples the hand-written positive tests miss.
@@ -95,6 +100,13 @@ dev = [
"mypy>=1.0",
]
spellcheck = ["autocorrect>=2.0"]
# Opt-in Milvus backend. Defaults to per-palace Milvus Lite
# (`<palace>/milvus.db`); set MEMPALACE_MILVUS_URI and
# MEMPALACE_MILVUS_TOKEN to point at Milvus server or Zilliz Cloud.
milvus = [
"pymilvus>=3.0.0",
"milvus-lite>=3.0; python_version >= '3.10' and sys_platform != 'win32'",
]
# Opt-in Postgres + pgvector backend. Only the psycopg driver is needed on the
# client; the server must have the `vector` extension available. Selected via
# MEMPALACE_BACKEND=pgvector / --backend pgvector; never required for the
@@ -131,7 +143,11 @@ extract = [
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"ruff==0.15.15",
# Retries known-transient ChromaDB-on-Windows HNSW compaction failures in CI
# (wired only into the test-windows job in ci.yml, scoped to the specific
# error). Local/Linux/macOS runs never rerun, so real failures stay loud.
"pytest-rerunfailures>=12.0",
"ruff==0.15.20",
"psutil>=5.9",
"hypothesis>=6.0",
"pre-commit>=3.0",
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Backfill ``authored_at`` onto existing conversation drawers.
New mines stamp ``authored_at`` automatically (see ``convo_miner._extract_authored_at``),
but drawers mined before that change only have ``filed_at`` (ingest time). Re-mining does
NOT fix them: the scanner skips files already mined at the current ``NORMALIZE_VERSION``.
This migration updates the affected drawers IN PLACE — metadata only, embeddings are left
untouched, so there is no re-embedding cost. It is idempotent (drawers already correct are
skipped) and safe to re-run. It only touches ``ingest_mode == "convos"`` drawers; markdown
drawers have no per-line timestamps and keep their ``filed_at`` fallback.
Drawers whose source transcript is no longer on disk are left as-is (they keep falling back
to ``filed_at``), so point ``--sessions`` at the directories that still hold your ``.jsonl``
transcripts (e.g. ``~/.claude`` and ``~/.codex``).
Usage (dry-run prints what would change; pass --apply to write):
python scripts/backfill_authored_at.py \
--palace ~/.mempalace/palace \
--sessions ~/.claude --sessions ~/.codex [--apply]
In Docker (the MCP image), mount the volume and your session dirs read-only:
docker run --rm \
-v mempalace-data:/data \
-v ~/.claude:/sessions/claude:ro -v ~/.codex:/sessions/codex:ro \
-v "$PWD/scripts/backfill_authored_at.py:/tmp/backfill.py:ro" \
--entrypoint /app/.venv/bin/python mempalace:local \
/tmp/backfill.py --palace /data/.mempalace/palace \
--sessions /sessions/claude --sessions /sessions/codex --apply
"""
import argparse
import glob
import os
import chromadb
from mempalace.convo_miner import _extract_authored_at
COLLECTION = "mempalace_drawers"
PAGE = 2000
BATCH = 1000
def _index_sessions(session_dirs):
"""Map ``basename.jsonl -> realpath`` for every transcript under the given dirs."""
index = {}
for root in session_dirs:
for f in glob.glob(os.path.join(os.path.expanduser(root), "**", "*.jsonl"), recursive=True):
index.setdefault(os.path.basename(f), f)
return index
def backfill_authored_at(collection, session_dirs, apply=False):
"""Stamp ``authored_at`` on convos drawers from their source transcript timestamps.
Returns a stats dict: ``scanned``, ``updated``, ``resolved_files``, ``unresolved_files``.
"""
index = _index_sessions(session_dirs)
cache = {}
unresolved = set()
pending_ids, pending_metas = [], []
scanned = updated = 0
def flush():
nonlocal pending_ids, pending_metas, updated
if pending_ids and apply:
collection.update(ids=pending_ids, metadatas=pending_metas)
updated += len(pending_ids)
pending_ids, pending_metas = [], []
offset = 0
while True:
res = collection.get(
where={"ingest_mode": "convos"}, include=["metadatas"], limit=PAGE, offset=offset
)
ids = res["ids"]
if not ids:
break
for drawer_id, meta in zip(ids, res["metadatas"]):
scanned += 1
basename = os.path.basename(meta.get("source_file") or "")
if basename in cache:
authored = cache[basename]
else:
path = index.get(basename)
authored = _extract_authored_at(path) if path else None
cache[basename] = authored
if path is None and basename:
unresolved.add(basename)
if authored and meta.get("authored_at") != authored:
new_meta = dict(meta)
new_meta["authored_at"] = authored
pending_ids.append(drawer_id)
pending_metas.append(new_meta)
if len(pending_ids) >= BATCH:
flush()
offset += len(ids)
flush()
return {
"scanned": scanned,
"updated": updated,
"resolved_files": sum(1 for v in cache.values() if v),
"unresolved_files": len(unresolved),
}
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--palace", required=True, help="Path to the ChromaDB palace dir")
parser.add_argument(
"--sessions",
action="append",
default=[],
required=True,
help="Directory holding .jsonl transcripts (repeatable)",
)
parser.add_argument(
"--apply",
action="store_true",
help="Write changes (default is a dry run that only reports counts)",
)
args = parser.parse_args()
client = chromadb.PersistentClient(path=os.path.expanduser(args.palace))
collection = client.get_collection(COLLECTION)
stats = backfill_authored_at(collection, args.sessions, apply=args.apply)
mode = "APPLIED" if args.apply else "DRY-RUN (use --apply to write)"
print(
f"{mode}: scanned={stats['scanned']} updated={stats['updated']} "
f"resolved_files={stats['resolved_files']} unresolved_files={stats['unresolved_files']}"
)
if __name__ == "__main__":
main()
+14
View File
@@ -90,6 +90,20 @@ question — not a system prompt or pasted conversation) plus optional
- **MCP error / server down.** Surface the error and suggest the user
run `mempalace status` or re-run `/mempalace-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 vector index is out of sync with
`chroma.sqlite3` while the drawer rows remain intact. Tell the user to
stop the server and rebuild from SQLite — do not re-mine, which drops
MCP-added drawers and diary entries (#1843):
```bash
mempalace repair --mode from-sqlite --archive-existing --yes
mempalace repair-status
```
Do not attempt an in-process repair from the agent. Full steps are in
the shared protocol's "Recovering a corrupt index" section.
- **Conflicting facts.** Trust the knowledge graph's time-valid answer;
invalidate-then-add rather than overwriting silently.
+1 -1
View File
@@ -42,6 +42,6 @@ search-before-answer so the agent reads the palace instead of guessing.
## Cursor-specific notes
- The `mempalace-mcp` server is auto-registered by this plugin. Once installed, all 33 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, etc.) are available to the agent without any further configuration.
- The `mempalace-mcp` server is auto-registered by this plugin. Once installed, all 36 MemPalace MCP tools (`mempalace_search`, `mempalace_add_drawer`, `mempalace_diary_write`, `mempalace_check_duplicate`, `mempalace_diary_read`, etc.) are available to the agent without any further configuration.
- For automatic background saving every N agent turns plus session-start memory recall, also install the Cursor hooks separately by running `hooks/cursor/install.sh --scope user` from a cloned MemPalace repo. See [`website/guide/cursor-hooks.md`](../../website/guide/cursor-hooks.md) for the full walkthrough.
- The recommended `agent_name` when calling `mempalace_diary_write` from a Cursor session is `cursor-ide` (matches the precedent of `claude-code` and `codex`).
+57
View File
@@ -0,0 +1,57 @@
"""Shared helpers: create minimal valid palace-marker SQLite files for tests.
Many tests want to stand up "a chroma / sqlite_exact palace" cheaply —
historically they did this with ``(path / "<marker>.sqlite3").touch()`` or
``write_bytes(b"")``, relying on the backend ``detect()`` methods' old
``os.path.isfile()`` semantics. Post-#1893, both ``ChromaBackend.detect()``
and ``SQLiteExactBackend.detect()`` require a valid SQLite magic header, so
the empty stand-in no longer registers. These helpers create the minimum
required to make detection fire without standing up a full palace.
This module is intentionally not a ``test_*`` file: it ships utilities, not
tests.
"""
import sqlite3
from pathlib import Path
from typing import Union
def _write_minimal_sqlite_file(db_path: Path) -> None:
"""Write a valid SQLite magic header at ``db_path``.
Writing any statement is sufficient to land the 16-byte
``SQLite format 3\\x00`` magic prefix that the backend ``detect()``
methods check.
"""
conn = sqlite3.connect(db_path)
try:
conn.execute("CREATE TABLE _detect_smoke(x)")
conn.commit()
finally:
conn.close()
def make_minimal_chroma_sqlite(palace_path: Union[Path, str]) -> Path:
"""Create ``<palace_path>/chroma.sqlite3`` with a valid SQLite header.
Returns the path to the file. Backs
:py:meth:`mempalace.backends.chroma.ChromaBackend.detect`.
"""
db_path = Path(palace_path) / "chroma.sqlite3"
_write_minimal_sqlite_file(db_path)
return db_path
def make_minimal_sqlite_exact_sqlite(palace_path: Union[Path, str]) -> Path:
"""Create ``<palace_path>/sqlite_exact.sqlite3`` with a valid SQLite header.
Returns the path to the file. Backs
:py:meth:`mempalace.backends.sqlite_exact.SQLiteExactBackend.detect`.
"""
db_path = Path(palace_path) / "sqlite_exact.sqlite3"
_write_minimal_sqlite_file(db_path)
return db_path
+39 -1
View File
@@ -77,6 +77,17 @@ def _reset_mcp_cache():
if hasattr(mcp_server, "_kg_by_path"):
mcp_server._kg_by_path.clear()
# Close (not just dereference) the cached chromadb client so its
# rust-side file handles are released; on Windows a bare deref
# leaves them locked and leaks across the session (#1128).
cached_client = getattr(mcp_server, "_client_cache", None)
if cached_client is not None:
close = getattr(cached_client, "close", None)
if callable(close):
try:
close()
except Exception:
pass
mcp_server._client_cache = None
mcp_server._collection_cache = None
if hasattr(mcp_server, "_collection_cache_backend"):
@@ -97,6 +108,29 @@ def _reset_mcp_cache():
except (ImportError, AttributeError):
pass
# Release chromadb clients opened through the backend layer. Many tests
# reach the store via palace.get_collection() (sweep, repair, CLI, ...),
# which caches one PersistentClient per palace_path on the long-lived
# backend singleton and never closes it. chromadb frees the rust-side
# SQLite/HNSW file handles only on client.close(); on POSIX the open
# handles are harmless, but on Windows they stay locked and accumulate
# across the session until a later test's HNSW segment write fails
# (#1128 Windows CI). close_palace() closes the client and drops the
# handle without marking the backend closed, so it stays reusable.
try:
from mempalace import palace as _palace
backend = getattr(_palace, "_DEFAULT_BACKEND", None)
clients = getattr(backend, "_clients", None)
if clients:
for path in list(clients):
try:
backend.close_palace(path)
except Exception:
pass
except (ImportError, AttributeError):
pass
_clear_cache()
yield
_clear_cache()
@@ -154,7 +188,11 @@ def collection(palace_path):
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
yield col
client.delete_collection("mempalace_drawers")
del client
# close() (not a bare dereference) releases chromadb's rust-side SQLite/HNSW
# file handles. On Windows a mere `del` leaves them locked, so the temp
# palace cannot be removed and handles leak across the whole test session
# until a later test's HNSW write fails (#1128 Windows CI).
client.close()
@pytest.fixture
+35 -3
View File
@@ -178,12 +178,44 @@ def test_resolve_backend_priority_order(tmp_path):
assert resolve_backend_for_palace() == "chroma"
def test_chroma_detect_matches_palace_with_chroma_sqlite(tmp_path):
(tmp_path / "chroma.sqlite3").write_bytes(b"")
def test_chroma_detect_matches_palace_with_sqlite_header(tmp_path):
"""A real SQLite database at ``<path>/chroma.sqlite3`` registers as chroma.
Uses ``sqlite3.connect`` + a write so the SQLite magic header is actually
on disk — the only thing detection looks at.
"""
db_path = tmp_path / "chroma.sqlite3"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE detect_smoke(x)")
conn.commit()
conn.close()
assert ChromaBackend.detect(str(tmp_path)) is True
assert ChromaBackend.detect(str(tmp_path.parent)) is False
def test_chroma_detect_rejects_empty_chroma_sqlite(tmp_path):
"""A 0-byte ``chroma.sqlite3`` is not a chroma palace (closes #1893).
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 connect. Detection must reject that artifact so it cannot trip
``BackendMismatchError`` against a real non-chroma backend marker in the
same directory.
"""
(tmp_path / "chroma.sqlite3").write_bytes(b"")
assert ChromaBackend.detect(str(tmp_path)) is False
def test_chroma_detect_rejects_non_sqlite_file(tmp_path):
"""A non-SQLite file at the ``chroma.sqlite3`` path is not chroma.
Defends against partial writes / garbage content / anything that lands at
the canonical path but isn't actually a SQLite database.
"""
(tmp_path / "chroma.sqlite3").write_bytes(b"not a sqlite file" * 4)
assert ChromaBackend.detect(str(tmp_path)) is False
def test_chroma_lexical_search_uses_sqlite_fts_not_full_collection_scan(tmp_path):
db_path = tmp_path / "chroma.sqlite3"
conn = sqlite3.connect(db_path)
@@ -1810,7 +1842,7 @@ def test_explain_ef_mismatch_recognizes_chromadb_conflict():
assert msg is not None
assert "/tmp/palace.db" in msg
assert "MEMPALACE_EMBEDDING_MODEL" in msg
assert "rebuild-index" in msg
assert "mempalace --palace /tmp/palace.db repair rebuild-index" in msg
def test_explain_ef_mismatch_returns_none_for_unrelated_errors():
+86
View File
@@ -0,0 +1,86 @@
"""Integration tests for the authored_at backfill migration (scripts/)."""
import importlib.util
import uuid
from pathlib import Path
import chromadb
# The migration ships as a script, not a package module; load it directly.
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "backfill_authored_at.py"
_spec = importlib.util.spec_from_file_location("backfill_authored_at", _SCRIPT)
backfill_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(backfill_mod)
def _collection():
# Unique name per call: EphemeralClient shares one in-memory instance across the
# process, so a fixed collection name would leak drawers between tests.
client = chromadb.EphemeralClient()
return client.create_collection(f"drawers_{uuid.uuid4().hex}")
def _add(col, drawer_id, source_file, authored_at=None):
meta = {"ingest_mode": "convos", "source_file": source_file, "filed_at": "2026-06-27T00:00:00"}
if authored_at is not None:
meta["authored_at"] = authored_at
col.add(ids=[drawer_id], documents=["hello"], metadatas=[meta], embeddings=[[0.1, 0.2, 0.3]])
def _transcript(dir_path, name, *timestamps):
dir_path.mkdir(parents=True, exist_ok=True)
f = dir_path / name
f.write_text("".join(f'{{"timestamp": "{ts}"}}\n' for ts in timestamps))
return f
def test_backfill_sets_latest_timestamp(tmp_path):
sessions = tmp_path / "claude"
_transcript(sessions, "abc.jsonl", "2026-06-10T08:00:00.000Z", "2026-06-12T09:00:00.000Z")
col = _collection()
# Stored source_file uses an old mount prefix; resolution is by basename.
_add(col, "d1", "/old/mount/abc.jsonl")
stats = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
assert stats["scanned"] == 1
assert stats["updated"] == 1
got = col.get(ids=["d1"], include=["metadatas"])["metadatas"][0]
assert got["authored_at"] == "2026-06-12T09:00:00.000Z"
def test_dry_run_writes_nothing(tmp_path):
sessions = tmp_path / "claude"
_transcript(sessions, "abc.jsonl", "2026-06-12T09:00:00.000Z")
col = _collection()
_add(col, "d1", "/old/mount/abc.jsonl")
stats = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=False)
assert stats["updated"] == 1 # would update
assert "authored_at" not in col.get(ids=["d1"], include=["metadatas"])["metadatas"][0]
def test_idempotent_second_run_updates_nothing(tmp_path):
sessions = tmp_path / "claude"
_transcript(sessions, "abc.jsonl", "2026-06-12T09:00:00.000Z")
col = _collection()
_add(col, "d1", "/old/mount/abc.jsonl")
backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
stats2 = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
assert stats2["updated"] == 0
def test_unresolved_transcript_is_left_alone(tmp_path):
sessions = tmp_path / "claude"
sessions.mkdir()
col = _collection()
_add(col, "d1", "/old/mount/missing.jsonl") # no file on disk
stats = backfill_mod.backfill_authored_at(col, [str(sessions)], apply=True)
assert stats["updated"] == 0
assert stats["unresolved_files"] == 1
assert "authored_at" not in col.get(ids=["d1"], include=["metadatas"])["metadatas"][0]
+25
View File
@@ -19,8 +19,14 @@ HOOK_CONFIG = REPO_ROOT / ".claude-plugin" / "hooks" / "hooks.json"
# timeout of 60s in mempalace/hooks_cli.py. The hook-level floor of 60
# keeps the inner bound from being truncated, and the ceiling of 90
# bounds the worst case at ~30s above that.
# SessionEnd backgrounds all of its work in the shell wrapper — the foreground
# only forks the detached child and returns in milliseconds — so its timeout is
# a generous backstop on a near-instant operation, not a synchronous-work bound
# like Stop/PreCompact. A bound is still required (#1465) so a wedged fork can
# never fall back to the 600s command default.
EVENT_TIMEOUT_BOUNDS: dict[str, tuple[int, int]] = {
"Stop": (10, 30),
"SessionEnd": (5, 30),
"PreCompact": (60, 90),
}
@@ -86,3 +92,22 @@ def test_no_unbounded_events_in_plugin_config(hook_config: dict) -> None:
"Add a (floor, ceiling) entry to EVENT_TIMEOUT_BOUNDS in this test "
"after deciding the worst-case freeze the event can tolerate."
)
def test_session_end_hook_uses_background_wrapper(hook_config: dict) -> None:
"""Claude SessionEnd should use the backgrounding wrapper, not PreCompact."""
events = hook_config.get("hooks", {})
assert "SessionEnd" in events
assert "PreCompact" in events
assert events["SessionEnd"] != events["PreCompact"]
commands = [
hook["command"]
for entry in events["SessionEnd"]
for hook in entry.get("hooks", [])
if hook.get("type") == "command"
]
assert any("mempal-session-end-hook.sh" in command for command in commands)
assert not any("mempal-precompact-hook.sh" in command for command in commands)
+163
View File
@@ -0,0 +1,163 @@
"""Tests for embedded-NUL sanitisation in the bulk-ingest write path.
Mirrors ``test_clean_lone_surrogates.py`` (issue #1235) for a sibling class of
bad input: a document containing an embedded NUL character (U+0000).
Unlike a lone surrogate, a NUL-containing string is well-formed UTF-8 — it
doesn't raise on ``.encode()``. 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. This is routine in mined Bash
tool output — e.g. a reader racing a background writer, or genuine
NUL-delimited/binary command output captured verbatim.
``sanitize_content`` (the MCP write-tool path) already rejects NUL bytes
outright (``ValueError``) — that behaviour is unchanged and still tested
here for completeness. The bulk ingest paths (miner, convo_miner, sweeper,
diary_ingest) build documents without routing through that helper and reach
``ChromaCollection`` directly, exactly as #1235 found for surrogates. This
file covers the chokepoint fix that closes that gap: replace, not reject, so
a NUL-containing chunk doesn't abort or corrupt an otherwise-good mine.
"""
from mempalace.config import strip_nul_bytes
# ── Unit tests ─────────────────────────────────────────────────────────────
class TestStripNulBytes:
def test_passthrough_normal(self):
assert strip_nul_bytes("hello world") == "hello world"
assert strip_nul_bytes("你好世界") == "你好世界"
def test_empty_string(self):
assert strip_nul_bytes("") == ""
def test_replaces_single_nul(self):
assert strip_nul_bytes("hello\x00world") == "hello�world"
def test_replaces_multiple_nuls(self):
assert strip_nul_bytes("\x00\x00\x00") == "���"
assert strip_nul_bytes("a\x00b\x00c") == "a�b�c"
def test_replaces_large_nul_run(self):
"""The reporter-shaped case: a large contiguous NUL run inside
otherwise-ordinary captured tool output."""
dirty = "before-nuls " + ("\x00" * 10291) + " after-nuls"
clean = strip_nul_bytes(dirty)
assert "\x00" not in clean
assert clean.startswith("before-nuls ")
assert clean.endswith(" after-nuls")
assert clean.count("�") == 10291
def test_preserves_real_content_around_nuls(self):
assert (
strip_nul_bytes("ordinary log line one\x00\x00\x00ordinary log line two")
== "ordinary log line one���ordinary log line two"
)
# ── Existing sanitizer behavior (unchanged) ─────────────────────────────────
class TestSanitizeContentStillRejectsNuls:
"""sanitize_content (the MCP write-tool path) already guards against NUL
bytes by rejecting them outright — that's a reasonable choice for an
interactive tool call and is untouched by this fix. Verifies it still
holds now that a sibling path (the backend chokepoint) takes the
replace-not-reject approach instead, for the bulk-ingest paths where
rejecting one bad chunk would otherwise abort or corrupt a whole mine.
"""
def test_sanitize_content_rejects_nul(self):
from mempalace.config import sanitize_content
import pytest
with pytest.raises(ValueError, match="null bytes"):
sanitize_content("hello\x00world")
# ── Backend chokepoint (bulk ingest paths) ──────────────────────────────────
class _CapturingCollection:
"""Minimal chromadb.Collection stand-in that records the kwargs it receives,
so we can assert what actually reaches the chromadb client."""
def __init__(self):
self.calls = []
def add(self, **kwargs):
self.calls.append(("add", kwargs))
def upsert(self, **kwargs):
self.calls.append(("upsert", kwargs))
def update(self, **kwargs):
self.calls.append(("update", kwargs))
class TestBackendChokepointStripsNulBytes:
"""The bulk ingest paths (miner, convo_miner, sweeper, diary_ingest) build
documents without routing through ``sanitize_content`` and reach
``ChromaCollection`` directly — the same gap #1235 found for surrogates.
A NUL byte in *document* text can corrupt the FTS5 inverted index for the
whole collection. The backend chokepoint must strip it, mirroring the
existing surrogate handling in the same method."""
@staticmethod
def _collection():
from mempalace.backends.chroma import ChromaCollection
fake = _CapturingCollection()
return fake, ChromaCollection(fake)
def test_add_strips_nul_in_document(self):
fake, col = self._collection()
col.add(documents=["clean\x00doc"], ids=["1"])
_, kwargs = fake.calls[0]
assert kwargs["documents"] == ["clean�doc"]
def test_upsert_strips_nul_in_document(self):
fake, col = self._collection()
col.upsert(documents=["a\x00b"], ids=["1"], metadatas=[{"wing": "w"}])
_, kwargs = fake.calls[0]
assert kwargs["documents"] == ["a�b"]
def test_update_strips_nul_in_document(self):
fake, col = self._collection()
col.update(ids=["1"], documents=["x\x00y"])
_, kwargs = fake.calls[0]
assert kwargs["documents"] == ["x�y"]
def test_one_nul_message_does_not_corrupt_the_batch(self):
"""The reporter's shape: a single NUL-heavy row alongside ordinary
ones in the same batch. All rows survive, sanitised."""
fake, col = self._collection()
col.upsert(
documents=["ok one", "poison\x00row", "ok three"],
ids=["1", "2", "3"],
)
_, kwargs = fake.calls[0]
assert kwargs["documents"] == ["ok one", "poison�row", "ok three"]
assert len(kwargs["ids"]) == 3
def test_both_surrogates_and_nuls_in_same_document(self):
"""Both sanitizers apply to the same document — order doesn't
leave either kind of bad character behind."""
fake, col = self._collection()
col.upsert(documents=["a\udc95b\x00c"], ids=["1"])
_, kwargs = fake.calls[0]
assert kwargs["documents"] == ["a�b�c"]
def test_single_string_document_is_not_split_into_chars(self):
"""chromadb accepts a bare str as one document (OneOrMany[Document]).
The sanitiser must keep it whole and clean, not split it into
per-character documents."""
fake, col = self._collection()
col.upsert(documents="one\x00document", ids=["1"])
_, kwargs = fake.calls[0]
assert kwargs["documents"] == "one�document"
assert isinstance(kwargs["documents"], str)
+259
View File
@@ -17,6 +17,7 @@ from mempalace.cli import (
cmd_hook,
cmd_init,
cmd_instructions,
cmd_daemon,
cmd_mine,
cmd_repair,
cmd_search,
@@ -166,6 +167,13 @@ def test_cmd_hook_calls_run_hook():
mock_run.assert_called_once_with(hook_name="session-start", harness="claude-code")
def test_cmd_hook_session_end_calls_run_hook():
args = argparse.Namespace(hook="session-end", harness="claude-code")
with patch("mempalace.hooks_cli.run_hook") as mock_run:
cmd_hook(args)
mock_run.assert_called_once_with(hook_name="session-end", harness="claude-code")
# ── cmd_init ───────────────────────────────────────────────────────────
@@ -621,6 +629,64 @@ def test_cmd_mine_include_ignored_comma_split(mock_config_cls):
assert call_kwargs["include_ignored"] == ["a.txt", "b.txt", "c.txt"]
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_mine_daemon_background_submits_job(mock_config_cls, capsys):
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(
dir="/src",
palace=None,
mode="projects",
wing=None,
agent="mempalace",
limit=0,
dry_run=False,
no_gitignore=False,
include_ignored=["a.txt,b.txt"],
extract="exchange",
daemon=True,
background=True,
backend=None,
global_backend=None,
max_chunks_per_file=None,
redetect_origin=False,
)
with patch("mempalace.daemon.submit_job", return_value={"id": "job-1"}) as mock_submit:
with patch("mempalace.miner.mine") as mock_mine:
cmd_mine(args)
mock_mine.assert_not_called()
mock_submit.assert_called_once()
call_kwargs = mock_submit.call_args.kwargs
assert call_kwargs["palace_path"] == "/fake/palace"
assert call_kwargs["wait"] is False
payload = mock_submit.call_args.args[1]
assert payload["include_ignored"] == ["a.txt", "b.txt"]
assert "job-1" in capsys.readouterr().out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_mine_background_requires_daemon(mock_config_cls, capsys):
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(
dir="/src",
palace=None,
mode="projects",
wing=None,
agent="mempalace",
limit=0,
dry_run=False,
no_gitignore=False,
include_ignored=[],
extract="exchange",
daemon=False,
background=True,
)
with pytest.raises(SystemExit) as excinfo:
cmd_mine(args)
assert excinfo.value.code == 2
assert "--background requires --daemon" in capsys.readouterr().err
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_mine_exits_nonzero_on_lock_holder(mock_config_cls, capsys):
"""Regression #1264: lock contention must exit non-zero with a clear message.
@@ -872,6 +938,18 @@ def test_main_hook_run_dispatches():
mock_cmd.assert_called_once()
def test_main_hook_run_dispatches_session_end():
with (
patch(
"sys.argv",
["mempalace", "hook", "run", "--hook", "session-end", "--harness", "claude-code"],
),
patch("mempalace.cli.cmd_hook") as mock_cmd,
):
main()
mock_cmd.assert_called_once()
def test_main_instructions_no_subcommand_prints_help(capsys):
with patch("sys.argv", ["mempalace", "instructions"]):
main()
@@ -897,6 +975,16 @@ def test_main_repair_dispatches():
mock_cmd.assert_called_once()
def test_main_repair_rebuild_index_dispatches():
with (
patch("sys.argv", ["mempalace", "repair", "rebuild-index"]),
patch("mempalace.cli.cmd_repair") as mock_cmd,
):
main()
args = mock_cmd.call_args.args[0]
assert args.repair_action == "rebuild-index"
def test_main_compress_dispatches():
with (
patch("sys.argv", ["mempalace", "compress"]),
@@ -957,6 +1045,35 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
assert "Error reading palace" in out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_error_reading_points_to_from_sqlite_not_remine(
mock_config_cls, tmp_path, capsys
):
"""When the drawer-index read fails (the chromadb HNSW compactor cannot
apply WAL logs to the segment), legacy repair must point the user at
``repair --mode from-sqlite`` — the rows are intact in chroma.sqlite3 —
and must NOT advise re-mining from source files, which silently drops
drawers added via the MCP server and diary entries (#1843)."""
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
sqlite3.connect(str(palace_dir / "chroma.sqlite3")).close()
mock_config_cls.return_value.palace_path = str(palace_dir)
mock_config_cls.return_value.collection_name = "mempalace_drawers"
args = argparse.Namespace(palace=None)
mock_col = MagicMock()
mock_col.count.side_effect = Exception(
"Error executing plan: Error sending backfill request to compactor: "
"Failed to apply logs to the hnsw segment writer"
)
mock_backend = MagicMock()
mock_backend.get_collection.return_value = mock_col
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_repair(args)
out = capsys.readouterr().out
assert "mempalace repair --mode from-sqlite --archive-existing" in out
assert "may need to be re-mined" not in out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
@@ -1260,6 +1377,94 @@ def test_cmd_sync_palace_dir_no_db(mock_config_cls, tmp_path, capsys):
assert list(tmp_path.iterdir()) == []
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_sync_daemon_background_submits_job(mock_config_cls, capsys):
from mempalace.cli import cmd_sync
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(
palace=None,
dir="/project",
root=["/extra"],
wing="wing_a",
dry_run=False,
daemon=True,
background=True,
backend=None,
global_backend=None,
)
with patch("mempalace.daemon.submit_job", return_value={"id": "sync-job"}) as mock_submit:
cmd_sync(args)
mock_submit.assert_called_once()
assert mock_submit.call_args.args[0] == "sync"
payload = mock_submit.call_args.args[1]
assert payload == {"dir": "/project", "root": ["/extra"], "wing": "wing_a", "dry_run": False}
assert mock_submit.call_args.kwargs["wait"] is False
assert "sync-job" in capsys.readouterr().out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_daemon_jobs_reads_durable_queue_when_stopped(
mock_config_cls, tmp_path, monkeypatch, capsys
):
from mempalace.daemon import QueueStore, queue_path
palace_dir = tmp_path / "palace"
state_root = tmp_path / "state"
palace_dir.mkdir()
monkeypatch.setenv("MEMPALACE_DAEMON_STATE_ROOT", str(state_root))
mock_config_cls.return_value.palace_path = str(palace_dir)
job = QueueStore(queue_path(str(palace_dir))).enqueue("mine", {"source": "/src"})
args = argparse.Namespace(
palace=None,
backend=None,
global_backend=None,
daemon_action="jobs",
limit=20,
)
with patch("mempalace.daemon.get_client_if_running", return_value=None):
cmd_daemon(args)
out = capsys.readouterr().out
assert job.id in out
assert "queued" in out
assert "mine" in out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_daemon_wait_reads_finished_job_when_stopped(
mock_config_cls, tmp_path, monkeypatch, capsys
):
from mempalace.daemon import QueueStore, queue_path
palace_dir = tmp_path / "palace"
state_root = tmp_path / "state"
palace_dir.mkdir()
monkeypatch.setenv("MEMPALACE_DAEMON_STATE_ROOT", str(state_root))
mock_config_cls.return_value.palace_path = str(palace_dir)
store = QueueStore(queue_path(str(palace_dir)))
queued = store.enqueue("mine", {"source": "/src"})
store.finish(
queued.id,
state="succeeded",
result={"success": True, "stdout": "done\n", "exit_code": 0},
)
args = argparse.Namespace(
palace=None,
backend=None,
global_backend=None,
daemon_action="wait",
job_id=queued.id,
)
with patch("mempalace.daemon.get_client_if_running", return_value=None):
cmd_daemon(args)
assert "done" in capsys.readouterr().out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_compress_no_palace(mock_config_cls, tmp_path, capsys):
"""cmd_compress exits non-zero with a 'No palace found' message on a missing dir.
@@ -1572,3 +1777,57 @@ def test_cmd_repair_from_sqlite_success_does_not_exit(mock_config_cls, tmp_path)
with patch("mempalace.repair.rebuild_from_sqlite", return_value=fake_counts):
# Should return cleanly; no SystemExit raised.
cmd_repair(args)
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_from_sqlite_cleanup_failure_exits_nonzero(mock_config_cls, tmp_path, capsys):
from mempalace.repair import RebuildCleanupError
palace_dir = tmp_path / "palace"
source_dir = tmp_path / "source"
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(
palace=str(palace_dir),
mode="from-sqlite",
source=str(source_dir),
archive_existing=False,
yes=True,
)
failure = RebuildCleanupError(
"cleanup failed",
counts={"mempalace_drawers": 1},
dest_palace=str(palace_dir),
archive_path=None,
)
with patch("mempalace.repair.rebuild_from_sqlite", side_effect=failure):
with pytest.raises(SystemExit) as excinfo:
cmd_repair(args)
assert excinfo.value.code == 1
assert "Rebuild cleanup failed" in capsys.readouterr().out
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_rebuild_index_alias_uses_sqlite_archive(mock_config_cls, tmp_path):
"""``repair rebuild-index`` must bypass Chroma reads and rebuild from SQLite."""
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(
palace=str(palace_dir),
repair_action="rebuild-index",
mode="legacy",
source=None,
archive_existing=False,
yes=True,
)
fake_counts = {"mempalace_drawers": 1, "mempalace_closets": 0}
with patch("mempalace.repair.rebuild_from_sqlite", return_value=fake_counts) as rebuild:
cmd_repair(args)
rebuild.assert_called_once_with(
source_palace=str(palace_dir),
dest_palace=str(palace_dir),
archive_existing_dest=True,
)
+74
View File
@@ -0,0 +1,74 @@
"""Tests for the `hallways` CLI command."""
from argparse import Namespace
import mempalace.hallways as hallways_mod
from mempalace.cli import cmd_hallways
def test_lists_sorted_by_count(monkeypatch, capsys):
rows = [
{
"entity_a": "C",
"entity_b": "D",
"co_occurrence_count": 1,
"wing": "w",
"label": "C <-> D (x1)",
},
{
"entity_a": "A",
"entity_b": "B",
"co_occurrence_count": 3,
"wing": "w",
"label": "A <-> B (x3)",
},
]
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
cmd_hallways(Namespace(wing=None, limit=50))
out = capsys.readouterr().out
assert "2 hallway(s)" in out
assert "A <-> B (x3)" in out
# Highest co-occurrence first.
assert out.index("A <-> B") < out.index("C <-> D")
def test_respects_limit(monkeypatch, capsys):
rows = [
{"entity_a": f"E{i}", "entity_b": "X", "co_occurrence_count": i, "label": f"E{i} <-> X"}
for i in range(5)
]
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
cmd_hallways(Namespace(wing=None, limit=2))
assert capsys.readouterr().out.count("<->") == 2
def test_negative_limit_shows_nothing_not_tail(monkeypatch, capsys):
rows = [
{"entity_a": f"E{i}", "entity_b": "X", "co_occurrence_count": i, "label": f"E{i} <-> X"}
for i in range(5)
]
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: list(rows))
cmd_hallways(Namespace(wing=None, limit=-2))
# A negative limit must not slice from the end (which would print all-but-2).
assert capsys.readouterr().out.count("<->") == 0
def test_empty_message(monkeypatch, capsys):
monkeypatch.setattr(hallways_mod, "list_hallways", lambda wing=None, config=None: [])
cmd_hallways(Namespace(wing="x", limit=50))
assert "No hallways yet" in capsys.readouterr().out
def test_explicit_palace_scopes_hallway_listing(monkeypatch, tmp_path):
calls = []
def fake_list(wing=None, config=None):
calls.append((wing, config.palace_path))
return []
selected = tmp_path / "selected" / "palace"
monkeypatch.setattr(hallways_mod, "list_hallways", fake_list)
cmd_hallways(Namespace(wing="wing_aya", limit=50, palace=str(selected)))
assert calls == [("wing_aya", str(selected))]
+155
View File
@@ -1,5 +1,6 @@
import os
import json
import sqlite3
import tempfile
import pytest
@@ -10,6 +11,7 @@ from mempalace.config import (
sanitize_iso_temporal,
sanitize_kg_value,
sanitize_name,
sqlite_read_uri,
)
@@ -68,6 +70,41 @@ def test_qdrant_config_from_env_and_file(tmp_path, monkeypatch):
assert cfg.qdrant_timeout == 3.5
def test_milvus_config_from_env_and_file(tmp_path, monkeypatch):
with open(tmp_path / "config.json", "w") as f:
json.dump(
{
"milvus_uri": "https://config.example",
"milvus_token": "config-token",
"milvus_db_name": "config-db",
"milvus_namespace": "config-ns",
"milvus_consistency_level": "bounded",
},
f,
)
monkeypatch.setenv("MEMPALACE_MILVUS_URI", "https://env.example")
monkeypatch.setenv("MEMPALACE_MILVUS_TOKEN", "env-token")
monkeypatch.setenv("MEMPALACE_MILVUS_DB_NAME", "env-db")
monkeypatch.setenv("MEMPALACE_MILVUS_NAMESPACE", "env-ns")
monkeypatch.setenv("MEMPALACE_MILVUS_CONSISTENCY_LEVEL", "eventually")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.milvus_uri == "https://env.example"
assert cfg.milvus_token == "env-token"
assert cfg.milvus_db_name == "env-db"
assert cfg.milvus_namespace == "env-ns"
assert cfg.milvus_consistency_level == "Eventually"
def test_milvus_config_rejects_invalid_consistency_level(tmp_path, monkeypatch):
monkeypatch.setenv("MEMPALACE_MILVUS_CONSISTENCY_LEVEL", "linearizable")
cfg = MempalaceConfig(config_dir=str(tmp_path))
with pytest.raises(ValueError, match="milvus_consistency_level"):
cfg.milvus_consistency_level
def test_set_backend_persists_choice(tmp_path):
cfg = MempalaceConfig(config_dir=str(tmp_path))
cfg.set_backend("sqlite_exact")
@@ -100,6 +137,79 @@ def test_embedding_device_env_overrides_config(tmp_path, monkeypatch):
assert cfg.embedding_device == "coreml"
def test_embedding_threads_defaults_to_half_cpus(monkeypatch):
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
monkeypatch.setattr("os.cpu_count", lambda: 10)
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
# unset / "auto" → half the logical CPUs so a background mine stays tame
assert cfg.embedding_threads == 5
def test_embedding_threads_auto_keyword(tmp_path, monkeypatch):
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
monkeypatch.setattr("os.cpu_count", lambda: 8)
with open(tmp_path / "config.json", "w") as f:
json.dump({"embedding_threads": "auto"}, f)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_threads == 4
def test_embedding_threads_positive_value_from_config(tmp_path, monkeypatch):
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
with open(tmp_path / "config.json", "w") as f:
json.dump({"embedding_threads": 3}, f)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_threads == 3
def test_embedding_threads_zero_means_uncapped(tmp_path, monkeypatch):
monkeypatch.delenv("MEMPALACE_EMBEDDING_THREADS", raising=False)
with open(tmp_path / "config.json", "w") as f:
json.dump({"embedding_threads": 0}, f)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_threads == 0
def test_embedding_threads_env_overrides_config(tmp_path, monkeypatch):
with open(tmp_path / "config.json", "w") as f:
json.dump({"embedding_threads": 2}, f)
monkeypatch.setenv("MEMPALACE_EMBEDDING_THREADS", "6")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_threads == 6
def test_embedding_threads_invalid_falls_back_to_auto(tmp_path, monkeypatch):
monkeypatch.setattr("os.cpu_count", lambda: 4)
monkeypatch.setenv("MEMPALACE_EMBEDDING_THREADS", "not-a-number")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_threads == 2
def test_sqlite_read_uri_opens_path_with_spaces(tmp_path):
"""sqlite_read_uri must open a read-only DB whose path contains spaces,
which a bare f"file:{path}?mode=ro" mis-parses (especially on Windows)."""
db_dir = tmp_path / "palace with spaces"
db_dir.mkdir()
db_path = db_dir / "chroma.sqlite3"
setup = sqlite3.connect(str(db_path))
setup.execute("CREATE TABLE t (x INTEGER)")
setup.execute("INSERT INTO t VALUES (42)")
setup.commit()
setup.close()
uri = sqlite_read_uri(str(db_path))
assert "%20" in uri # the space is percent-encoded, not left raw
conn = sqlite3.connect(uri, uri=True)
try:
assert conn.execute("SELECT x FROM t").fetchone()[0] == 42
# mode=ro is still honored through the encoded URI
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO t VALUES (1)")
finally:
conn.close()
def test_env_override():
raw = "/env/palace"
os.environ["MEMPALACE_PALACE_PATH"] = raw
@@ -690,6 +800,36 @@ def test_hooks_auto_save_env_override_true():
del os.environ["MEMPALACE_HOOKS_AUTO_SAVE"]
def test_hook_use_daemon_default_false(monkeypatch):
monkeypatch.delenv("MEMPALACE_HOOKS_DAEMON", raising=False)
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.hook_use_daemon is False
def test_hook_use_daemon_from_config(monkeypatch, tmp_path):
monkeypatch.delenv("MEMPALACE_HOOKS_DAEMON", raising=False)
with open(tmp_path / "config.json", "w") as f:
json.dump({"hooks": {"daemon": True}}, f)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.hook_use_daemon is True
def test_hook_use_daemon_string_config(monkeypatch, tmp_path):
monkeypatch.delenv("MEMPALACE_HOOKS_DAEMON", raising=False)
with open(tmp_path / "config.json", "w") as f:
json.dump({"hooks": {"daemon": "yes"}}, f)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.hook_use_daemon is True
def test_hook_use_daemon_env_override(monkeypatch, tmp_path):
with open(tmp_path / "config.json", "w") as f:
json.dump({"hooks": {"daemon": False}}, f)
monkeypatch.setenv("MEMPALACE_HOOKS_DAEMON", "yes")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.hook_use_daemon is True
# --- max_backups (backup retention) ---
@@ -748,3 +888,18 @@ def test_max_backups_bad_env_falls_back_to_config(monkeypatch, tmp_path):
monkeypatch.setenv("MEMPALACE_MAX_BACKUPS", "garbage")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.max_backups == 4
def test_explicit_palace_path_overrides_env_and_file_config(monkeypatch, tmp_path):
configured = tmp_path / "configured" / "palace"
explicit = tmp_path / "explicit" / "../explicit" / "palace"
with open(tmp_path / "config.json", "w") as f:
json.dump({"palace_path": str(configured)}, f)
monkeypatch.setenv("MEMPALACE_PALACE_PATH", str(tmp_path / "environment" / "palace"))
cfg = MempalaceConfig(config_dir=str(tmp_path), palace_path=str(explicit))
expected = os.path.abspath(os.path.expanduser(str(explicit)))
assert cfg.palace_path == expected
assert cfg.hallway_file == os.path.join(os.path.dirname(expected), "hallways.json")
assert cfg.tunnel_file == os.path.join(os.path.dirname(expected), "tunnels.json")
+33
View File
@@ -0,0 +1,33 @@
"""Tests for palace_path tilde expansion in MempalaceConfig."""
import os
from mempalace.config import MempalaceConfig
def test_palace_path_expands_tilde_from_config_file():
"""palace_path must expand ~ even when read from config.json, not env."""
cfg = MempalaceConfig()
cfg._file_config["palace_path"] = "~/.mempalace/palace"
result = cfg.palace_path
assert not result.startswith("~"), (
f"palace_path returned unexpanded tilde: {result!r}. "
"This causes mempalace mine to create a literal '~' directory "
"relative to CWD instead of writing to the home directory."
)
assert result == os.path.expanduser("~/.mempalace/palace")
def test_palace_path_expands_tilde_nested():
"""Nested tilde paths (e.g. ~/custom/palace) are also expanded."""
cfg = MempalaceConfig()
cfg._file_config["palace_path"] = "~/custom/mempalace"
result = cfg.palace_path
assert not result.startswith("~")
assert result == os.path.expanduser("~/custom/mempalace")
def test_palace_path_absolute_unchanged():
"""Absolute paths pass through without modification."""
cfg = MempalaceConfig()
cfg._file_config["palace_path"] = "/tmp/test_palace"
assert cfg.palace_path == "/tmp/test_palace"
+243 -1
View File
@@ -1,6 +1,7 @@
import os
import tempfile
import shutil
import time
from pathlib import Path
import chromadb
@@ -8,10 +9,11 @@ import pytest
from mempalace.convo_miner import (
_is_ai_tool_path,
_register_file,
_resolve_wing,
mine_convos,
)
from mempalace.palace import MineAlreadyRunning, file_already_mined
from mempalace.palace import MineAlreadyRunning, file_already_mined, prefetch_mined_set
def test_convo_mining():
@@ -454,3 +456,243 @@ def test_mine_convos_limit_skips_already_mined(capsys):
break
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
# ── mtime-aware re-mining ────────────────────────────────────────────
#
# Conversation transcripts are NOT immutable: a Claude Code session keeps
# appending to its own file while active, and /compact or /clear can
# rewrite one in place. These tests cover the fix -- convo mining used to
# treat "we've seen this source_file before" as sufficient to skip it
# forever (transcripts were assumed immutable), silently missing content
# appended after the first mine.
def test_mine_convos_reprocesses_when_file_grows(capsys):
"""A session file that grows after being mined must be picked up on
the next mine, not skipped forever."""
tmpdir = tempfile.mkdtemp()
try:
convo_path = Path(tmpdir) / "session.txt"
convo_path.write_text(
"> What is the plan?\nStart with the schema, then the API.\n\n"
"> Any risks?\nMigration ordering is the main one.\n"
)
palace_path = os.path.join(tmpdir, "palace")
mine_convos(tmpdir, palace_path, wing="test")
capsys.readouterr()
# Simulate the session being extended: real content added, mtime
# bumped forward (avoids same-second mtime resolution flakiness).
convo_path.write_text(
"> What is the plan?\nStart with the schema, then the API.\n\n"
"> Any risks?\nMigration ordering is the main one.\n\n"
"> UNIQUE_GROWN_SESSION_MARKER, did we resolve it?\n"
"Yes, resolved by locking the migration order explicitly.\n"
)
future = time.time() + 60
os.utime(convo_path, (future, future))
mine_convos(tmpdir, palace_path, wing="test")
out = capsys.readouterr().out
assert "Files skipped (already filed): 1" not in out
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
docs = col.get(include=["documents"])["documents"]
assert any("UNIQUE_GROWN_SESSION_MARKER" in d for d in docs), (
"grown session content was not picked up on re-mine"
)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_convos_unchanged_file_still_skipped(capsys):
"""A file whose content and mtime are unchanged must still be skipped
-- the mtime check must not defeat the existing skip-on-unchanged
optimization."""
tmpdir = tempfile.mkdtemp()
try:
convo_path = Path(tmpdir) / "session.txt"
convo_path.write_text(
"> What is the plan?\nStart with the schema, then the API.\n\n"
"> Any risks?\nMigration ordering is the main one.\n"
)
palace_path = os.path.join(tmpdir, "palace")
mine_convos(tmpdir, palace_path, wing="test")
capsys.readouterr()
mine_convos(tmpdir, palace_path, wing="test")
out = capsys.readouterr().out
assert "Files skipped (already filed): 1" in out
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_convos_grown_file_purges_stale_drawers_not_additive(capsys):
"""Re-mining a grown file must not leave duplicate/stale drawers behind
-- purge-then-insert, not additive accumulation. Checks content
directly (a drawer count comparison is fragile: ChromaDB collections
can carry non-drawer bookkeeping rows unrelated to this behavior)."""
tmpdir = tempfile.mkdtemp()
try:
convo_path = Path(tmpdir) / "session.txt"
convo_path.write_text(
"> What is the plan?\nUNIQUE_ORIGINAL_EXCHANGE_MARKER here.\n\n"
"> Any risks?\nMigration ordering is the main one.\n"
)
palace_path = os.path.join(tmpdir, "palace")
mine_convos(tmpdir, palace_path, wing="test")
capsys.readouterr()
convo_path.write_text(
"> What is the plan?\nUNIQUE_ORIGINAL_EXCHANGE_MARKER here.\n\n"
"> Any risks?\nMigration ordering is the main one.\n\n"
"> One more exchange?\nUNIQUE_NEW_EXCHANGE_MARKER here.\n"
)
future = time.time() + 60
os.utime(convo_path, (future, future))
mine_convos(tmpdir, palace_path, wing="test")
capsys.readouterr()
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
docs = col.get(include=["documents"])["documents"]
original_hits = sum(1 for d in docs if "UNIQUE_ORIGINAL_EXCHANGE_MARKER" in d)
new_hits = sum(1 for d in docs if "UNIQUE_NEW_EXCHANGE_MARKER" in d)
assert original_hits == 1, (
f"original exchange duplicated across re-mine: {original_hits} copies"
)
assert new_hits == 1, f"new exchange should appear exactly once, got {new_hits}"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_prefetch_mined_set_returns_stored_mtime():
"""prefetch_mined_set's dict carries each source_file's stored mtime,
not just membership."""
tmpdir = tempfile.mkdtemp()
try:
convo_path = Path(tmpdir) / "session.txt"
convo_path.write_text(
"> What is the plan?\nStart with the schema, then the API.\n\n"
"> Any risks?\nMigration ordering is the main one.\n"
)
palace_path = os.path.join(tmpdir, "palace")
mine_convos(tmpdir, palace_path, wing="test")
resolved_file = str(convo_path.resolve())
actual_mtime = os.path.getmtime(resolved_file)
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
mined = prefetch_mined_set(col, extract_mode="exchange")
assert resolved_file in mined
assert mined[resolved_file] is not None
assert abs(mined[resolved_file] - actual_mtime) < 0.001
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_prefetch_mined_set_none_for_drawer_without_stored_mtime():
"""A drawer written before source_mtime existed (or with getmtime
failure at write time) must surface as None, not be silently absent --
None must be treated as stale by callers, not as 'unknown, assume ok'."""
tmpdir = tempfile.mkdtemp()
try:
palace_path = os.path.join(tmpdir, "palace")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers")
col.upsert(
ids=["drawer_legacy_1"],
documents=["legacy content with no source_mtime field"],
metadatas=[
{
"wing": "test",
"room": "general",
"source_file": "/fake/legacy/file.txt",
"chunk_index": 0,
"extract_mode": "exchange",
"normalize_version": 999, # force >= current version
}
],
)
mined = prefetch_mined_set(col, extract_mode="exchange")
assert "/fake/legacy/file.txt" in mined
assert mined["/fake/legacy/file.txt"] is None
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_convos_reprocesses_legacy_drawer_without_stored_mtime(capsys):
"""A file mined before source_mtime was tracked (simulated: drawer
written directly, no source_mtime field) must be re-mined on the next
run, not skipped forever -- this is the one-time backfill behavior."""
tmpdir = tempfile.mkdtemp()
try:
convo_path = Path(tmpdir) / "session.txt"
convo_path.write_text(
"> What is the plan?\nUNIQUE_LEGACY_BACKFILL_MARKER here.\n\n"
"> Any risks?\nMigration ordering is the main one.\n"
)
resolved_file = str(convo_path.resolve())
palace_path = os.path.join(tmpdir, "palace")
# Simulate a pre-existing drawer from before source_mtime existed.
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers")
from mempalace.palace import NORMALIZE_VERSION
col.upsert(
ids=["drawer_legacy_session_1"],
documents=["stale legacy content, no mtime field"],
metadatas=[
{
"wing": "test",
"room": "general",
"source_file": resolved_file,
"chunk_index": 0,
"extract_mode": "exchange",
"normalize_version": NORMALIZE_VERSION,
}
],
)
del col, client
mine_convos(tmpdir, palace_path, wing="test")
out = capsys.readouterr().out
assert "Files skipped (already filed): 1" not in out
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
docs = col.get(include=["documents"])["documents"]
assert any("UNIQUE_LEGACY_BACKFILL_MARKER" in d for d in docs)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_register_file_sentinel_includes_source_mtime():
"""The 0-chunk sentinel must stamp source_mtime too, so a file that
later grows past the min-chunk-size floor is detected as changed
instead of being skipped forever by the sentinel."""
tmpdir = tempfile.mkdtemp()
try:
tiny_file = Path(tmpdir) / "tiny.txt"
tiny_file.write_text("hi")
palace_path = os.path.join(tmpdir, "palace")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers")
_register_file(col, str(tiny_file), "test", "mempalace", "exchange")
mined = prefetch_mined_set(col, extract_mode="exchange")
assert str(tiny_file) in mined
assert mined[str(tiny_file)] is not None
assert abs(mined[str(tiny_file)] - os.path.getmtime(tiny_file)) < 0.001
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+175
View File
@@ -8,6 +8,7 @@ import pytest
from mempalace.convo_miner import (
CHUNK_SIZE,
_emit_bounded,
_extract_authored_at,
_file_chunks_locked,
chunk_exchanges,
detect_convo_room,
@@ -362,6 +363,30 @@ class TestScanConvos:
files = scan_convos(str(tmp_path))
assert files == []
def test_scan_skips_tool_results_dirs(self, tmp_path):
# Claude Code pages large tool outputs to <session>/tool-results/*.txt
# inside ~/.claude/projects/<slug>/. These are raw machine dumps
# referenced from the transcript JSONL, not conversations — mining
# them floods the palace (12.8k drawers measured in the field, one
# single file produced 3.6k). The scanner must not descend into them.
session_dir = tmp_path / "1234-5678-session"
tool_results = session_dir / "tool-results"
tool_results.mkdir(parents=True)
(tool_results / "bipc8jdx0.txt").write_text("raw tool dump " * 100, encoding="utf-8")
(tmp_path / "session.jsonl").write_text('{"type": "user"}', encoding="utf-8")
files = scan_convos(str(tmp_path))
names = [f.name for f in files]
assert "session.jsonl" in names
assert "bipc8jdx0.txt" not in names
def test_scan_keeps_regular_nested_dirs(self, tmp_path):
# The tool-results skip must not turn into a blanket nested-dir skip.
nested = tmp_path / "archive"
nested.mkdir()
(nested / "old-chat.md").write_text("> q\na\n> q2\na2\n> q3\na3", encoding="utf-8")
files = scan_convos(str(tmp_path))
assert [f.name for f in files] == ["old-chat.md"]
@pytest.mark.skipif(
sys.platform == "win32",
reason="symlink creation requires elevated privileges on Windows",
@@ -431,6 +456,59 @@ class TestScanConvos:
assert "deep/subdir/nested.jsonl" in err
assert "(symlink)" in err
def test_scan_skips_oversized_files(self, tmp_path, capsys, monkeypatch):
import re
import mempalace.convo_miner as convo_mod
monkeypatch.setattr(convo_mod, "MAX_FILE_SIZE", 100)
(tmp_path / "small.txt").write_text("hello " * 5, encoding="utf-8")
(tmp_path / "big.txt").write_text("hello " * 100, encoding="utf-8")
files = scan_convos(str(tmp_path))
names = [f.name for f in files]
assert "small.txt" in names
assert "big.txt" not in names
err = capsys.readouterr().err
# SKIP message goes to stderr, matching the existing
# `SKIP: <rel> (symlink)` line in the same function.
assert "SKIP: big.txt" in err
# Validate the full template so a drop of the MB suffix or a
# regression to bare-substring output trips the test.
assert re.search(r"SKIP: big\.txt \(\d+\.\d+ MB\) exceeds \d+ MB limit", err), err
def test_scan_skips_unreadable_files(self, tmp_path, capsys, monkeypatch):
from pathlib import Path
# .txt is in CONVO_EXTENSIONS so it reaches the size-check gate.
(tmp_path / "readable.txt").write_text("hi", encoding="utf-8")
unreadable = tmp_path / "unreadable.txt"
unreadable.write_text("hi", encoding="utf-8")
real_stat = Path.stat
def selective_stat(self, *args, **kwargs):
# On Py 3.10+, Path.is_symlink() routes through lstat ->
# stat(follow_symlinks=False). Only raise for the follow-symlinks
# call that the actual size-check makes, otherwise the test
# never reaches the size-check arm we want to exercise.
if self.name == "unreadable.txt" and kwargs.get("follow_symlinks", True):
raise PermissionError(13, "Permission denied", str(self))
return real_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", selective_stat)
files = scan_convos(str(tmp_path))
names = [f.name for f in files]
assert "readable.txt" in names
assert "unreadable.txt" not in names
err = capsys.readouterr().err
assert "SKIP: unreadable.txt" in err
assert "stat error" in err
class TestFileChunksLocked:
def test_uses_bounded_upsert_batches(self, monkeypatch):
@@ -468,3 +546,100 @@ class TestFileChunksLocked:
assert dict(room_counts) == {}
assert skipped is False
assert col.batch_sizes == [2, 2, 1]
def test_populates_entities_metadata(self, monkeypatch):
import mempalace.convo_miner as convo_miner
class FakeCol:
def __init__(self):
self.metas = []
def delete(self, *args, **kwargs):
pass
def get(self, ids=None, include=None, **kwargs):
return {"ids": [], "metadatas": []}
def upsert(self, documents, ids, metadatas):
self.metas.extend(metadatas)
chunks = [
{
"content": "We changed `MemoryStack` in rag/foo.py via do_thing_now().",
"chunk_index": 0,
}
]
col = FakeCol()
monkeypatch.setattr(
convo_miner, "file_already_mined", lambda collection, source_file, **kwargs: False
)
monkeypatch.setattr(convo_miner, "mine_lock", lambda source_file: contextlib.nullcontext())
monkeypatch.setattr(convo_miner, "_detect_hall_cached", lambda content: "conversations")
_file_chunks_locked(col, "chat.txt", chunks, "wing", "general", "agent", "exchange")
entities = col.metas[0]["entities"].split(";")
assert "MemoryStack" in entities
assert "rag/foo.py" in entities
assert "do_thing_now" in entities
class TestExtractAuthoredAt:
"""authored_at = max per-line ``timestamp`` in a transcript (real authored date,
independent of mine time). Both Claude Code and Codex JSONL carry a top-level
ISO-8601 ``timestamp`` per line."""
def test_returns_latest_timestamp(self, tmp_path):
f = tmp_path / "session.jsonl"
f.write_text(
'{"type": "user", "timestamp": "2026-06-21T10:00:00.000Z"}\n'
'{"type": "assistant", "timestamp": "2026-06-23T14:30:00.000Z"}\n'
'{"type": "user", "timestamp": "2026-06-22T09:00:00.000Z"}\n'
)
assert _extract_authored_at(f) == "2026-06-23T14:30:00.000Z"
def test_ignores_lines_without_timestamp(self, tmp_path):
f = tmp_path / "session.jsonl"
f.write_text(
'{"type": "summary", "summary": "x"}\n'
'{"type": "assistant", "timestamp": "2026-06-23T14:30:00.000Z"}\n'
)
assert _extract_authored_at(f) == "2026-06-23T14:30:00.000Z"
def test_tolerates_blank_and_malformed_lines(self, tmp_path):
f = tmp_path / "session.jsonl"
f.write_text(
"\n"
"not json\n"
"[1, 2, 3]\n" # valid JSON, but no .get()
'{"timestamp": "2026-06-25T00:00:00.000Z"}\n'
)
assert _extract_authored_at(f) == "2026-06-25T00:00:00.000Z"
def test_none_for_non_jsonl(self, tmp_path):
f = tmp_path / "notes.md"
f.write_text("# heading\n")
assert _extract_authored_at(f) is None
def test_none_when_no_timestamps(self, tmp_path):
f = tmp_path / "session.jsonl"
f.write_text('{"type": "user", "content": "hi"}\n')
assert _extract_authored_at(f) is None
def test_none_for_missing_file(self, tmp_path):
assert _extract_authored_at(tmp_path / "absent.jsonl") is None
def test_non_string_timestamp_does_not_crash(self, tmp_path):
# A non-string timestamp must be skipped, not raise TypeError on compare.
f = tmp_path / "session.jsonl"
f.write_text(
'{"type": "user", "timestamp": 1234567890}\n'
'{"type": "assistant", "timestamp": {"nested": true}}\n'
'{"type": "user", "timestamp": "2026-06-24T00:00:00.000Z"}\n'
)
assert _extract_authored_at(f) == "2026-06-24T00:00:00.000Z"
def test_only_non_string_timestamps_returns_none(self, tmp_path):
f = tmp_path / "session.jsonl"
f.write_text('{"timestamp": 1}\n{"timestamp": false}\n')
assert _extract_authored_at(f) is None
+4 -5
View File
@@ -349,11 +349,10 @@ class TestSaveHookCounter:
f"third invocation must emit a followup_message; got {response!r}"
)
msg = response["followup_message"]
# Followup must reference the real MCP tool names (regression
# guard against future typos that would silently fail).
assert "mempalace_add_drawer" in msg
assert "mempalace_check_duplicate" in msg
assert "mempalace_diary_write" in msg
# Followup must reference the real MCP tool name (regression
# guard against future typos that would silently fail). The save
# is driven by a single batch checkpoint call.
assert "mempalace_checkpoint" in msg
assert "cursor-ide" in msg, "diary entries must be tagged agent_name=cursor-ide"
def test_threshold_followup_references_inferred_wing(self, tmp_path):
+908
View File
@@ -0,0 +1,908 @@
import os
import subprocess
import threading
import time
import pytest
from _chroma_palace_helper import make_minimal_chroma_sqlite
from mempalace import daemon
from mempalace import service
# POSIX file-mode bits (0600/0700) are not representable on Windows: os.chmod
# can only toggle the read-only attribute, so a "private" file still reports
# 0o666. The daemon relies on the user-profile directory ACLs for privacy
# there, so the owner-only assertions only make sense on POSIX.
_posix_only_perms = pytest.mark.skipif(
os.name == "nt",
reason="POSIX 0600/0700 file-mode bits are not representable on Windows (ACL-based privacy)",
)
# Env keys run_server mutates from its background thread, plus umask. If a
# lifecycle test times out before the server comes up, run_server's finally
# never runs and those mutations leak into the rest of the suite — every later
# test that reads MempalaceConfig().palace_path sees a stale deleted tmp path and
# fails (the 60+ test cascade seen on slow CI runners). The fixtures below force a
# clean baseline around every daemon test so a leaked thread can't poison the
# process for tests/test_mcp_server.py and friends (which have no such guard).
_LEAK_ENV_KEYS = ("MEMPALACE_PALACE_PATH", "MEMPALACE_BACKEND", "MEMPALACE_BACKEND_EXPLICIT")
@pytest.fixture(scope="module")
def _clean_env_snapshot():
"""Capture the true pre-suite values once, before any daemon test runs."""
return {key: os.environ.get(key) for key in _LEAK_ENV_KEYS}
@pytest.fixture(autouse=True)
def _isolate_process_global_state(_clean_env_snapshot):
"""Restore the process-global env + umask to the pre-suite baseline after every
daemon test, even if a leaked run_server thread is still holding them mutated.
"""
prev_umask = os.umask(0o022)
os.umask(prev_umask) # read current umask without changing it
yield
for key, value in _clean_env_snapshot.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
os.umask(prev_umask)
def _raise_not_ready(*a, **kw):
"""Stand-in for DaemonClient when the spawned daemon must never come up."""
raise daemon.DaemonError("not ready")
def test_prune_terminal_drops_old_terminal_jobs_keeps_active(tmp_path, monkeypatch):
"""Terminal jobs older than the retention window are pruned; queued/running
and fresh terminal jobs are untouched. Bounded queue growth for the DB that
holds verbatim payloads."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
old_term = store.enqueue("mine", {"source": "old"})
store.finish(old_term.id, state="succeeded", result={"success": True})
fresh_term = store.enqueue("mine", {"source": "fresh"})
store.finish(fresh_term.id, state="succeeded", result={"success": True})
queued = store.enqueue("mine", {"source": "queued"})
from datetime import datetime, timedelta, timezone
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
with store._lock, store._connect() as conn:
conn.execute(
"UPDATE jobs SET finished_at = ? WHERE id = ?",
(cutoff, old_term.id),
)
pruned = store.prune_terminal(older_than_days=7)
assert pruned == 1
# The old terminal job is gone; the fresh terminal and queued jobs survive.
with pytest.raises(daemon.DaemonError):
store.get(old_term.id)
assert store.get(fresh_term.id).state == "succeeded"
assert store.get(queued.id).state == "queued"
def test_queue_dedupes_and_recovers_running_jobs(tmp_path, monkeypatch):
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
first = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
second = store.enqueue("mine", {"source": "a"}, dedupe_key="same")
assert second.id == first.id
claimed = store.claim_next()
assert claimed.id == first.id
assert claimed.state == "running"
recovered = store.recover_running()
assert recovered == 1
assert store.get(first.id).state == "queued"
def test_daemon_http_lifecycle_executes_job(tmp_path, monkeypatch):
calls = []
def fake_execute(kind, payload):
calls.append((kind, payload))
return {"success": True, "exit_code": 0, "stdout": "done\n"}
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
health = client.health()
assert health["ok"] is True
assert health["palace_path"] == daemon.canonical_palace_path(str(palace))
job = client.submit("mine", {"source": "src"}, dedupe_key="job")
finished = client.wait(job["id"], timeout=5)
assert finished["state"] == "succeeded"
assert finished["result"]["stdout"] == "done\n"
assert calls == [("mine", {"source": "src", "palace_path": str(palace.resolve())})]
_stop_server(client, thread, holders)
def test_submit_job_uses_client_and_waits(monkeypatch, tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
class DummyClient:
def __init__(self):
self.submitted = None
def submit(self, kind, payload, dedupe_key=None, priority=0):
self.submitted = (kind, payload, dedupe_key, priority)
return {"id": "job-1", "state": "queued"}
def wait(self, job_id, timeout=daemon.DEFAULT_WAIT_TIMEOUT):
assert job_id == "job-1"
return {
"id": "job-1",
"state": "succeeded",
"result": {"success": True, "exit_code": 0},
}
dummy = DummyClient()
monkeypatch.setattr(daemon, "ensure_client", lambda *a, **kw: dummy)
job = daemon.submit_job(
"mine",
{"source": "src"},
palace_path=str(palace),
dedupe_key="dedupe",
wait=True,
)
assert job["state"] == "succeeded"
assert dummy.submitted[0] == "mine"
# palace_path is overridden (not trusted from the payload), never appended.
assert dummy.submitted[1]["palace_path"] == daemon.canonical_palace_path(str(palace))
assert dummy.submitted[2] == "dedupe"
def test_service_tool_classification():
assert service.classify_tool("mempalace_search") == "read"
assert service.classify_tool("mempalace_add_drawer") == "write"
assert service.classify_tool("mempalace_checkpoint") == "write"
assert service.classify_tool("mempalace_delete_by_source") == "write"
assert service.classify_tool("mempalace_mine") == "maintenance"
assert service.classify_tool("unknown") == "unknown"
# --- helpers for HTTP-lifecycle tests ---
def _capture_httpd(monkeypatch):
"""Capture the httpd instance run_server creates.
run_server defines a local ``class _Server(ThreadingHTTPServer)``; by
monkeypatching ``daemon.ThreadingHTTPServer`` before run_server runs, that
subclass inherits from a capturing base that records each instance. The
httpd can then be force-stopped from the test thread (see _stop_server) so a
slow/failed ``client.shutdown()`` POST can never leave the server thread
alive — which on Windows hangs the interpreter at process exit on an open
listening socket.
"""
holders: list = []
base = daemon.ThreadingHTTPServer
class _CapturingServer(base):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
holders.append(self)
monkeypatch.setattr(daemon, "ThreadingHTTPServer", _CapturingServer)
return holders
def _stop_server(client, thread, holders, *, join_timeout=5.0):
"""Shut the daemon down deterministically and assert the thread died.
First try the normal path (POST /shutdown). If the server thread is still
alive afterwards — the POST was slow, lost, or the drain overran the join —
call httpd.shutdown() directly from this thread (stdlib-safe: it is a
different thread than serve_forever) to force serve_forever to return, then
re-join. The assert turns a leak into a visible failure instead of a silent
interpreter-exit hang.
"""
try:
client.shutdown()
except Exception: # noqa: BLE001 - best-effort; we force-shutdown below
pass
thread.join(timeout=join_timeout)
if thread.is_alive() and holders:
httpd = holders[-1]
try:
httpd.shutdown()
except Exception: # noqa: BLE001
pass
try:
httpd.server_close()
except Exception: # noqa: BLE001
pass
thread.join(timeout=join_timeout)
assert not thread.is_alive(), "daemon server thread did not shut down"
def _start_server(tmp_path, monkeypatch, execute_fn):
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
monkeypatch.setattr(service, "execute_job", execute_fn)
holders = _capture_httpd(monkeypatch)
# Capture any exception run_server raises in its thread. Without this a
# startup crash is invisible: the poll below would just spin for 30s and
# fail with a bare ``assert client is not None`` giving no cause.
server_error: list = []
def _serve():
try:
daemon.run_server(palace_path=str(palace), port=0)
except BaseException as exc: # noqa: BLE001 - re-surfaced to the test thread
server_error.append(exc)
thread = threading.Thread(target=_serve, name="test-daemon-server", daemon=True)
thread.start()
client = None
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if server_error:
raise AssertionError(f"run_server crashed during startup: {server_error[0]!r}")
client = daemon.get_client_if_running(str(palace))
if client is not None:
break
time.sleep(0.05)
if client is None:
raise AssertionError(
"daemon did not become ready within 30s "
f"(thread_alive={thread.is_alive()}, httpd_bound={bool(holders)}, "
f"endpoint_exists={daemon.endpoint_path(str(palace)).exists()})"
)
return client, thread, palace, holders
# --- ship-blocker regressions ---
def test_systemexit_in_job_does_not_kill_worker(tmp_path, monkeypatch):
"""A SystemExit (BaseException, not Exception) must be caught, the job
marked failed, and the worker kept alive for the next job. Regression for
the critical worker-death bug."""
state = {"first": True}
def fake_execute(kind, payload):
if state["first"]:
state["first"] = False
raise SystemExit("boom")
return {"success": True, "exit_code": 0}
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
try:
first = client.submit("mine", {"source": "src"})
finished_first = client.wait(first["id"], timeout=5)
assert finished_first["state"] == "failed"
assert finished_first["error"]["error_class"] == "SystemExit"
# Worker must still be alive — health reports it and a second job runs.
assert client.health()["worker_alive"] is True
second = client.submit("mine", {"source": "src2"})
finished_second = client.wait(second["id"], timeout=5)
assert finished_second["state"] == "succeeded"
finally:
_stop_server(client, thread, holders)
def test_shutdown_cancels_active_job(tmp_path, monkeypatch):
"""POST /shutdown must not leave an in-flight job 'running' for blind
re-queue on next start. The worker is drained (bounded), then the active
job is marked 'cancelled' so recover_running won't re-run it.
In production the serve process exits immediately after run_server returns,
killing the daemon worker thread before it can overwrite the cancelled
state. The test mirrors that by asserting the cancelled state *before*
releasing the blocked worker.
"""
block = threading.Event()
def fake_execute(kind, payload):
# Simulate a long-running job that never finishes on its own.
block.wait(30)
return {"success": True, "exit_code": 0}
monkeypatch.setattr(daemon, "SHUTDOWN_DRAIN_SECONDS", 0.2)
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
job = client.submit("mine", {"source": "src"}, dedupe_key="x")
# Wait until the worker has claimed it (state flips to running).
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if client.get_job(job["id"])["state"] == "running":
break
time.sleep(0.02)
assert client.get_job(job["id"])["state"] == "running"
_stop_server(client, thread, holders)
# The interrupted job must be cancelled (terminal), not left running.
store = daemon.QueueStore(daemon.queue_path(str(palace)))
final = store.get(job["id"])
assert final.state == "cancelled"
# And recover_running must not re-queue a cancelled job.
assert store.recover_running() == 0
# Release the blocked worker so it (and the daemon thread) can exit.
block.set()
def test_recover_running_dead_letters_exhausted_jobs(tmp_path, monkeypatch):
"""A job that has crashed MAX_ATTEMPTS times must be dead-lettered to
'failed', not re-queued — non-idempotent kinds (diary_write) would
otherwise duplicate verbatim content on every restart."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
job = store.enqueue("diary_write", {"entry": "x"})
# Simulate MAX_ATTEMPTS claims that each crashed (running, attempts=MAX).
with store._lock, store._connect() as conn:
conn.execute(
"UPDATE jobs SET state='running', attempts=? WHERE id=?",
(daemon.MAX_ATTEMPTS, job.id),
)
recovered = store.recover_running()
assert recovered == 0 # not re-queued
final = store.get(job.id)
assert final.state == "failed"
assert final.attempts == daemon.MAX_ATTEMPTS
def test_claim_next_does_not_reclaim_running_job(tmp_path, monkeypatch):
"""The conditional UPDATE (WHERE state='queued') means a job already
flipped to 'running' cannot be claimed again — the cross-process
double-execution guard."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
job = store.enqueue("mine", {"source": "src"})
first = store.claim_next()
assert first.id == job.id
# Manually re-mark it queued but leave a second claim attempt: claim_next
# should still only ever return one running job per claim. After finishing
# the first, the next claim returns None (queue empty).
store.finish(first.id, state="succeeded", result={"success": True})
assert store.claim_next() is None
@_posix_only_perms
def test_queue_db_file_is_owner_only(tmp_path, monkeypatch):
"""The queue DB holds verbatim payloads — it must be 0600, not the sqlite
default 0644. Regression for the privacy-principle violation."""
import os as _os
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
store = daemon.QueueStore(daemon.queue_path(str(palace)))
store.enqueue("diary_write", {"entry": "secret verbatim content"})
mode = _os.stat(str(store.path)).st_mode & 0o777
assert mode == 0o600, f"queue.sqlite3 is {oct(mode)}, expected 0600"
@_posix_only_perms
def test_token_file_is_owner_only(tmp_path, monkeypatch):
import os as _os
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
daemon.ensure_token(str(palace))
token_path = daemon.state_dir(str(palace)) / "token"
assert (_os.stat(str(token_path)).st_mode & 0o777) == 0o600
def test_health_rejects_missing_and_wrong_token(tmp_path, monkeypatch):
from urllib import error as urlerror
from urllib import request as urlrequest
client, thread, palace, holders = _start_server(
tmp_path, monkeypatch, lambda k, p: {"success": True}
)
try:
base = f"http://127.0.0.1:{client.port}"
# No Authorization header → 401.
with pytest.raises(urlerror.HTTPError):
urlrequest.urlopen(urlrequest.Request(base + "/health"), timeout=3)
# Wrong token → 401.
with pytest.raises(urlerror.HTTPError):
urlrequest.urlopen(
urlrequest.Request(base + "/health", headers={"Authorization": "Bearer wrong"}),
timeout=3,
)
finally:
_stop_server(client, thread, holders)
def test_worker_overrides_client_palace_path(tmp_path, monkeypatch):
"""An authenticated client must not be able to retarget the daemon at a
different palace by stuffing palace_path into the payload."""
seen = {}
def fake_execute(kind, payload):
seen["palace_path"] = payload.get("palace_path")
return {"success": True, "exit_code": 0}
client, thread, palace, holders = _start_server(tmp_path, monkeypatch, fake_execute)
try:
job = client.submit(
"mine", {"source": "src", "palace_path": "/tmp/other-palace"}, dedupe_key="p"
)
client.wait(job["id"], timeout=5)
finally:
_stop_server(client, thread, holders)
assert seen["palace_path"] == daemon.canonical_palace_path(str(palace))
assert seen["palace_path"] != "/tmp/other-palace"
def test_mcp_tool_allowlist_rejects_non_write_tools(tmp_path, monkeypatch):
"""The daemon queue is a durable write surface; read/maintenance/unknown
tools must be rejected so verbatim content can't be exfiltrated into the
queue or retried destructively."""
# read tool → rejected
out = service.run_mcp_tool({"name": "mempalace_search", "arguments": {}})
assert out["success"] is False
assert "only accepts write tools" in out["error"]
# maintenance tool → rejected (has its own kinds: mine/sync)
out = service.run_mcp_tool({"name": "mempalace_mine", "arguments": {}})
assert out["success"] is False
# unknown tool → rejected
out = service.run_mcp_tool({"name": "mempalace_bogus", "arguments": {}})
assert out["success"] is False
# write tool → passes the allowlist (handler not called here since TOOLS
# won't have it under the test name; but classification must let it through)
assert service.classify_tool("mempalace_add_drawer") == "write"
def test_execute_job_isolates_env_per_job(monkeypatch):
"""A job that mutates MEMPALACE_BACKEND must not leak into the next job's
env. Regression for the per-job isolation bug (_apply_backend poisoning)."""
import os as _os
monkeypatch.delenv("MEMPALACE_BACKEND", raising=False)
monkeypatch.delenv("MEMPALACE_PALACE_PATH", raising=False)
def fake_mine(payload):
_os.environ["MEMPALACE_BACKEND"] = "leaked-backend"
return {"success": True, "exit_code": 0}
monkeypatch.setattr(service, "run_mine", fake_mine)
service.execute_job("mine", {"palace_path": "/tmp/p", "source": "s"})
assert _os.environ.get("MEMPALACE_BACKEND") is None
def test_daemon_client_raises_on_endpoint_missing_port(tmp_path, monkeypatch):
"""A malformed endpoint.json must raise DaemonError, not a bare KeyError."""
import json as _json
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
daemon.ensure_token(str(palace))
# endpoint with no port
daemon.state_dir(str(palace)).mkdir(parents=True, exist_ok=True)
(daemon.state_dir(str(palace)) / "endpoint.json").write_text(
_json.dumps({"host": "127.0.0.1", "pid": 1}) + "\n", encoding="utf-8"
)
with pytest.raises(daemon.DaemonError):
daemon.DaemonClient(str(palace))
def test_pid_alive_probe_is_signal_free_and_correct():
"""``_pid_alive`` must be a pure liveness probe.
On Windows ``os.kill(pid, 0)`` is NOT harmless — signal 0 is
``CTRL_C_EVENT``, so it emits a console Ctrl-C to the target's process
group. The daemon client polls a same-process endpoint, so that Ctrl-C was
delivered back to the interpreter and surfaced as a spurious
``KeyboardInterrupt`` that hung the whole test session on CI runners (which,
unlike a detached dev shell, have an attached console). Assert the probe is
both correct and emits no SIGINT even when hammered like the poll loop.
"""
import signal
assert daemon._pid_alive(os.getpid()) is True
assert daemon._pid_alive(0) is False
assert daemon._pid_alive(-1) is False
# A pid that is almost certainly not running.
assert daemon._pid_alive(2_000_000_000) is False
# pytest runs tests on the main thread, so installing a SIGINT handler is
# allowed. If the probe regresses to os.kill(pid, 0) on Windows, the repeated
# calls below deliver CTRL_C_EVENT and this handler fires.
fired = []
previous = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGINT, lambda *_: fired.append(1))
try:
for _ in range(25):
daemon._pid_alive(os.getpid())
time.sleep(0.25)
finally:
signal.signal(signal.SIGINT, previous)
assert fired == [], "_pid_alive delivered a console control event (CTRL_C_EVENT)"
def test_start_daemon_kills_orphan_on_readiness_timeout(tmp_path, monkeypatch):
"""If the spawned daemon never becomes ready, start_daemon must kill and
reap the orphaned subprocess rather than leaking it with the port/token."""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
daemon.ensure_token(str(palace))
monkeypatch.setattr(daemon, "get_client_if_running", lambda *a, **kw: None)
class FakeProc:
def __init__(self):
self.killed = False
self.returncode = None
def poll(self):
return self.returncode # None == still alive
def kill(self):
self.killed = True
def wait(self):
self.returncode = -9
return self.returncode
fake = FakeProc()
def fake_popen(*a, **kw):
return fake
monkeypatch.setattr(daemon.subprocess, "Popen", fake_popen)
monkeypatch.setattr(daemon, "DaemonClient", _raise_not_ready)
monkeypatch.setattr(daemon.time, "sleep", lambda *a, **kw: None)
with pytest.raises(daemon.DaemonError):
daemon.start_daemon(str(palace), timeout=0.05)
assert fake.killed is True
# --- service.run_* happy-path coverage ---
# These close the draft PR's follow-up ("Add focused happy-path tests for
# service.run_mine / run_diary_write / run_mcp_tool") and, now that the daemon
# tests complete reliably, keep service.py's coverage above the CI gate. The
# capsys-using tests come first; the two that import mempalace.mcp_server (which
# rebinds sys.stdout) come last and do not use capsys, so the rebind can't break
# capture in this file or later files (capsys activates after the rebind).
def test_print_job_result_replays_stdout_stderr_and_returns_exit_code(capsys):
from mempalace import service
code = service.print_job_result(
{"success": False, "error": "boom", "stdout": "out\n", "stderr": "err\n", "exit_code": 3}
)
assert code == 3
captured = capsys.readouterr()
assert "out" in captured.out
assert "err" in captured.err
def test_print_job_result_prints_error_to_stderr_when_no_stderr(capsys):
from mempalace import service
code = service.print_job_result({"success": False, "error": "boom", "exit_code": 1})
assert code == 1
captured = capsys.readouterr()
assert "mempalace: boom" in captured.err
def test_run_sync_returns_success_when_palace_dir_missing(tmp_path):
from mempalace import service
result = service.run_sync({"palace_path": str(tmp_path / "nope"), "dry_run": True})
assert result["success"] is True
assert result["exit_code"] == 0
def test_run_sync_returns_success_when_palace_has_no_backend_artifact(tmp_path):
from mempalace import service
palace = tmp_path / "palace"
palace.mkdir()
result = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert result["success"] is True
assert result["exit_code"] == 0
def test_run_mine_invalid_mode_returns_structured_error(tmp_path):
from mempalace import service
palace = tmp_path / "palace"
palace.mkdir()
out = service.run_mine({"palace_path": str(palace), "mode": "bogus"})
assert out["success"] is False
assert "invalid mine mode" in out["error"]
assert out["exit_code"] == 2
def test_run_mcp_tool_rejects_non_dict_arguments():
from mempalace import service
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": "nope"})
assert out["success"] is False
assert "must be an object" in out["error"]
assert out["exit_code"] == 2
def test_run_mcp_tool_dispatches_write_tool(monkeypatch):
import mempalace.mcp_server as mcp
from mempalace import service
captured = {}
def fake_handler(**arguments):
captured["arguments"] = arguments
return {"success": True, "written": True}
monkeypatch.setattr(mcp, "TOOLS", {"mempalace_add_drawer": {"handler": fake_handler}})
out = service.run_mcp_tool({"name": "mempalace_add_drawer", "arguments": {"x": 1}})
assert out["success"] is True
assert out["written"] is True
assert out["exit_code"] == 0
assert captured["arguments"] == {"x": 1}
def test_run_diary_write_forwards_args_and_sets_exit_code(monkeypatch):
import mempalace.mcp_server as mcp
from mempalace import service
captured = {}
def fake_diary(agent_name, entry, topic, wing):
captured.update(agent_name=agent_name, entry=entry, topic=topic, wing=wing)
return {"success": True}
monkeypatch.setattr(mcp, "tool_diary_write", fake_diary)
out = service.run_diary_write(
{"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
)
assert out["success"] is True
assert out["exit_code"] == 0
assert captured == {"agent_name": "alice", "entry": "hello", "topic": "t", "wing": "w"}
def test_run_mine_applies_backend_before_mode_validation(tmp_path):
"""Covers _apply_backend (env set + get_backend_class validation) on the daemon
path; the invalid mode short-circuits before any mining runs."""
from mempalace import service
palace = tmp_path / "palace"
palace.mkdir()
out = service.run_mine({"palace_path": str(palace), "mode": "bogus", "backend": "chroma"})
assert out["success"] is False
assert out["exit_code"] == 2
def test_execute_job_dispatches_diary_write_mcp_tool_and_unknown(monkeypatch):
"""Covers execute_job's kind dispatch for diary_write, mcp_tool, and the
unknown-kind fallback."""
import mempalace.mcp_server as mcp
from mempalace import service
monkeypatch.setattr(mcp, "tool_diary_write", lambda **kw: {"success": True})
monkeypatch.setattr(
mcp, "TOOLS", {"mempalace_add_drawer": {"handler": lambda **kw: {"success": True}}}
)
assert service.execute_job("diary_write", {"entry": "x"})["success"] is True
assert (
service.execute_job("mcp_tool", {"name": "mempalace_add_drawer", "arguments": {}})[
"success"
]
is True
)
unknown = service.execute_job("bogus_kind", {})
assert unknown["success"] is False
assert unknown["exit_code"] == 2
def test_run_sync_structured_errors_on_sync_failures(tmp_path, monkeypatch):
"""Covers run_sync's three exception handlers (MineAlreadyRunning, ValueError,
generic Exception) so a failing sync_palace returns a structured error instead
of propagating."""
import mempalace.sync as sync_module
from mempalace import service
from mempalace.palace import MineAlreadyRunning
palace = tmp_path / "palace"
palace.mkdir()
make_minimal_chroma_sqlite(palace)
def _raise(exc):
def fn(**kw):
raise exc
return fn
monkeypatch.setattr(sync_module, "sync_palace", _raise(MineAlreadyRunning("locked")))
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert r["success"] is False
assert r["error_class"] == "LockHeldByOtherProcess"
monkeypatch.setattr(sync_module, "sync_palace", _raise(ValueError("bad scope")))
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert r["success"] is False
assert r["exit_code"] == 2
monkeypatch.setattr(sync_module, "sync_palace", _raise(RuntimeError("boom")))
r = service.run_sync({"palace_path": str(palace), "dry_run": True})
assert r["success"] is False
assert "sync failed" in r["error"]
# --- post-merge review follow-ups (Copilot review on #1826) ---
@_posix_only_perms
def test_run_server_tightens_umask_before_building_queue(tmp_path, monkeypatch):
"""The owner-only umask must be active BEFORE the queue DB is built.
SQLite's WAL/SHM sidecars hold un-checkpointed verbatim payloads and are
created with the process umask, so a loose umask at DaemonRuntime/QueueStore
construction time would leave them world-readable. Capture the umask at the
moment DaemonRuntime is constructed and assert it is already 0o077.
"""
monkeypatch.setenv(daemon.STATE_ROOT_ENV, str(tmp_path / "state"))
palace = tmp_path / "palace"
palace.mkdir()
captured = {}
def _spy_runtime(*args, **kwargs):
current = os.umask(0o022)
os.umask(current) # restore without changing
captured["umask"] = current
raise RuntimeError("stop before binding a real socket")
monkeypatch.setattr(daemon, "DaemonRuntime", _spy_runtime)
with pytest.raises(RuntimeError):
daemon.run_server(str(palace), port=0)
assert captured["umask"] == 0o077
def test_negative_content_length_is_rejected_without_blocking(tmp_path, monkeypatch):
"""A POST with Content-Length: -1 must get a prompt 400, not hang the worker.
rfile.read(-1) would read until the client closes the socket (an auth-gated
DoS) and bypass the MAX_BODY_BYTES cap. The recv timeout below turns a
regression into a failure instead of a hang.
"""
import socket
client, thread, palace, holders = _start_server(
tmp_path, monkeypatch, lambda k, p: {"success": True}
)
try:
sock = socket.create_connection((client.host, client.port), timeout=5)
sock.settimeout(5)
request = (
"POST /jobs HTTP/1.1\r\n"
"Host: daemon\r\n"
f"Authorization: Bearer {client.token}\r\n"
"Content-Length: -1\r\n"
"Connection: close\r\n\r\n"
)
sock.sendall(request.encode("ascii"))
status_line = sock.recv(4096).decode("latin-1").split("\r\n", 1)[0]
sock.close()
assert "400" in status_line, f"expected 400, got {status_line!r}"
finally:
_stop_server(client, thread, holders)
def test_run_mcp_tool_marks_bare_error_dict_as_failure(monkeypatch):
"""A write tool that returns {"error": ...} with no success flag must be
recorded as a failed job, not succeeded (Copilot review)."""
import mempalace.mcp_server as mcp
from mempalace import service
monkeypatch.setattr(
mcp,
"TOOLS",
{"mempalace_create_tunnel": {"handler": lambda **kw: {"error": "bad endpoint"}}},
)
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
assert out["success"] is False
assert out["exit_code"] == 1
assert out["error"] == "bad endpoint"
# A result with neither an explicit success flag nor an error is a success.
monkeypatch.setattr(
mcp, "TOOLS", {"mempalace_create_tunnel": {"handler": lambda **kw: {"tunnel_id": "t1"}}}
)
out = service.run_mcp_tool({"name": "mempalace_create_tunnel", "arguments": {}})
assert out["success"] is True
assert out["exit_code"] == 0
def test_get_client_if_running_uses_short_probe_timeout(monkeypatch):
"""The hook liveness precheck must pass a short health timeout so a wedged
daemon can't stall the hook past its budget (Copilot review)."""
captured = {}
class _FakeClient:
def __init__(self, palace_path):
pass
def health(self, *, timeout):
captured["timeout"] = timeout
return {"ok": True}
monkeypatch.setattr(daemon, "DaemonClient", _FakeClient)
assert daemon.HOOK_PROBE_TIMEOUT <= 0.5
client = daemon.get_client_if_running("/p", health_timeout=daemon.HOOK_PROBE_TIMEOUT)
assert client is not None
assert captured["timeout"] == daemon.HOOK_PROBE_TIMEOUT
# --- _detached_kwargs ---
def test_detached_kwargs_posix(tmp_path, monkeypatch):
monkeypatch.setattr("mempalace.daemon.os.name", "posix")
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
fh = kwargs["stdout"]
try:
assert kwargs.get("start_new_session") is True
assert kwargs.get("stdin") is subprocess.DEVNULL
assert kwargs.get("close_fds") is True
assert "creationflags" not in kwargs
finally:
fh.close()
def test_detached_kwargs_windows(tmp_path, monkeypatch):
monkeypatch.setattr("mempalace.daemon.os.name", "nt")
monkeypatch.setattr("mempalace.daemon.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False)
monkeypatch.setattr("mempalace.daemon.subprocess.DETACHED_PROCESS", 0x00000008, raising=False)
monkeypatch.setattr(
"mempalace.daemon.subprocess.CREATE_NEW_PROCESS_GROUP", 0x00000200, raising=False
)
monkeypatch.setattr(
"mempalace.daemon.subprocess.CREATE_BREAKAWAY_FROM_JOB", 0x01000000, raising=False
)
kwargs = daemon._detached_kwargs(tmp_path / "daemon.log")
fh = kwargs["stdout"]
try:
flags = kwargs.get("creationflags", 0)
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
assert not (flags & 0x00000008), (
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
)
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP preserved (Ctrl-Break group isolation)"
assert flags & 0x01000000, (
"CREATE_BREAKAWAY_FROM_JOB preserved (survive parent Job-Object close)"
)
assert kwargs.get("stdin") is subprocess.DEVNULL
assert kwargs.get("close_fds") is True
# creationflags + start_new_session are mutually exclusive on Windows
# (Popen raises ValueError); the nt branch must not set the latter.
assert "start_new_session" not in kwargs
finally:
fh.close()
+99 -3
View File
@@ -73,7 +73,7 @@ def test_onnxruntime_import_error_falls_back_to_cpu(monkeypatch):
def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
class DummyEF:
def __init__(self, preferred_providers):
def __init__(self, preferred_providers, intra_op_num_threads=0):
self.preferred_providers = preferred_providers
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
@@ -81,13 +81,109 @@ def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
first = embedding.get_embedding_function("cpu")
second = embedding.get_embedding_function("auto")
first = embedding.get_embedding_function("cpu", "minilm")
second = embedding.get_embedding_function("auto", "minilm")
assert first is second
assert first.preferred_providers == ["CPUExecutionProvider"]
def test_intra_op_session_options_caps_threads():
so = embedding._intra_op_session_options(3)
assert so is not None
assert so.intra_op_num_threads == 3
def test_intra_op_session_options_uncapped_returns_none():
assert embedding._intra_op_session_options(0) is None
assert embedding._intra_op_session_options(-1) is None
def test_get_embedding_function_threads_cap_passed_to_minilm_ef(monkeypatch):
captured = {}
class DummyEF:
def __init__(self, preferred_providers, intra_op_num_threads=0):
captured["threads"] = intra_op_num_threads
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
monkeypatch.setattr(
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
monkeypatch.setattr(embedding, "_resolve_intra_op_threads", lambda: 2)
embedding.get_embedding_function("cpu", "minilm")
assert captured["threads"] == 2
def test_get_embedding_function_threads_cap_passed_to_embeddinggemma(monkeypatch):
captured = {}
class DummyGemma:
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
captured["threads"] = intra_op_num_threads
monkeypatch.setattr(embedding, "EmbeddinggemmaONNX", DummyGemma)
monkeypatch.setattr(
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
monkeypatch.setattr(embedding, "_resolve_intra_op_threads", lambda: 4)
embedding.get_embedding_function("cpu", "embeddinggemma")
assert captured["threads"] == 4
def test_minilm_ef_model_override_applies_thread_cap(monkeypatch):
"""The ``_MempalaceONNX.model`` override must construct the ORT session
with the configured ``intra_op_num_threads`` (#1068). We stub
``InferenceSession`` to capture the ``SessionOptions`` it receives, so the
test never downloads or loads the real model."""
import onnxruntime as ort
captured = {}
def fake_session(model_path, providers=None, sess_options=None):
captured["sess_options"] = sess_options
captured["providers"] = providers
return object()
monkeypatch.setattr(ort, "InferenceSession", fake_session)
ef_cls = embedding._build_ef_class()
ef = ef_cls(preferred_providers=["CPUExecutionProvider"], intra_op_num_threads=2)
_ = ef.model # triggers the cached_property build
assert captured["sess_options"] is not None
assert captured["sess_options"].intra_op_num_threads == 2
assert "CoreMLExecutionProvider" not in captured["providers"]
def test_minilm_ef_model_override_falls_back_when_uncapped(monkeypatch):
"""With no cap (0), the override must defer to the parent build via
``super().model`` — not reach into ``cached_property`` internals (#1068
review). Proves super() resolves the parent descriptor without error."""
import onnxruntime as ort
captured = {}
def fake_session(model_path, providers=None, sess_options=None):
captured["sess_options"] = sess_options
return object()
monkeypatch.setattr(ort, "InferenceSession", fake_session)
ef_cls = embedding._build_ef_class()
ef = ef_cls(preferred_providers=["CPUExecutionProvider"], intra_op_num_threads=0)
session = ef.model # cap <= 0 → super().model (upstream builder)
assert session is not None
# Upstream leaves intra_op at ORT's default (0 = unset), confirming we
# deferred to it rather than applying our cap.
assert captured["sess_options"].intra_op_num_threads == 0
def test_describe_device_uses_resolved_effective_device(monkeypatch):
monkeypatch.setattr(
embedding,
+95
View File
@@ -124,3 +124,98 @@ def test_add_wraps_bare_string_ids_and_dict_metadatas(monkeypatch):
# documents / embeddings / ids / metadatas all length-aligned at 1
assert call["documents"] == ["solo"]
assert len(call["embeddings"]) == 1
def test_facet_counts_forwards_to_inner():
"""``BaseCollection.facet_counts`` is a concrete method (raises) — Python
MRO resolves it on the wrapper subclass before ``__getattr__`` ever fires,
so without an explicit forwarder the wrapper would raise
``UnsupportedCapabilityError`` and silently degrade every wrapped backend
(qdrant/pgvector/sqlite_exact) to client-side counting in mcp_server."""
class _Inner:
def __init__(self):
self.calls = []
def facet_counts(self, field, where=None, limit=1000):
self.calls.append((field, where, limit))
return {"alpha": 2, "beta": 1}
inner = _Inner()
out = ew.EmbeddingCollection(inner).facet_counts("wing", where={"wing": "alpha"}, limit=50)
assert out == {"alpha": 2, "beta": 1}
assert inner.calls == [("wing", {"wing": "alpha"}, 50)]
def test_get_all_metadata_forwards_to_inner():
"""Same MRO-shadow pattern as ``facet_counts``: ``BaseCollection.get_all_
metadata`` ships a concrete default that pages through ``self.get()``.
Without this forwarder the wrapper runs the base default, the inner
backend's overridden ``get_all_metadata`` (e.g. pgvector's
``with_document=False`` fast path from #1892) is unreachable, and every
metadata-only fetch transfers the full document column over the wire."""
class _Inner:
def __init__(self):
self.calls = []
def get_all_metadata(self, where=None):
self.calls.append(where)
return [{"wing": "alpha"}, {"wing": "beta"}]
# Provide a ``get`` so a missing forwarder would silently succeed via
# the BaseCollection default rather than crash — this is exactly the
# shape that hid the bug in production. Returning a sentinel here
# makes a wrong delegation observable: the test would receive
# ``[{"WRONG"}]``, not the inner's real list.
def get(self, **_kw):
from mempalace.backends.base import GetResult
return GetResult(ids=["bad"], documents=[""], metadatas=[{"WRONG": True}])
inner = _Inner()
out = ew.EmbeddingCollection(inner).get_all_metadata(where={"wing": "alpha"})
assert out == [{"wing": "alpha"}, {"wing": "beta"}]
assert inner.calls == [{"wing": "alpha"}]
def test_wrapper_forwards_all_concrete_basecollection_methods():
"""Every concrete (non-abstract) public method on ``BaseCollection`` must
be explicitly defined on ``EmbeddingCollection``. Without an override,
Python MRO resolves the call to ``BaseCollection``'s default before
``__getattr__`` ever fires, silently shadowing the wrapped backend's real
implementation.
The bug class this catches: any new ``BaseCollection`` method with a
concrete default body (raises, returns ``{}``, returns a no-op, pages
through ``self.get()``) becomes a silent regression for every wrapped
backend the moment a backend overrides it. ``facet_counts`` (#1868) and
``get_all_metadata`` (#1796 / #1892) both hit this; ``lexical_search``
would have hit it earlier if the wrapper hadn't been updated by hand.
If this test fails, add an explicit forwarder on ``EmbeddingCollection``
that calls ``self._inner.<name>(...)``."""
import inspect
from mempalace.backends.base import BaseCollection
from mempalace.backends.embedding_wrapper import EmbeddingCollection
concrete: set[str] = set()
for name, member in inspect.getmembers(BaseCollection):
if name.startswith("_"):
continue
if isinstance(member, property):
if member.fget and not getattr(member.fget, "__isabstractmethod__", False):
concrete.add(name)
continue
if callable(member) and not getattr(member, "__isabstractmethod__", False):
concrete.add(name)
forwarded = set(vars(EmbeddingCollection).keys())
missing = sorted(concrete - forwarded)
assert not missing, (
"EmbeddingCollection must explicitly forward these concrete "
"BaseCollection methods (otherwise MRO resolves the call here and "
"shadows the inner backend's implementation): "
f"{missing}"
)
+1 -1
View File
@@ -347,7 +347,7 @@ def test_cache_key_separates_models(monkeypatch):
"""
class DummyMiniLM:
def __init__(self, preferred_providers=None):
def __init__(self, preferred_providers=None, intra_op_num_threads=0):
self.kind = "minilm"
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyMiniLM)
+69
View File
@@ -0,0 +1,69 @@
"""Tests for no-LLM structural entity extraction."""
from mempalace.entities import entities_metadata, extract_structural_entities
def test_extracts_code_symbols_paths_urls():
text = (
"We patched `_extract_authored_at` in rag/convo_miner.py so MemoryStack and "
"ChromaBackend agree. See module.func and pkg.Class.method, plus do_thing_now. "
"Ref https://github.com/MemPalace/mempalace/pull/1890 for details."
)
ents = set(extract_structural_entities(text))
assert "_extract_authored_at" in ents
assert "rag/convo_miner.py" in ents
assert "MemoryStack" in ents
assert "ChromaBackend" in ents
assert "module.func" in ents
assert "pkg.Class.method" in ents
assert "do_thing_now" in ents
assert any(e.startswith("https://github.com/MemPalace") for e in ents)
def test_excludes_prose_noise():
text = "This is a normal sentence, e.g. with i.e. abbreviations and version 1.2.3 here."
ents = extract_structural_entities(text)
# No plain prose words, no "e.g"/"i.e", no bare version numbers.
assert ents == []
def test_ranked_by_frequency_then_order():
text = "alpha_one alpha_one alpha_one beta_two beta_two gamma_three"
ents = extract_structural_entities(text)
assert ents[:3] == ["alpha_one", "beta_two", "gamma_three"]
def test_dedup_case_insensitive_keeps_first_form():
text = "`MemoryStack` and memorystack and MEMORYSTACK"
ents = extract_structural_entities(text)
assert ents.count("MemoryStack") == 1
assert ents == ["MemoryStack"]
def test_respects_max_entities():
text = " ".join(f"sym_{i}_x" for i in range(50))
assert len(extract_structural_entities(text, max_entities=10)) == 10
def test_extracts_leading_underscore_snake_in_plain_text():
# Not in backticks — must still be caught by the snake-case pattern.
ents = extract_structural_entities("we called _extract_authored_at and _do_thing here")
assert "_extract_authored_at" in ents
assert "_do_thing" in ents
def test_semicolon_in_entity_does_not_corrupt_metadata():
# A backtick span containing ';' must not split the ;-joined metadata field.
md = entities_metadata("see `a(); b()` and TwoThing")
parts = md.split(";")
# Every part is a whole entity — no fragment is a bare separator artifact.
assert all(p.strip() for p in parts)
assert "TwoThing" in parts
def test_metadata_is_semicolon_joined():
text = "`one_thing` and TwoThing"
md = entities_metadata(text)
assert md == "one_thing;TwoThing"
assert entities_metadata("") == ""
assert entities_metadata("just plain prose with nothing structural") == ""
+20
View File
@@ -531,6 +531,24 @@ def test_scan_for_detection_skips_git_dir(tmp_path):
assert not any(".git" in f for f in file_strs)
def test_scan_for_detection_includes_latex_prose(tmp_path):
# .tex and .bib are prose-heavy (author names, abstracts, citations) and
# belong in the preferred PROSE_EXTENSIONS bucket alongside .md / .rst,
# not the code-file fallback. .bib in particular is almost entirely
# author names — high entity density per byte.
(tmp_path / "paper.tex").write_text(
"\\documentclass{article}\\author{Leslie Lamport}\\begin{document}Body.\\end{document}"
)
(tmp_path / "refs.bib").write_text(
"@article{l86, author={Leslie Lamport}, title={LaTeX}, year={1986}}"
)
(tmp_path / "code.py").write_text("import os")
files = scan_for_detection(str(tmp_path))
extensions = {os.path.splitext(str(f))[1] for f in files}
assert ".tex" in extensions
assert ".bib" in extensions
# ── module-level constants ──────────────────────────────────────────────
@@ -543,6 +561,8 @@ def test_stopwords_contains_common_words():
def test_prose_extensions():
assert ".txt" in PROSE_EXTENSIONS
assert ".md" in PROSE_EXTENSIONS
assert ".tex" in PROSE_EXTENSIONS
assert ".bib" in PROSE_EXTENSIONS
# ── _print_entity_list ─────────────────────────────────────────────────
+37 -21
View File
@@ -19,6 +19,9 @@ Also pins the three feature contracts:
from __future__ import annotations
import json
import os
import subprocess
import sys
from unittest.mock import MagicMock, patch
import pytest
@@ -260,32 +263,45 @@ class TestCheckTextContract:
class TestCLI:
def test_exits_nonzero_when_issues_found(self, tmp_path, monkeypatch, capsys):
def test_exits_nonzero_when_issues_found(self, tmp_path):
"""The CLI exit code is how shell scripts / hooks know to act —
pin it explicitly."""
registry = tmp_path / "known_entities.json"
registry.write_text(json.dumps({"people": ["Milla", "Mila"]}))
from mempalace import fact_checker, miner
pin it explicitly.
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
# Simulate argv: "Mila said hi"
monkeypatch.setattr(
"sys.argv",
["fact_checker", "Mila said hi", "--palace", str(tmp_path / "palace")],
Uses a fresh subprocess so that the already-imported
``mempalace.fact_checker`` module in the test process does not
collide with runpy re-executing it as ``__main__``, which produced
a spurious RuntimeWarning from <frozen runpy>.
"""
# Place the registry where the subprocess's miner will find it:
# $HOME/.mempalace/known_entities.json. We give the subprocess a
# private HOME so we don't touch the developer's real registry.
fake_home = tmp_path / "home"
mempalace_dir = fake_home / ".mempalace"
mempalace_dir.mkdir(parents=True)
(mempalace_dir / "known_entities.json").write_text(
json.dumps({"people": ["Milla", "Mila"]})
)
with pytest.raises(SystemExit) as excinfo:
# Re-exec the __main__ block via runpy.
import runpy
runpy.run_module("mempalace.fact_checker", run_name="__main__")
env = {**os.environ, "HOME": str(fake_home), "USERPROFILE": str(fake_home)}
result = subprocess.run(
[
sys.executable,
"-m",
"mempalace.fact_checker",
"Mila said hi",
"--palace",
str(tmp_path / "palace"),
],
capture_output=True,
text=True,
env=env,
)
# Issues found → exit code 1.
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "similar_name" in out
# Silence unused import warning.
_ = (MagicMock, patch, fact_checker)
assert result.returncode == 1
assert "similar_name" in result.stdout
# Silence unused import warning (MagicMock, patch still used by
# other tests in the class).
_ = (MagicMock, patch)
def test_reconfigures_stdio_to_utf8_on_windows(self):
"""Windows fact_checker --stdin must decode payload as UTF-8.
+7 -2
View File
@@ -1158,7 +1158,9 @@ def test_mine_formats_calls_compute_topic_tunnels_after_loop(_mine_formats_mocks
patch("mempalace.format_miner._compute_topic_tunnels_for_wing", return_value=0) as p_tun,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_aya")
p_tun.assert_called_once_with("wing_aya")
p_tun.assert_called_once()
assert p_tun.call_args.args == ("wing_aya",)
assert p_tun.call_args.kwargs["config"].palace_path == str(tmp / "palace")
def test_mine_formats_tunnel_failure_does_not_crash_mine(_mine_formats_mocks):
@@ -1518,13 +1520,16 @@ def test_mine_formats_threads_chunk_size_from_user_config(monkeypatch, tmp_path:
# Inject custom config values via a fake MempalaceConfig.
class _FakeMempalaceConfig:
def __init__(self, **kwargs):
self._palace_path = kwargs.get("palace_path", str(tmp_path / "palace"))
chunk_size = 1234
chunk_overlap = 56
min_chunk_size = 78
@property
def palace_path(self):
return str(tmp_path / "palace")
return self._palace_path
monkeypatch.setattr(format_miner, "MempalaceConfig", _FakeMempalaceConfig)
+31
View File
@@ -92,6 +92,24 @@ class TestHallwayStorage:
class TestComputeHallways:
def test_explicit_config_scopes_persistence_to_selected_palace(self, tmp_path):
from mempalace.config import MempalaceConfig
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
col = _fake_collection(
[
{"wing": "wing_aya", "room": "diary", "entities": "Aya;Lumi"},
{"wing": "wing_aya", "room": "letters", "entities": "Aya;Lumi"},
]
)
created = hallways_mod.compute_hallways_for_wing("wing_aya", col=col, config=selected_cfg)
assert len(created) == 1
assert hallways_mod.list_hallways(config=selected_cfg) == created
assert hallways_mod.list_hallways(config=default_cfg) == []
def test_returns_empty_for_unknown_wing(self, tmp_path, monkeypatch):
"""Wing with no drawers → no hallways, no crash."""
_use_tmp_hallway_file(monkeypatch, tmp_path)
@@ -300,6 +318,19 @@ class TestHallwayQuery:
hallways_mod._save_hallways([{"id": "h1", "wing": "wing_aya"}])
assert hallways_mod.delete_hallway("nonexistent") is False
def test_delete_hallway_uses_selected_palace_config(self, tmp_path):
from mempalace.config import MempalaceConfig
default_cfg = MempalaceConfig(palace_path=tmp_path / "default" / "palace")
selected_cfg = MempalaceConfig(palace_path=tmp_path / "selected" / "palace")
record = {"id": "h1", "wing": "wing_aya"}
hallways_mod._save_hallways([record], default_cfg)
hallways_mod._save_hallways([record], selected_cfg)
assert hallways_mod.delete_hallway("h1", config=selected_cfg) is True
assert hallways_mod.list_hallways(config=selected_cfg) == []
assert hallways_mod.list_hallways(config=default_cfg) == [record]
# ─────────────────────────────────────────────────────────────────────────────
# L7 dynamics integration — hallway records carry strength/stability/etc
+57 -2
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import os
import pickle
import sqlite3
import time
import pytest
@@ -588,7 +589,9 @@ def test_bm25_fallback_handles_short_query(palace_with_drawers):
def test_repair_status_reports_diverged(tmp_path, capsys):
"""The status command prints DIVERGED and recommends rebuild."""
"""The status command prints DIVERGED and recommends the from-sqlite
rebuild (not a re-mine), since a diverged index means the rows are
intact in sqlite but the HNSW segment is out of sync (#1843)."""
from mempalace.repair import status as repair_status
seg = "seg-status"
@@ -597,7 +600,8 @@ def test_repair_status_reports_diverged(tmp_path, capsys):
out = repair_status(palace_path=str(tmp_path))
captured = capsys.readouterr().out
assert "DIVERGED" in captured
assert "mempalace repair`" in captured
assert "mempalace repair --mode from-sqlite --archive-existing" in captured
assert "Do not re-mine" in captured
assert out["drawers"]["diverged"] is True
@@ -640,3 +644,54 @@ def test_tool_status_via_sqlite_returns_breakdown(palace_with_drawers, monkeypat
# ops×2 (incident + repair runbook), design×1 (metaphor).
assert out["wings"].get("ops") == 2
assert out["wings"].get("design") == 1
def test_capacity_status_flags_small_gap_with_explicit_low_sync_threshold(tmp_path):
"""New palaces use a low explicit sync threshold, so 57 missing rows is unsafe."""
seg = "seg-1816-explicit-low-sync"
_seed_chroma_db(str(tmp_path), sqlite_count=1768, segment_id=seg, sync_threshold=2)
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["divergence"] == 57
assert info["threshold"] == 4
assert info["status"] == "diverged"
assert info["diverged"] is True
assert "repair" in info["message"].lower()
def test_capacity_status_flags_stale_below_floor_divergence(tmp_path):
"""A persistent below-floor sqlite>HNSW gap must not be treated as fresh lag."""
from mempalace.backends import chroma
seg = "seg-1816-stale-below-floor"
_seed_chroma_db(str(tmp_path), sqlite_count=1768, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
pickle_path = tmp_path / seg / "index_metadata.pickle"
old = time.time() - chroma._HNSW_PERSISTENT_DIVERGENCE_GRACE_SECONDS - 10
os.utime(pickle_path, (old, old))
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["divergence"] == 57
assert info["threshold"] >= 2000
assert info["status"] == "diverged"
assert info["diverged"] is True
assert "persisted below" in info["message"]
def test_capacity_status_ok_with_stale_metadata_under_explicit_threshold(tmp_path):
"""An idle database with an explicit sync threshold and a gap within tolerance must remain OK."""
seg = "seg-1816-stale-ok"
_seed_chroma_db(str(tmp_path), sqlite_count=1712, segment_id=seg, sync_threshold=2)
_write_pickle(str(tmp_path), seg, hnsw_count=1711)
pickle_path = tmp_path / seg / "index_metadata.pickle"
old = time.time() - 400.0
os.utime(pickle_path, (old, old))
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["divergence"] == 1
assert info["threshold"] == 4
assert info["status"] == "ok"
assert info["diverged"] is False
+30 -10
View File
@@ -113,8 +113,11 @@ def test_quarantine_leaves_reasonable_payload_in_place(tmp_path):
assert seg_dir.exists()
def test_segment_health_rejects_zero_byte_link_lists_with_payload(tmp_path):
"""Regression #1457: real HNSW payload with empty link_lists.bin is corrupt."""
def test_segment_health_accepts_zero_byte_link_lists_with_valid_pickle(tmp_path):
"""Regression #1716: an all-layer-0 HNSW index serializes an empty
link_lists.bin (level-0 links live in data_level0.bin). With a complete
index_metadata.pickle the persist finished, so the segment is healthy —
not the partial-flush corruption a missing marker would imply."""
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
_write_segment(
@@ -124,11 +127,32 @@ def test_segment_health_rejects_zero_byte_link_lists_with_payload(tmp_path):
write_metadata=True,
)
assert _segment_appears_healthy(str(seg_dir))
def test_segment_health_rejects_zero_byte_link_lists_with_truncated_pickle(tmp_path):
"""An empty link_lists.bin with real payload but a truncated metadata
envelope is a partial flush, not an all-layer-0 index — still rejected.
The completion marker (intact pickle), not link_lists content, is what
distinguishes the two."""
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
_write_segment(
seg_dir,
data_size=2_000,
link_size=0,
write_metadata=False,
)
# Persist started (0x80 head) but never wrote the STOP terminator.
(seg_dir / "index_metadata.pickle").write_bytes(b"\x80" + b"x" * 20)
assert not _segment_appears_healthy(str(seg_dir))
def test_quarantine_catches_zero_byte_link_lists_when_stale(tmp_path):
"""Regression #1457: stale segments with empty link_lists.bin are quarantined."""
def test_quarantine_leaves_zero_byte_link_lists_with_valid_pickle(tmp_path):
"""Regression #1716: a stale all-layer-0 segment with a complete pickle is
left in place. Quarantining it spawned the self-perpetuating loop — repair
rebuilds the byte-identical empty-link_lists shape and it re-quarantines."""
palace = tmp_path / "palace"
palace.mkdir()
@@ -150,9 +174,5 @@ def test_quarantine_catches_zero_byte_link_lists_when_stale(tmp_path):
moved = quarantine_stale_hnsw(str(palace), stale_seconds=300)
assert len(moved) == 1
assert not seg_dir.exists()
moved_path = Path(moved[0])
assert moved_path.exists()
assert moved_path.name.startswith("11111111-2222-3333-4444-555555555555.drift-")
assert moved == []
assert seg_dir.exists()
+45
View File
@@ -27,6 +27,14 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SAVE_HOOK = REPO_ROOT / "hooks" / "mempal_save_hook.sh"
PRECOMPACT_HOOK = REPO_ROOT / "hooks" / "mempal_precompact_hook.sh"
SESSION_END_HOOK = REPO_ROOT / "hooks" / "mempal_session_end_hook.sh"
PLUGIN_SESSION_END_HOOK = REPO_ROOT / ".claude-plugin" / "hooks" / "mempal-session-end-hook.sh"
_SESSION_END_HOOKS = pytest.mark.parametrize(
"hook",
[SESSION_END_HOOK, PLUGIN_SESSION_END_HOOK],
ids=["user_hook", "plugin_hook"],
)
# Re-used by every parametrize decorator that runs the same test against
# both hooks. ``ids=`` keeps pytest output readable (`...[save_hook]`
@@ -322,3 +330,40 @@ class TestFailLoudGuard:
err_log = tmp_path / ".mempalace" / "hook_state" / "last_python_err.log"
mode = stat.S_IMODE(err_log.stat().st_mode)
assert mode == 0o600, f"last_python_err.log mode should be 0600 on failure, got {oct(mode)}"
class TestSessionEndWrappers:
"""The SessionEnd wrappers must background their work — so the foreground
beats Claude Code's ~1.5s SessionEnd budget (a plugin-provided per-hook
timeout cannot raise it) — and stay bash 3.2-safe like the other hooks."""
@_SESSION_END_HOOKS
def test_bash_syntax_clean(self, hook):
p = subprocess.run(["bash", "-n", str(hook)], capture_output=True, text=True)
assert p.returncode == 0, f"{hook.name} syntax error: {p.stderr}"
@_SESSION_END_HOOKS
def test_dispatches_session_end_through_cli(self, hook):
src = _hook_src_no_comments(hook)
# The dispatcher runs ``mempalace hook run "$@"`` in run_mempalace_hook,
# and the bottom call supplies the ``--hook session-end --harness`` args
# — so the two halves are asserted separately, not as one contiguous string.
assert "hook run" in src
assert "--hook session-end --harness" in src
assert "MEMPALACE_HOOK_HARNESS" in src
assert "MEMPAL_PYTHON" in src
@_SESSION_END_HOOKS
def test_backgrounds_then_returns_empty(self, hook):
src = _hook_src_no_comments(hook)
assert "</dev/null &" in src, f"{hook.name} does not background the worker"
assert "disown" in src, f"{hook.name} does not disown the backgrounded worker"
assert src.rstrip().endswith("printf '{}'"), (
f"{hook.name} must return the empty payload immediately"
)
@_SESSION_END_HOOKS
def test_no_bash4_only_builtins(self, hook):
src = _hook_src_no_comments(hook)
assert "mapfile" not in src, f"{hook.name} uses mapfile (bash 4 only)"
assert "readarray" not in src, f"{hook.name} uses readarray (bash 4 only)"
+472 -7
View File
@@ -8,26 +8,32 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hypothesis import given
from hypothesis import strategies as st
import mempalace.hooks_cli as hooks_cli_mod
from mempalace.config import sanitize_name
from mempalace.hooks_cli import (
SAVE_INTERVAL,
_count_human_messages,
_diary_agent_for_harness,
_extract_recent_messages,
_get_mine_targets,
_hooks_daemon_enabled,
_log,
_maybe_auto_ingest,
_mempalace_python,
_mine_already_running,
_mine_sync,
_parse_harness_input,
_safe_wing_slug,
_sanitize_session_id,
_save_diary_direct,
_validate_transcript_path,
_wing_from_transcript_path,
hook_stop,
hook_session_start,
hook_session_end,
hook_precompact,
run_hook,
_claim_mine_slot,
@@ -467,6 +473,45 @@ def test_stop_hook_checkpoint_visible_to_diary_read(monkeypatch, config, palace_
assert legacy.get("entries") == []
def test_save_diary_direct_daemon_opt_in_submits_job(tmp_path):
transcript = tmp_path / "session.jsonl"
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"message {i}"}} for i in range(3)],
)
env = {"MEMPALACE_HOOKS_DAEMON": "yes", "MEMPALACE_PALACE_PATH": str(palace_dir)}
job = {"id": "job", "state": "succeeded", "result": {"success": True, "entry_id": "e1"}}
with patch.dict("os.environ", env):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._daemon_available", return_value=True):
with patch("mempalace.daemon.submit_job", return_value=job) as mock_submit:
result = _save_diary_direct(
str(transcript),
"sess1",
wing="wing_project",
agent_name="claude",
)
assert result["count"] == 3
mock_submit.assert_called_once()
assert mock_submit.call_args.args[0] == "diary_write"
payload = mock_submit.call_args.args[1]
assert payload["agent_name"] == "claude"
assert payload["wing"] == "wing_project"
assert payload["topic"] == "checkpoint"
assert (tmp_path / "last_checkpoint").exists()
def test_hooks_daemon_enabled_requires_explicit_true():
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
assert _hooks_daemon_enabled() is False
mock_cfg_cls.return_value.hook_use_daemon = True
assert _hooks_daemon_enabled() is True
# --- hook_session_start ---
@@ -681,6 +726,84 @@ def test_wing_from_transcript_path_cwd_handles_non_string_cwd(tmp_path):
assert _wing_from_transcript_path(str(transcript)) == "wing_proper_name"
# --- _safe_wing_slug: special-character project dirs (e.g. +project) ---
def test_safe_wing_slug_strips_leading_plus():
"""Regression: a ``+``-prefixed folder (e.g. ``+project``) leaked ``+`` into the
slug, producing ``wing_+project`` which ``sanitize_name`` rejects — silently
breaking diary auto-save for that project."""
assert _safe_wing_slug("+project") == "project"
def test_safe_wing_slug_replaces_inner_special_chars():
assert _safe_wing_slug("foo+bar") == "foo_bar"
def test_safe_wing_slug_preserves_space_and_hyphen_behavior():
# spaces and hyphens still collapse to underscores (no regression)
assert _safe_wing_slug("React Native") == "react_native"
assert _safe_wing_slug("claude-code") == "claude_code"
def test_safe_wing_slug_preserves_existing_valid_names():
"""Backward compatibility: a name that already produced a valid wing keeps the
same slug, so previously-filed diary entries aren't orphaned (AGENTS.md: never
destroy existing data). ``.`` and ``'`` are both accepted by sanitize_name and
must survive."""
assert _safe_wing_slug("myapp") == "myapp"
assert _safe_wing_slug("my.app") == "my.app"
assert _safe_wing_slug("v1.2.3") == "v1.2.3"
assert _safe_wing_slug("o'brien") == "o'brien"
def test_safe_wing_slug_collapses_double_dots():
"""``..`` must collapse — sanitize_name rejects it as path traversal."""
assert _safe_wing_slug("my..app") == "my.app"
assert _safe_wing_slug("..hidden..") == "hidden"
def test_safe_wing_slug_caps_length_for_sanitize_name():
"""sanitize_name rejects names over 128 chars; the slug stays short enough that
wing_<slug> never trips that limit, even for very long directory names."""
slug = _safe_wing_slug("a" * 500)
assert len(slug) <= 120
sanitize_name(f"wing_{slug}") # must not raise
def test_safe_wing_slug_falls_back_to_sessions_when_empty():
# names made entirely of disallowed characters reduce to nothing
assert _safe_wing_slug("+") == "sessions"
assert _safe_wing_slug("@#$") == "sessions"
def test_wing_from_transcript_path_cwd_plus_prefixed_dir(tmp_path):
"""Reporter's case: a ``+``-prefixed working directory must yield a sanitizable
wing (``wing_project``), not the rejected ``wing_+project``."""
project_dir = tmp_path / "encoded-dir"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
transcript.write_text(
'{"type":"user","cwd":"/Users/me/code/+project","content":"hi"}\n',
encoding="utf-8",
)
assert _wing_from_transcript_path(str(transcript)) == "wing_project"
def test_wing_from_transcript_path_legacy_plus_prefixed_project():
"""Legacy ``-Projects-<name>`` path with a ``+``-prefixed project folder."""
path = "/Users/me/foo/-Projects-+app/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_app"
@given(st.text(min_size=1, max_size=300))
def test_safe_wing_slug_always_yields_sanitizable_wing(name):
"""Property: for ANY non-empty input, ``wing_<slug>`` must pass sanitize_name —
the entire contract of the helper (a rejected wing silently breaks auto-save)."""
wing = f"wing_{_safe_wing_slug(name)}"
assert sanitize_name(wing) == wing
# --- _log ---
@@ -788,6 +911,33 @@ def test_maybe_auto_ingest_with_env(tmp_path):
assert cmd[cmd.index("--mode") + 1] == "projects"
def test_maybe_auto_ingest_daemon_opt_in_submits_job(tmp_path):
"""Daemon-enabled hooks submit a background mine instead of spawning one."""
mempal_dir = tmp_path / "project"
palace_dir = tmp_path / "palace"
mempal_dir.mkdir()
palace_dir.mkdir()
env = {
"MEMPAL_DIR": str(mempal_dir),
"MEMPALACE_HOOKS_DAEMON": "yes",
"MEMPALACE_PALACE_PATH": str(palace_dir),
}
with patch.dict("os.environ", env):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._daemon_available", return_value=True):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
with patch(
"mempalace.daemon.submit_job", return_value={"id": "job"}
) as mock_submit:
_maybe_auto_ingest()
mock_popen.assert_not_called()
mock_submit.assert_called_once()
assert mock_submit.call_args.args[0] == "mine"
assert mock_submit.call_args.args[1]["source"] == str(mempal_dir.resolve())
assert mock_submit.call_args.kwargs["wait"] is False
def test_maybe_auto_ingest_uses_mempalace_python(tmp_path):
"""Spawned mine command uses _mempalace_python(), not bare sys.executable.
@@ -968,17 +1118,26 @@ def test_detached_popen_kwargs_posix(monkeypatch):
def test_detached_popen_kwargs_windows(monkeypatch):
"""On Windows, kwargs include creationflags that fully detach the child.
"""On Windows, the miner child gets a hidden console (CREATE_NO_WINDOW),
not a detached/no-console child (DETACHED_PROCESS).
Without these, the parent hook hangs at session end on Windows because
the child's inherited stdout/stderr handles keep the parent's exit
blocked (#1268 root cause for the Python hook path).
DETACHED_PROCESS gave the child no console at all, which caused any
console grandchild it spawned to allocate a fresh *visible* window
(#1783). CREATE_NO_WINDOW gives a real-but-invisible console that all
descendants inherit, so nothing flashes — while still fixing the
#1268 hang (stdin=DEVNULL, close_fds, explicit stdout/stderr redirect,
and CREATE_NEW_PROCESS_GROUP for the signal boundary are unchanged).
Per the Win32 CreateProcess docs CREATE_NO_WINDOW is ignored when OR'd
with DETACHED_PROCESS, so the two must be mutually exclusive.
"""
from mempalace.hooks_cli import _detached_popen_kwargs
monkeypatch.setattr("mempalace.hooks_cli.os.name", "nt")
# Simulate Windows-only Popen flag constants. Patch on the imported
# subprocess module within hooks_cli so getattr() picks them up.
# Simulate Windows-only Popen flag constants on the imported subprocess
# module so getattr() picks them up cross-platform.
monkeypatch.setattr(
"mempalace.hooks_cli.subprocess.CREATE_NO_WINDOW", 0x08000000, raising=False
)
monkeypatch.setattr(
"mempalace.hooks_cli.subprocess.DETACHED_PROCESS", 0x00000008, raising=False
)
@@ -989,7 +1148,10 @@ def test_detached_popen_kwargs_windows(monkeypatch):
assert kwargs.get("stdin") is subprocess.DEVNULL
assert kwargs.get("close_fds") is True
flags = kwargs.get("creationflags", 0)
assert flags & 0x00000008, "DETACHED_PROCESS must be set"
assert flags & 0x08000000, "CREATE_NO_WINDOW must be set"
assert not (flags & 0x00000008), (
"DETACHED_PROCESS must NOT be set (it suppresses CREATE_NO_WINDOW)"
)
assert flags & 0x00000200, "CREATE_NEW_PROCESS_GROUP must be set"
@@ -1114,6 +1276,31 @@ def test_ingest_transcript_uses_detached_kwargs(tmp_path):
assert kwargs.get("close_fds") is True
def test_ingest_transcript_daemon_opt_in_submits_job(tmp_path):
transcript = tmp_path / "session.jsonl"
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
transcript.write_text("x" * 200)
env = {"MEMPALACE_HOOKS_DAEMON": "yes", "MEMPALACE_PALACE_PATH": str(palace_dir)}
with patch.dict("os.environ", env):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._daemon_available", return_value=True):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
with patch(
"mempalace.daemon.submit_job", return_value={"id": "job"}
) as mock_submit:
from mempalace.hooks_cli import _ingest_transcript
_ingest_transcript(str(transcript))
mock_popen.assert_not_called()
mock_submit.assert_called_once()
payload = mock_submit.call_args.args[1]
assert payload["source"] == str(tmp_path)
assert payload["mode"] == "convos"
assert payload["wing"] == "sessions"
def test_ingest_transcript_skips_when_target_running(tmp_path):
"""Repeated transcript ingests for the same transcript should dedup."""
transcript = tmp_path / "session.jsonl"
@@ -1781,6 +1968,284 @@ def test_hook_stop_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch)
assert not fake_root.exists()
# ── session-end hook (#1341) ──────────────────────────────────────────────
def test_run_hook_dispatches_session_end():
stdin_data = json.dumps({"session_id": "run-test"})
with patch("sys.stdin", io.StringIO(stdin_data)):
with patch("mempalace.hooks_cli.hook_session_end") as mock_hook:
run_hook("session-end", "claude-code")
mock_hook.assert_called_once_with({"session_id": "run-test"}, "claude-code")
def test_session_end_uses_detached_paths_not_sync_mine(tmp_path):
"""SessionEnd must spawn the detached ingest/auto-ingest and NEVER call the
synchronous ``_mine_sync``: the foreground SessionEnd budget (~1.5s, which a
plugin-provided per-hook timeout cannot raise) would kill a synchronous mine
before it saved anything.
"""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(3)],
)
last_save_file = tmp_path / "sess_last_save"
last_save_file.write_text("2", encoding="utf-8")
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = True
mock_cfg_cls.return_value.hook_desktop_toast = False
with (
patch(
"mempalace.hooks_cli._save_diary_direct",
return_value={"count": 3, "themes": ["exit"]},
) as mock_save,
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
patch("mempalace.hooks_cli._mine_sync") as mock_sync,
):
result = _capture_hook_output(
hook_session_end,
{"session_id": "sess", "transcript_path": str(transcript)},
state_dir=tmp_path,
)
assert result == {}
expected_path = str(transcript.resolve())
mock_ingest.assert_called_once_with(expected_path)
mock_auto.assert_called_once()
mock_sync.assert_not_called()
mock_save.assert_called_once_with(
expected_path, "sess", wing="wing_sessions", toast=False, agent_name="claude"
)
# The session is over; its per-session save marker is cleared.
assert not last_save_file.exists()
def test_session_end_disabled_by_config_clears_marker(tmp_path):
last_save_file = tmp_path / "sess_last_save"
last_save_file.write_text("15", encoding="utf-8")
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = False
with (
patch("mempalace.hooks_cli._save_diary_direct") as mock_save,
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
):
result = _capture_hook_output(
hook_session_end,
{"session_id": "sess", "transcript_path": ""},
state_dir=tmp_path,
)
assert result == {}
mock_save.assert_not_called()
mock_ingest.assert_not_called()
mock_auto.assert_not_called()
assert not last_save_file.exists()
def test_session_end_defaults_to_saving_when_config_unreadable(tmp_path):
"""A corrupt/unreadable config must not lose the final save: the handler
defaults to auto-save on (toasts off) instead of crashing the hook."""
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, [{"message": {"role": "user", "content": "hi"}}])
with patch("mempalace.hooks_cli.MempalaceConfig", side_effect=RuntimeError("corrupt config")):
with (
patch(
"mempalace.hooks_cli._save_diary_direct",
return_value={"count": 1, "themes": []},
) as mock_save,
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
):
result = _capture_hook_output(
hook_session_end,
{"session_id": "cfg", "transcript_path": str(transcript)},
state_dir=tmp_path,
)
assert result == {}
mock_save.assert_called_once()
mock_ingest.assert_called_once()
mock_auto.assert_called_once()
def test_session_end_clears_marker_even_if_capture_raises(tmp_path):
"""Marker cleanup runs in a ``finally`` so a failing ingest still cleans up
and never wedges the per-session marker on."""
last_save_file = tmp_path / "boom_last_save"
last_save_file.write_text("5", encoding="utf-8")
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = True
mock_cfg_cls.return_value.hook_desktop_toast = False
with (
patch("mempalace.hooks_cli.STATE_DIR", tmp_path),
patch(
"mempalace.hooks_cli._ingest_transcript",
side_effect=RuntimeError("boom"),
),
patch("mempalace.hooks_cli._output"),
):
with pytest.raises(RuntimeError):
hook_session_end(
{"session_id": "boom", "transcript_path": str(tmp_path / "x.jsonl")},
"claude-code",
)
assert not last_save_file.exists()
def test_session_end_clears_marker_on_parse_failure(tmp_path):
"""A non-dict payload makes _parse_harness_input raise, but parsing now runs
inside the try, so the finally still clears the default-session marker
instead of skipping cleanup entirely."""
last_save_file = tmp_path / "unknown_last_save"
last_save_file.write_text("5", encoding="utf-8")
with (
patch("mempalace.hooks_cli.STATE_DIR", tmp_path),
patch("mempalace.hooks_cli._output"),
):
with pytest.raises(AttributeError):
hook_session_end(["not", "a", "dict"], "claude-code")
assert not last_save_file.exists()
def test_hook_session_end_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
hook_session_end({"session_id": "absent", "transcript_path": ""}, "claude-code")
assert json.loads(buf.getvalue() or "{}") == {}
assert not fake_root.exists()
def test_session_end_auto_ingest_only_when_no_transcript(tmp_path):
"""auto_save on but no transcript: project mine still fires, transcript-only
paths are skipped, and the marker is still cleared. Guards against wrapping
``_maybe_auto_ingest`` inside the ``if transcript_path`` block by mistake."""
last_save_file = tmp_path / "sess_last_save"
last_save_file.write_text("3", encoding="utf-8")
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = True
mock_cfg_cls.return_value.hook_desktop_toast = False
with (
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
patch("mempalace.hooks_cli._save_diary_direct") as mock_save,
patch("mempalace.hooks_cli._mine_sync") as mock_sync,
):
result = _capture_hook_output(
hook_session_end,
{"session_id": "sess", "transcript_path": ""},
state_dir=tmp_path,
)
assert result == {}
mock_auto.assert_called_once()
mock_ingest.assert_not_called()
mock_save.assert_not_called()
mock_sync.assert_not_called()
assert not last_save_file.exists()
def test_session_end_rejects_invalid_transcript_path(tmp_path):
"""A traversal / wrong-suffix transcript_path is rejected by the validator:
no transcript ingest or diary write fires (the independent project mine
still runs), and the per-session marker is still cleared."""
last_save_file = tmp_path / "bad_last_save"
last_save_file.write_text("5", encoding="utf-8")
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = True
mock_cfg_cls.return_value.hook_desktop_toast = False
with (
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
patch("mempalace.hooks_cli._save_diary_direct") as mock_save,
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
):
result = _capture_hook_output(
hook_session_end,
{"session_id": "bad", "transcript_path": "../../etc/passwd"},
state_dir=tmp_path,
)
assert result == {}
mock_ingest.assert_not_called()
mock_save.assert_not_called()
mock_auto.assert_called_once() # project mine is independent of the transcript
assert not last_save_file.exists()
def test_session_end_accepts_full_sessionend_payload(tmp_path):
"""The handler tolerates the real Claude Code SessionEnd stdin (reason,
hook_event_name, cwd) — extra keys ignored, behavior unchanged. Pins the
contract so a future strict-parse or reason-branching regression is caught."""
transcript = tmp_path / "t.jsonl"
_write_transcript(transcript, [{"message": {"role": "user", "content": "hi"}}])
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = True
mock_cfg_cls.return_value.hook_desktop_toast = False
with (
patch(
"mempalace.hooks_cli._save_diary_direct",
return_value={"count": 1, "themes": []},
),
patch("mempalace.hooks_cli._ingest_transcript") as mock_ingest,
patch("mempalace.hooks_cli._maybe_auto_ingest") as mock_auto,
patch("mempalace.hooks_cli._mine_sync") as mock_sync,
):
result = _capture_hook_output(
hook_session_end,
{
"session_id": "sess",
"transcript_path": str(transcript),
"hook_event_name": "SessionEnd",
"reason": "logout",
"cwd": str(tmp_path),
},
state_dir=tmp_path,
)
assert result == {}
mock_ingest.assert_called_once_with(str(transcript.resolve()))
mock_auto.assert_called_once()
mock_sync.assert_not_called()
def test_session_end_checkpoint_visible_to_diary_read(
monkeypatch, config, palace_path, kg, tmp_path
):
"""The SessionEnd in-process diary checkpoint is real and discoverable via
diary_read (not mocked) — the diary half of #1341's clean-exit capture,
mirroring test_stop_hook_checkpoint_visible_to_diary_read."""
import chromadb
from mempalace import mcp_server
from mempalace.mcp_server import tool_diary_read
monkeypatch.setattr(mcp_server, "_config", config)
monkeypatch.setattr(mcp_server, "_get_kg", lambda *a, **kw: kg)
client = chromadb.PersistentClient(path=palace_path)
client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del client
transcript = tmp_path / "session.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(5)],
)
with patch("mempalace.hooks_cli.MempalaceConfig") as mock_cfg_cls:
mock_cfg_cls.return_value.hooks_auto_save = True
mock_cfg_cls.return_value.hook_desktop_toast = False
# Mock only the detached subprocess mines; run the REAL diary write.
with (
patch("mempalace.hooks_cli._ingest_transcript"),
patch("mempalace.hooks_cli._maybe_auto_ingest"),
):
hook_session_end(
{"session_id": "sessX", "transcript_path": str(transcript)},
"claude-code",
)
visible = tool_diary_read(agent_name="claude")
assert visible.get("total", 0) >= 1
assert "CHECKPOINT" in visible["entries"][0]["content"]
def test_hook_precompact_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
transcript = tmp_path / "t.jsonl"
+131
View File
@@ -24,6 +24,7 @@ import os
import stat
import subprocess
import sys
import time
from pathlib import Path
import pytest
@@ -31,6 +32,8 @@ import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
SAVE_HOOK = REPO_ROOT / "hooks" / "mempal_save_hook.sh"
PRECOMPACT_HOOK = REPO_ROOT / "hooks" / "mempal_precompact_hook.sh"
SESSION_END_HOOK = REPO_ROOT / "hooks" / "mempal_session_end_hook.sh"
PLUGIN_SESSION_END_HOOK = REPO_ROOT / ".claude-plugin" / "hooks" / "mempal-session-end-hook.sh"
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
@@ -168,3 +171,131 @@ class TestMempalPythonOverride:
assert "python3" in invocations, (
f"fallback-to-PATH did not use the shimmed python3. Marker log: {invocations!r}"
)
# ── session-end wrapper: must background so the foreground beats the budget ──
def _write_recording_mempalace(
path: Path, args_file: Path, *, sleep_secs: float = 0.0, done_file: Path | None = None
) -> Path:
"""A fake ``mempalace`` that consumes stdin, optionally sleeps, then records
its argv to ``args_file`` (and touches ``done_file``). Lets a test observe a
*backgrounded* dispatch after the wrapper's foreground has already returned.
"""
done_line = f'printf done > "{done_file}"' if done_file is not None else ":"
src = f"""#!/bin/bash
cat >/dev/null
sleep {sleep_secs}
printf '%s' "$*" > "{args_file}"
{done_line}
printf '{{}}'
"""
path.write_text(src)
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return path
def _wait_for(path: Path, timeout: float = 15.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if path.exists() and path.read_text():
return True
time.sleep(0.05)
return False
class TestSessionEndWrapper:
def test_foreground_returns_before_worker_finishes(self, tmp_path):
"""Budget contract: the foreground must return well before the (slow)
worker completes, otherwise SessionEnd's ~1.5s budget would kill the
mine. Proven with a worker that sleeps 2s before recording."""
args_file = tmp_path / "args.log"
done_file = tmp_path / "worker.done"
fake = _write_recording_mempalace(
tmp_path / "mempalace", args_file, sleep_secs=2.0, done_file=done_file
)
t0 = time.monotonic()
result = _run_hook(
SESSION_END_HOOK,
{"session_id": "abc", "transcript_path": ""},
env_overrides={"HOME": str(tmp_path)},
path_prefix=[fake.parent],
)
elapsed = time.monotonic() - t0
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert result.stdout == "{}"
assert elapsed < 1.5, f"foreground blocked {elapsed:.2f}s; the budget would kill it"
assert not done_file.exists(), (
"worker finished before the foreground returned — wrapper is not backgrounding"
)
assert _wait_for(done_file), "detached worker never completed"
assert args_file.read_text() == "hook run --hook session-end --harness claude-code"
def test_dispatches_via_mempal_python_override(self, tmp_path):
args_file = tmp_path / "args.log"
shim = tmp_path / "python3"
shim.write_text(
f"""#!/bin/bash
if [ "$1" = "-c" ]; then exit 0; fi
if [ "$1" = "-m" ] && [ "$2" = "mempalace" ]; then
shift 2
cat >/dev/null
printf '%s' "$*" > "{args_file}"
printf '{{}}'
exit 0
fi
exit 1
""",
encoding="utf-8",
)
shim.chmod(shim.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
result = _run_hook(
SESSION_END_HOOK,
{"session_id": "abc", "transcript_path": ""},
env_overrides={
"HOME": str(tmp_path),
"PATH": "/usr/bin:/bin",
"MEMPAL_PYTHON": str(shim),
},
)
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert result.stdout == "{}"
assert _wait_for(args_file), "backgrounded worker never ran"
assert args_file.read_text() == "hook run --hook session-end --harness claude-code"
def test_harness_override_is_forwarded(self, tmp_path):
args_file = tmp_path / "args.log"
fake = _write_recording_mempalace(tmp_path / "mempalace", args_file)
result = _run_hook(
SESSION_END_HOOK,
{"session_id": "abc", "transcript_path": ""},
env_overrides={"HOME": str(tmp_path), "MEMPALACE_HOOK_HARNESS": "codex"},
path_prefix=[fake.parent],
)
assert result.returncode == 0
assert _wait_for(args_file)
assert args_file.read_text() == "hook run --hook session-end --harness codex"
class TestPluginSessionEndWrapper:
def test_foreground_returns_before_worker_finishes(self, tmp_path):
args_file = tmp_path / "args.log"
done_file = tmp_path / "worker.done"
fake = _write_recording_mempalace(
tmp_path / "mempalace", args_file, sleep_secs=2.0, done_file=done_file
)
t0 = time.monotonic()
result = _run_hook(
PLUGIN_SESSION_END_HOOK,
{"session_id": "abc", "transcript_path": ""},
env_overrides={"HOME": str(tmp_path)},
path_prefix=[fake.parent],
)
elapsed = time.monotonic() - t0
assert result.returncode == 0, f"stderr={result.stderr!r}"
assert result.stdout == "{}"
assert elapsed < 1.5, f"plugin foreground blocked {elapsed:.2f}s"
assert not done_file.exists()
assert _wait_for(done_file), "detached plugin worker never completed"
assert args_file.read_text() == "hook run --hook session-end --harness claude-code"
+20
View File
@@ -213,6 +213,26 @@ class TestCandidateUnion:
f"(basename collision would drop one); got sources={sources}"
)
def test_union_respects_source_file_filter(self, tmp_path):
"""Union pulls BM25 candidates from sqlite FTS5 directly; the
source_file filter must constrain that pool too, not just the vector
path — otherwise union silently re-injects other sources (#1815)."""
palace = str(tmp_path / "palace")
_seed_drawers(palace)
result = search_memories(
_NARRATIVE_QUERY,
palace,
n_results=5,
candidate_strategy="union",
source_file="ticket_D2.md",
)
sources = {h["source_file"] for h in result["results"]}
assert sources <= {"ticket_D2.md"}, (
f"union must honor source_file on the BM25 pool; got {sources}"
)
# The BM25-strong brand-voice doc must NOT leak past the filter.
assert "brand_voice_D4.md" not in sources
class TestHybridRankTolerantOfMissingDistance:
"""``_hybrid_rank`` accepts ``distance=None`` — required for BM25-only
+72 -1
View File
@@ -12,7 +12,7 @@ from mempalace.palace import (
get_collection,
upsert_closet_lines,
)
from mempalace.searcher import search_memories
from mempalace.searcher import _hybrid_rank, search_memories
def _seed_drawers(palace_path):
@@ -133,3 +133,74 @@ class TestClosetMetadata:
assert h["matched_via"] == "drawer"
assert "closet_preview" not in h
assert h["closet_boost"] == 0.0
# ── source_file filter scopes both drawer and closet queries (#1815) ──────
class TestSourceFileFilter:
def test_source_file_filter_excludes_other_sources(self, tmp_path):
palace = str(tmp_path / "palace")
_seed_drawers(palace)
result = search_memories(
"Kafka consumer rebalance timeout",
palace,
n_results=5,
source_file="fixture_D4.md",
)
ids = [h["source_file"] for h in result["results"]]
assert ids, "the matching source_file drawer should be returned"
assert set(ids) == {"fixture_D4.md"}
def test_source_file_filter_overrides_closet_boost_for_other_source(self, tmp_path):
# A strong closet pointing at D1 must NOT leak D1 in when the search
# is scoped to a different source_file — the where clause is applied
# to the closet query too, not just the drawer query.
palace = str(tmp_path / "palace")
_seed_drawers(palace)
_seed_strong_closet_for(
palace,
drawer_id="D1",
source_file="fixture_D1.md",
topics=["Kafka queue tuning", "consumer rebalance config"],
)
result = search_memories(
"Kafka consumer rebalance",
palace,
n_results=5,
source_file="fixture_D4.md",
)
ids = [h["source_file"] for h in result["results"]]
assert "fixture_D1.md" not in ids
assert set(ids) <= {"fixture_D4.md"}
def test_hybrid_rank_breaks_score_ties_by_authored_at():
"""Identical-content hits get identical vector + BM25 scores; the tie must break
toward the more recently authored drawer, not arbitrary backend order."""
older = {
"text": "alpha beta gamma",
"distance": 0.2,
"metadata": {"authored_at": "2026-06-21T10:00:00.000Z"},
}
newer = {
"text": "alpha beta gamma",
"distance": 0.2,
"metadata": {"authored_at": "2026-06-27T10:00:00.000Z"},
}
# Input order puts the older drawer first; the tiebreak should reorder it.
results = [older, newer]
_hybrid_rank(results, "alpha beta gamma")
assert results[0]["metadata"]["authored_at"] == "2026-06-27T10:00:00.000Z"
assert results[1]["metadata"]["authored_at"] == "2026-06-21T10:00:00.000Z"
def test_hybrid_rank_tiebreak_handles_top_level_authored_at():
"""The search_memories path puts authored_at at the top level (no `metadata`
nesting); the tie-break must read it there too."""
older = {"text": "alpha beta gamma", "distance": 0.2, "authored_at": "2026-06-21T10:00:00.000Z"}
newer = {"text": "alpha beta gamma", "distance": 0.2, "authored_at": "2026-06-27T10:00:00.000Z"}
results = [older, newer]
_hybrid_rank(results, "alpha beta gamma")
assert results[0]["authored_at"] == "2026-06-27T10:00:00.000Z"
assert results[1]["authored_at"] == "2026-06-21T10:00:00.000Z"

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