397 Commits
Author SHA1 Message Date
Milla J d0163a7bec Merge pull request #1434 from MemPalace/chore/release-3.3.5-prep 2026-05-10 16:25:38 -07:00
Igor Lins e Silva a5ec32561c docs(changelog): correct KG date validator entry for 3.3.5
Copilot review on PR #1434 caught that the existing 3.3.5 entry
described the validator as it was authored under #1167 — accepting
``YYYY``/``YYYY-MM``/``YYYY-MM-DD`` and rejecting ISO datetimes — but
PR #1417 (closes #1374) merged into develop on 2026-05-10 and
inverted that: ``sanitize_iso_temporal()`` now rejects partial dates
and accepts canonical UTC datetimes (``YYYY-MM-DDTHH:MM:SSZ`` /
``+00:00``). ``sanitize_iso_date()`` is kept as a backwards-compat
wrapper.

Update the bullet to describe the *shipped* behavior, name both
functions, list both accepted and rejected forms, and call out the
3.3.4 → 3.3.5 behavior change for partial-date inputs that now error.
Reference both #1167 (original) and #1374/#1417 (the expansion).
2026-05-10 01:35:21 -03:00
Igor Lins e Silva 40524d5b04 Merge develop into main for v3.3.5 release
# Conflicts:
#	.claude-plugin/marketplace.json
#	.claude-plugin/plugin.json
#	.codex-plugin/plugin.json
#	CHANGELOG.md
#	README.md
#	mempalace/version.py
#	pyproject.toml
#	uv.lock
2026-05-10 01:05:36 -03:00
Igor Lins e Silva 1247e175b5 Merge pull request #1432 from MemPalace/chore/release-3.3.5
chore(release): 3.3.5
2026-05-09 22:54:32 -03:00
Igor Lins e Silva 6e9d057a42 docs(changelog): backfill 3.3.4 release date
The 3.3.4 release shipped 2026-05-01 (per GitHub release v3.3.4) but
its CHANGELOG header was never flipped from ``unreleased`` to the
release date. Backfill while we're already touching CHANGELOG for
the 3.3.5 cut.
2026-05-09 21:30:31 -03:00
Igor Lins e Silva 36100f820e Merge pull request #1417 from fatkobra/fix/1374-kg-iso-datetimes
fix(kg): accept ISO datetimes for temporal inputs
2026-05-09 21:27:37 -03:00
Igor Lins e Silva b2bab382f3 Merge remote-tracking branch 'origin/develop' into pr-1417-fix-kg-datetimes
# Conflicts:
#	tests/test_palace_locks.py
2026-05-09 21:12:40 -03:00
Igor Lins e Silva fa9b7e0525 chore(release): 3.3.5
Bumps version 3.3.4 → 3.3.5 across pyproject.toml, version.py, plugin
manifests, README badge, and uv.lock. Flips CHANGELOG.md from
``[3.3.5] — unreleased`` to ``[3.3.5] — 2026-05-09`` and adds entries
for the four PRs that landed after the bug-fix block was authored:

- Bug Fixes: #1396 (tool_search retry on transient HNSW flush)
- Documentation: #1385 (CONTRIBUTING git-identity guidance, closes #1317)
- Internal: #1431 (test multiprocessing fork → spawn)
- Internal: #1430 (test sqlite connection lifecycle via contextlib.closing)

The four open issues remaining on the v3.3.5 milestone (#1266, #1253,
#1092, #1082) have been moved to v3.4 — they form the concurrent-writer
/ HNSW corruption cluster that needs deeper work than this cycle could
absorb.
2026-05-09 21:11:13 -03:00
Igor Lins e Silva b9cec32528 Merge pull request #1430 from MemPalace/chore/test-sqlite-cleanup
chore(tests): wrap sqlite3 connections in contextlib.closing
2026-05-09 20:58:42 -03:00
Igor Lins e Silva df5ca114b2 chore(tests): wrap sqlite3 connections in contextlib.closing
Several tests opened sqlite3 connections without try/finally or
context-manager cleanup, relying on a flat conn.close() after the
work. Any assertion failure or exception between connect and close
leaked the connection until GC, producing
``ResourceWarning: unclosed database`` in CI logs.

On Python 3.13 / macOS the ResourceWarning isn't just noise: a
leaked connection can hold a SQLite advisory lock long enough for
later test setup to block on it, which appears to be the cause of
recent intermittent CI hangs on those two runners.

Wrap each affected ``conn = sqlite3.connect(...)`` block in
``contextlib.closing(...)`` so cleanup runs on the failure path too.
Mirrors the try/finally pattern already used in production code
(searcher.py, repair.py, backends/chroma.py).

No behavior change — same operations, same assertions, just
deterministic cleanup. All 162 affected tests pass locally.
2026-05-09 20:25:47 -03:00
Igor Lins e Silva 7d5e6c4e85 Merge pull request #1431 from MemPalace/fix/test-multiprocessing-spawn
fix(tests): use spawn instead of fork for lock-test subprocesses
2026-05-09 20:25:26 -03:00
Igor Lins e Silva 11d0a64202 fix(tests): use spawn instead of fork for lock-test subprocesses
test_palace_locks.py and test_chroma_collection_lock.py spawned child
processes with the ``fork`` start method on POSIX. Under Python 3.13
this deadlocks reliably enough to hang the Linux 3.13 and macOS CI jobs
indefinitely while Linux 3.9 / 3.11 / Windows complete normally.

Root cause: by the time these tests run, the pytest parent process is
multi-threaded — chromadb and onnxruntime both spawn background threads
on import. ``fork`` snapshots the parent's address space into the
child without those threads, so any lock another thread held at fork
time stays locked in the child forever. Python 3.13 widened the window
where Python's own internal threads can be holding locks (hence the new
DeprecationWarning that fired ten times in our local 3.13 run).

macOS hits a related but distinct issue: Apple's CoreFoundation
explicitly forbids fork-without-exec; once anything in the parent has
loaded a CF-using library (ONNX, anything via Objective-C bridges) a
forked child will silently hang the moment it touches the same
library.

Switching to ``spawn`` re-imports modules in the child (~0.5s overhead
per Process — measurable but bounded), which is the standard fix for
both classes of bug. Lock-file semantics are unchanged: ``spawn``
inherits ``os.environ`` (including monkeypatched ``HOME``), which is
all these tests need from the parent.

Locally on Python 3.13: all 14 lock tests pass in 6.58s.
2026-05-09 20:11:08 -03:00
Igor Lins e Silva d9e60d84ad Merge pull request #1396 from JPdeB61/fix/1315-mcp-search-retry-on-hnsw-flush
fix(mcp): retry tool_search once on Chroma "Error finding   id" transient (#1315)
2026-05-09 19:32:20 -03:00
Igor Lins e Silva b2ce45d301 style: ruff format tests/test_mcp_server.py for ruff <0.5 2026-05-09 18:09:51 -03:00
fatkobra 03f9a73d44 fix(tests): format palace lock test 2026-05-09 12:16:26 +02:00
fatkobra 710fa529c0 style: format test_palace_locks as part of fix(tests): isolate palace lock subprocess state 2026-05-09 11:45:51 +02:00
fatkobra 00041ca736 Update test_palace_locks.py as part of fix(tests): isolate palace lock subprocess state 2026-05-09 11:30:45 +02:00
fatkobra f34b78c8b1 Update conftest.py as part of fix(tests): isolate palace lock subprocess state 2026-05-09 11:20:33 +02:00
Igor Lins e Silva 2fc47a52fc Merge pull request #1004 from coogie/coogie/fix/miner-routing
fix(miner): use token-boundary matching in detect_room
2026-05-09 01:36:51 -03:00
Igor Lins e Silva 1c26ecef0a Merge pull request #1385 from sjhddh/docs/contributor-git-identity-1317
docs: clarify contributor git identity setup
2026-05-09 01:31:05 -03:00
Igor Lins e Silva 5453ad701a Merge pull request #1421 from mvalentsev/feat/sync-gitignore-prune
feat(sync): add gitignore-aware drawer prune (#1252)
2026-05-09 00:12:29 -03:00
mvalentsev 1822756afe fix(test_sync): use tmp_dir for elsewhere path so it stays absolute on Windows
Windows runs treated `/tmp/elsewhere/x.md` as relative because Windows
absolute paths require a drive letter, so `_classify_drawer` routed
`drawer_out_of_scope` to `no_source` instead of `out_of_scope` and
`test_dry_run_classifies_correctly` failed on test-windows.

`Path(tmp_dir) / "elsewhere" / "x.md"` is absolute on every platform
and still lives outside the project root that the synced_world fixture
exposes via `repo_path`, so the bucket assertions hold cross-platform.
2026-05-09 04:12:18 +05:00
mvalentsev 18f877869b style(test_sync): match CI ruff 0.4.x format 2026-05-09 04:04:56 +05:00
mvalentsev 0ff4121404 fix(sync): symmetric source_file resolve + perf optimizations
CI fix: `_classify_drawer` now resolves `source_file` symmetric to
`project_roots` (which `_normalize_project_dirs` and
`_auto_detect_project_roots` already `.resolve()`). Without this, on
platforms where the temp directory is a symlink (macOS `/var/folders` ->
`/private/var/folders`, Windows 8.3 short-name normalization), every
drawer mis-bucketed as `out_of_scope` and survived prune.

Perf:
- `_resolve_project_root`: early-return on first match (sorted-desc
  precondition).
- `_normalize_project_dirs`: sort `(-len(str(p)), str(p))` desc for
  early-return + deterministic tie-break on equal-length paths.
- `_auto_detect_project_roots`: `seen_sources` dedupe so a 200-chunk
  file costs one disk walk, not 200.
- `sync_palace` main loop: per-file classification cache; registry
  sentinels (`_reg_*`, `room=_registry`, `ingest_mode=registry`) routed
  to "kept" before cache lookup so a sentinel sharing a `source_file`
  with a pruned drawer cannot inherit a stale "gitignored" verdict.
- Closet purge: collapse O(N) per-file purge into one
  `where={"source_file": {"$in": [...]}}` get + one bulk delete.

Tests (5 new in `TestSyncPalace`, 38 total):
- `test_symlinked_project_root_resolves`: pins symmetric resolve via
  real `os.symlink` (skipped on Windows).
- `test_classification_cache_avoids_redundant_disk_hits`: monkeypatch
  counter on `_classify_drawer` asserts `call_count == 1` for 5 chunks
  sharing one source_file.
- `test_closet_batch_purge_single_call`: wraps closets collection with
  `CallCountingCol` (forwards `.get`/`.delete`); asserts
  `delete_calls == 1` and `get_calls == 1`; expected `removed_closets`
  derived from `report["by_source"]` to stay robust to fixture changes.
- `test_registry_check_runs_before_cache_lookup`: a regular drawer
  caches "gitignored" first; a sentinel with the same source_file must
  still be kept.
- `test_normalize_project_dirs_sort_stable_on_equal_length`: pins the
  alphabetical secondary key when paths share length.
2026-05-09 04:01:34 +05:00
mvalentsev 1d3eecbf9d feat(sync): add gitignore-aware drawer prune (#1252)
Add `mempalace sync` CLI command and `mempalace_sync` MCP tool that
prune drawers whose source files are gitignored, deleted, or moved
out of the project. Reuses the existing GitignoreMatcher
infrastructure in mempalace/miner.py so the same gitignore rules
that block ingest also drive the corresponding cleanup.

Closes #1252.
2026-05-09 03:16:03 +05:00
Igor Lins e Silva 02dd6dc19e Merge pull request #1292 from MemPalace/dependabot/github_actions/actions/configure-pages-6
chore(deps): bump actions/configure-pages from 5 to 6
2026-05-08 16:15:41 -03:00
fatkobra 1fbcb739c9 fix(kg): tighten temporal handling and cleanup 2026-05-08 15:27:54 +00:00
fatkobra 29c0c8059e fix(kg): close sqlite connections during cleanup 2026-05-08 14:44:04 +00:00
fatkobra db69d1f1cc fix(kg): tighten canonical temporal datetime support 2026-05-08 14:21:19 +00:00
fatkobra 6694198c29 fix(kg): support Python 3.9 type annotations 2026-05-08 08:40:11 +00:00
fatkobra 4adc99f755 fix(kg): canonicalize temporal datetime support 2026-05-08 08:32:25 +00:00
fatkobra eebf48e975 fix(kg): accept ISO datetimes for temporal inputs 2026-05-08 06:29:36 +00:00
Igor Lins e Silva 018ded556b Merge pull request #1414 from MemPalace/chore/uv-and-3.3.4-housekeeping
chore: housekeeping — sync 3.3.4 version + recommend uv for installs
2026-05-08 03:12:10 -03:00
Igor Lins e Silva 424da244ef Merge pull request #1415 from MemPalace/fix/1212-per-target-pid-guard
fix(hooks): per-target PID guard with atomic claim (#1212, #1206)
2026-05-08 03:11:37 -03:00
Igor Lins e Silva d4c476b7d3 fix(hooks): use Optional[Path] for py39 compat
PEP 604 union syntax (Path | None) requires Python 3.10+. The project
still supports 3.9 (per pyproject target-version and CI matrix), and
this annotation lives in a function signature so it is evaluated at
module load time — failing with "unsupported operand type(s) for |"
on test-linux 3.9.

The other ``int | None`` annotation in this file is inside a function
body, where Python skips runtime evaluation of local annotations, so
it does not trip 3.9.
2026-05-08 02:16:07 -03:00
Igor Lins e Silva 3a76360301 fix(hooks): per-target PID guard with atomic claim (#1212, #1206)
The hook PID guard used a single global ``~/.mempalace/hook_state/mine.pid``
file, which failed two ways:

1. ``_mine_already_running`` read-then-spawn was a TOCTOU race. Two
   near-simultaneous Stop hook fires both passed the existence/liveness
   check before either wrote — so both ended up calling
   ``_spawn_mine``.

2. ``_spawn_mine`` unconditionally overwrote the global PID file with
   the new child's PID. The first PID was lost, orphaning the first
   child. The user-visible result in #1212 was two concurrent
   ``mempalace mine`` processes running against the same source, both
   driving HNSW inserts in parallel — exactly the corruption pattern
   the guard was meant to prevent. #1206 reported the same shape from
   the perspective of the user (two mines hung on a 350MB folder).

Replace the global file with per-target slots under
``~/.mempalace/hook_state/mine_pids/``, keyed by sha256 of the mine
sub-arguments (everything after ``mine``). The slot is claimed via
``O_CREAT | O_EXCL`` so the claim is atomic — two simultaneous fires
can never both pass.  Stale slots (PID exists but is dead) are
reclaimed transparently. Different targets (e.g. project mine vs
transcript ingest, or two different MEMPAL_DIRs) get independent
slots and run in parallel.

The mine subprocess receives its slot path via
``MEMPALACE_MINE_PID_FILE`` env var; ``miner._cleanup_mine_pid_file``
reads that var on exit and removes the slot if it points at our PID,
so orphaned slots from crashed mines don't accumulate.

Also routes ``_ingest_transcript`` through ``_spawn_mine`` so the
transcript ingest path now participates in the same dedup — repeated
Stop fires for the same transcript no longer stack parallel mines.

Closes #1212
Closes #1206
2026-05-08 02:09:00 -03:00
Igor Lins e Silva c08ec00f4d fix: address Copilot review on PATH visibility (PR #1414)
- mempalace/instructions/init.md: only skip Step 4 when `mempalace
  --version` succeeds. `pip show` / `uv tool list` reporting an install
  is not enough -- if the package lives in an unactivated venv, Step 5
  (`mempalace init ...`) fails with command-not-found. Treat that case
  as not-installed and re-install via Step 3 into a PATH-visible
  location.
- .codex-plugin/README.md: switch the git-install recipe from `uv sync`
  to `uv tool install --editable .` so the bundled `plugin.json`
  (which invokes `mempalace-mcp` by bare name) can launch the MCP
  server. Plain `uv sync` only puts the script in `.venv/bin/`, which
  Codex won't find unless the venv is activated first.
2026-05-08 02:01:36 -03:00
Igor Lins e Silva c70d5182dd Merge pull request #1413 from MemPalace/fix/1264-mine-lock-holder-diagnostics
fix(mine): identify lock holder + exit non-zero on contention (#1264)
2026-05-08 01:59:00 -03:00
Igor Lins e Silva bc7392aa29 Merge pull request #1412 from MemPalace/fix/1268-popen-detach-windows
fix(hooks): detach Popen children so hook exits cleanly on Windows (#1268)
2026-05-08 01:38:16 -03:00
Igor Lins e Silva c35686c9e1 docs(install): recommend uv as the package manager
End-user installs now lead with `uv tool install mempalace`, with
`pip install mempalace` kept as a fallback. Dev/contributor docs lead
with `uv sync --extra dev` and `uv run` for tests/benchmarks/lint, with
the equivalent pip recipe kept inline. The shipped `/mempalace:init`
skill instructions (mempalace/instructions/init.md) try `uv tool install`
first when uv is on PATH, then fall back through the pip variants.

Adds a .python-version pin at 3.12 because the lockfile's
onnxruntime==1.24.3 only ships wheels for Python >=3.11; without the
pin, `uv sync` on a host where uv prefers 3.10 fails with no source
distribution available, which would make the documented command a
footgun. pyproject's `requires-python = ">=3.9"` is unchanged — pip
users on 3.9/3.10 are unaffected.

Files updated: README.md, CONTRIBUTING.md, CLAUDE.md, the gemini-cli
guide and example, the .claude-plugin / .codex-plugin READMEs, the
mempalace SKILL, the openclaw SKILL, tools/save.md, the three
benchmarks docs, and the corresponding website mirrors.
2026-05-08 01:38:00 -03:00
Igor Lins e Silva 25bfd37644 chore(release): sync 3.3.4 version bump back into develop
The v3.3.4 release prep landed on main but was never merged back into
develop, leaving every version-bearing file one release behind. Bumps
pyproject.toml, mempalace/version.py, both plugin manifests, the
marketplace entry, the README badge, and the lockfile to 3.3.4 to match
the tagged release.
2026-05-08 01:38:00 -03:00
Igor Lins e Silva 11a35de5ac test(palace): set USERPROFILE too so the lock-path test works on Windows
os.path.expanduser("~") reads HOME on POSIX but USERPROFILE on Windows;
the lock-body bound test was monkeypatching HOME only, so on
test-windows the lock file landed in the runner's real ~/.mempalace
and the tmp_path glob found nothing.

Patch USERPROFILE in addition to HOME, and read the body as bytes so
the byte-0 sentinel doesn't trip a UTF-8 decode warning. Assertion
shifts from line-count to size-bound (still detects unbounded growth
across re-acquires).
2026-05-08 01:34:46 -03:00
Igor Lins e Silva d5ce97c7af fix(palace): reserve byte 0 as lock sentinel for Windows portability
Windows CI surfaced two bugs introduced by the holder-identity write:

1. msvcrt.locking(LK_NBLCK, 1) locks 1 byte at the *current* file
   position. Switching to "a+" mode put the position at end-of-file,
   so two contenders locked different bytes and silently both
   acquired (the test asserts saw [(ok, 1), (ok, 2)] instead of
   ok+busy).

2. With the byte-range lock active on Windows, the locked byte is
   read-blocked for other processes. A contender trying to read the
   holder identity from byte 0 would hit PermissionError.

Switch to "r+" mode (after touch-create) and explicitly seek(0) before
both lock and unlock. Then reserve byte 0 as a pure lock sentinel and
write the holder identity from byte 1 onward. _read_lock_holder reads
from byte 1+, so it never touches the locked byte.

Also bound file growth across re-acquires: truncate to
sentinel + len(ident) before writing so the file body stays the size
of the current holder, never accumulating across runs.

Linux fcntl.flock locks the whole file independent of byte position,
so the seek(0) is harmless on POSIX. The shape works on both.
2026-05-08 01:28:42 -03:00
Igor Lins e Silva ef8d83cc8a fix(mine): identify lock holder + exit non-zero on contention
When a `mempalace mine` collided with another writer (live mcp_server,
another mine, anything taking mine_palace_lock), the operator saw a
generic "another `mempalace mine` is already running" message and the
CLI exited 0 — making the contention invisible to nohup or scripts
checking $?. The reporter ran a `nohup mempalace mine ... & disown`
and got a 200-byte log with only the auto-defaults warning, no clue
that an MCP server was holding the store.

palace.py: the lock file now records the holder's PID + first three
argv tokens on acquire. A failed acquire reads the file and surfaces
"palace <path> is held by PID N (mempalace mcp_server); wait for it
to finish or stop the holder before retrying" in the
MineAlreadyRunning message. Open mode changes from "w" to "a+" so the
prior holder's identity survives long enough to be read.

miner.mine() now lets MineAlreadyRunning propagate. cmd_mine catches
it, prints the holder-aware message to stderr, and exits non-zero so
shell wrappers detect the contention.

Note: this is a behavior change for in-process callers that depended
on miner.mine() silently swallowing MineAlreadyRunning. The silent
swallow was the bug.

Closes #1264
2026-05-08 01:00:00 -03:00
Igor Lins e Silva 71804c0aa5 fix(hooks): detach Popen children so the hook can exit on Windows
The Stop hook spawns mining subprocesses via subprocess.Popen and then
returns. On Windows the parent stays blocked at session end because the
child inherits stdout/stderr handles and the OS waits for them to
release before the parent can exit — the user-visible symptom is the
"running stop hooks... 3/3" spinner hanging for minutes (#1268).

Add _detached_popen_kwargs() helper that returns the right detach knobs
per platform:
- POSIX: start_new_session=True, stdin=DEVNULL, close_fds=True
- Windows: creationflags=DETACHED_PROCESS|CREATE_NEW_PROCESS_GROUP|
  CREATE_BREAKAWAY_FROM_JOB, stdin=DEVNULL, close_fds=True

Apply to all three fire-and-forget Popen sites in hooks_cli:
_spawn_mine, _ingest_transcript, _desktop_toast. Leave _mine_sync's
subprocess.run alone — that path is intentionally synchronous (the
precompact hook must wait for the mine to finish).

Note: the issue body references mempalace-stop.js, which does not exist
in this repo (the plugin ships shell wrappers calling Python). The
mechanism described — child holds parent open via inherited handles —
is universal, so this fix targets the equivalent symptom in our Python
hook path. Will follow up on the upstream JS file with the reporter.
2026-05-08 00:55:11 -03:00
Igor Lins e Silva ea36a00f5f Merge pull request #1406 from MemPalace/fix/925-diary-content-hash
fix(diary): detect same-size edits via content hash (#925)
2026-05-07 17:53:24 -03:00
Stephen Coogan 3d0d037b87 docs(changelog): add 3.3.5 entry for detect_room substring fix (#1004) 2026-05-07 21:46:15 +01:00
Stephen Coogan ead2c5d299 fix(miner): use token-boundary matching in detect_room
Substring checks in path/filename routing caused systemic misrouting
in large monorepos — e.g., "views" ⊂ "interviews" sent every file
under views/ to the interviews room. Switch to separator-bounded
token matching (-, _, ., /) via a _name_matches helper, applied to
priority 1 (path parts) and priority 2 (filename).
2026-05-07 21:44:30 +01:00
Igor Lins e Silva 26bc3d4f91 test(diary): write fixture with explicit utf-8 to fix Windows hash assert
test_legacy_state_backfills_content_hash failed on test-windows because
Path.write_text without an encoding uses the system locale (cp1252 on
Windows). The em dash was written as 0x97, then read back by
diary_ingest as UTF-8 with errors=replace — round-trip produced
different bytes than the in-Python literal, so the assertion comparing
the persisted hash to sha256(text.encode(utf-8)) diverged.

Pin the write side to encoding=utf-8 so the on-disk bytes match what
diary_ingest decodes. No production change.
2026-05-07 17:41:19 -03:00
Igor Lins e Silva ba30ab6951 Merge pull request #1405 from MemPalace/fix/1156-exporter-reject-symlinks
fix(exporter): refuse symlinks at export targets (#1156)
2026-05-07 17:38:23 -03:00
Igor Lins e Silva 83d53644bb Merge pull request #1404 from MemPalace/fix/1155-call-llm-retry-on-json-decode
fix(closet_llm): retry _call_llm on JSONDecodeError (#1155)
2026-05-07 17:38:04 -03:00
Igor Lins e Silva 2ff6283b32 fix(diary): rebuild closets on hash change + backfill legacy state
Address Copilot review on #925:

- Full closet rebuild whenever the content hash differs from prior
  state, not only on entry-count growth. Without this, an in-place
  edit (same entry count, different body) updated the drawer but
  left the closet/search index stale — defeats the verbatim guarantee
  at the search layer even if the drawer is correct.
- Legacy size-only skip path now records the computed content_hash
  back into state so subsequent runs use the strict hash check
  instead of remaining on the size-only path indefinitely.
- Test updates: typo direction in the regression test now matches the
  comment (typo "Teh" → fix "The"), assertion now also checks the
  closet collection reflects the edit, and a new test exercises the
  legacy-state backfill path.
2026-05-07 12:54:09 -03:00
Igor Lins e Silva 75452380a8 fix(exporter): refuse symlinks at file targets and skip tests on Windows
Address Copilot review on #1156:

- Per-file symlink check via new _safe_open_for_write() helper. Uses
  O_NOFOLLOW on POSIX (close TOCTOU window between islink check and
  open) and falls back to islink + open on Windows. Applied to room
  files and index.md, mirroring the existing dir-level check.
- Tests now wrap os.symlink() in _try_symlink_or_skip() so Windows
  without Developer Mode and restricted CI sandboxes skip rather than
  hard-fail. Added two regression tests for the file-level cases
  (room file, index.md).
2026-05-07 12:51:47 -03:00
Igor Lins e Silva 8e21b5abd4 test(closet_llm): use _ for unused return values per copilot review 2026-05-07 12:49:27 -03:00
Igor Lins e Silva 0d1c1fbcaa fix(diary): detect same-size edits via content hash
The skip-if-unchanged check compared byte length only, so any in-place
edit preserving total length (typo fix "teh"→"the", word swap) was
silently dropped — a verbatim-storage violation: the user's actual
words never reached the palace.

Switch the gate to sha256(text). State entries gain a "content_hash"
field; the legacy size-only path is preserved when prev_hash is missing
so a post-upgrade run does not re-ingest every untouched diary.

Closes #925
2026-05-07 12:42:02 -03:00
Igor Lins e Silva 40e2c8b056 fix(exporter): refuse symlinks at export targets
A symlink pre-placed at the export output_dir or any wing subdirectory
would redirect markdown writes to wherever the symlink points. The
miner already rejects symlinked inputs via Path.is_symlink(); the
exporter should apply the same caution to outputs.

Add _reject_symlink() helper and call it before makedirs on both
output_dir and each wing_dir. Refusal raises ValueError with a clear
message rather than silently falling through.

Closes #1156
2026-05-07 12:40:26 -03:00
Igor Lins e Silva 2a0ed0cb8f fix(closet_llm): retry _call_llm on JSONDecodeError instead of bailing
The retry loop already backs off on HTTP 429/503 and rate-limit-shaped
exceptions, but JSONDecodeError exited on the first failure. Local LLM
runtimes occasionally produce malformed JSON (truncated streams, partial
chunks under load), and the retry was effectively dead for that path.

Mirror the 429/503 branch: sleep with exponential backoff and continue
through all 3 attempts, only returning None after the final failure.

Closes #1155
2026-05-07 12:38:39 -03:00
Igor Lins e Silva 03ed4c45cf Merge pull request #1403 from MemPalace/fix/sqlite-preflight-order
fix(repair): run SQLite integrity preflight before chromadb open (follow-up to #1364)
2026-05-07 12:15:18 -03:00
Igor Lins e Silva 7b151039c9 test(repair): page-align corruption offset in preflight regression test
Address Copilot review on #1403: the test seeked unconditionally to
offset 40960 with only `pre_size > 16384` as a guard. If pre_size sat
between 16384 and 40960 + 16384 = 57344 (e.g., on a chromadb version
that allocated fewer pages on init, or a future schema change), the
seek would extend the file with zero-padding and the original pages
would stay intact — quick_check would still pass on the (untouched)
real data, and the regression guard would silently skip detecting a
preflight-ordering regression.

Compute the offset from pre_size, page-aligned, with explicit asserts
that the file is large enough to mangle 4 pages without truncating
the header or extending past EOF.
2026-05-07 12:07:54 -03:00
Igor Lins e Silva 5134a635ed fix(repair): run SQLite integrity preflight before chromadb open
#1364 added the SQLite quick_check preflight to rebuild_index, but
placed it AFTER backend.get_collection(...). On a SQLite-corrupt
palace, chromadb's rust binding raises pyo3_runtime.PanicException —
which is not a regular Exception subclass — so it propagates past the
existing `except Exception` handlers and the user sees a 30-line stack
trace instead of the friendly abort message #1364 was designed to
deliver. Reproduced with `mempalace repair --yes` against a palace
whose chroma.sqlite3 has 4 mangled pages: pre-fix, panic; post-fix,
the clean abort message and exit code 1.

Two changes:

- mempalace/cli.py cmd_repair: run sqlite_integrity_errors() right
  after the basic palace-existence check, BEFORE the max_seq_id
  preflight (which itself opens sqlite3) and BEFORE backend =
  ChromaBackend(). Exit non-zero so unattended scripts and CI gates
  see the failure.

- mempalace/repair.py rebuild_index: same move at the function level
  for direct callers (tests, MCP) that bypass cmd_repair.

The new test test_rebuild_index_runs_sqlite_preflight_before_chromadb_open
uses a real chromadb-built palace (no ChromaBackend mock) plus a
real corrupt SQLite (16 KB of mangled pages) so the ordering is
exercised end-to-end. The previously-shipping test for the abort path
mocked both the backend and sqlite_integrity_errors, which is why the
ordering bug shipped CI-green.

Six existing test_cli.py cmd_repair tests used `(palace_dir /
"chroma.sqlite3").write_text("db")` to fake the SQLite file. The new
preflight correctly fails quick_check on those 2-byte stubs, so the
tests now create empty real SQLite DBs the same way the test_repair.py
fixtures already do.
2026-05-07 11:52:58 -03:00
Igor Lins e Silva f38d9eb109 Merge pull request #1364 from fatkobra/fix/1362-repair-sqlite-integrity-preflight
fix(repair): preflight SQLite integrity before rebuild
2026-05-07 11:07:52 -03:00
Igor Lins e Silva aecd543d75 merge: develop into fix/1362-repair-sqlite-integrity-preflight (round 2)
#1357 (max_seq_id preflight) merged into develop while this branch
was in CI, opening a fresh conflict between the two preflight helpers.

mempalace/repair.py:
- Kept both: this branch's sqlite_integrity_errors() / print_sqlite_
  integrity_abort() AND develop's maybe_repair_poisoned_max_seq_id_
  before_rebuild() from #1357. They check for distinct corruption
  classes and run as separate preflights.

tests/test_repair.py:
- Kept both this branch's sqlite_integrity_errors test group and
  develop's max_seq_id preflight test group; non-overlapping coverage.

Local: 1623 tests pass, ruff lint+format clean against 0.4.x CI pin.
2026-05-07 10:45:29 -03:00
Igor Lins e Silva 3893228e03 Merge pull request #1342 from fatkobra/fix/1274-missing-hnsw-metadata-gate
fix(storage): quarantine partial HNSW flush without metadata (#1274)
2026-05-07 10:42:20 -03:00
Igor Lins e Silva 557b9b1908 Merge pull request #1357 from fatkobra/fix/1295-repair-max-seq-id-preflight
fix(repair): preflight poisoned max_seq_id before rebuild (#1295)
2026-05-07 10:42:09 -03:00
Igor Lins e Silva f2291b0320 merge: develop into fix/1362-repair-sqlite-integrity-preflight
Conflicts opened by #1285 (temp-staging rebuild) and #1312
(collection_name in recovery paths) merging after this branch was
authored.

mempalace/repair.py:
- Kept this branch's sqlite_integrity_errors() and
  print_sqlite_integrity_abort() helpers; took develop's rebuild_index
  signature with the collection_name parameter from #1312. Normalized
  the helper's print indent to 2 spaces to match the rest of the file.

tests/test_repair.py:
- Kept both this branch's sqlite_integrity_errors tests and develop's
  rebuild_from_sqlite + configured-collection coverage.
- Replaced 7 sites of sqlite_path.write_text("fake") with
  sqlite3.connect(...).close() — write_text("fake") fails PRAGMA
  quick_check, so the new preflight aborts before the rebuild logic
  the tests actually exercise. An empty real SQLite DB passes
  quick_check and lets the tests run as intended.
- Took develop's temp-staging assertion shape (delete/create the
  __repair_tmp collection in addition to the live drawers collection)
  for the existing test_rebuild_index_success test.

Local: 1618 tests pass, ruff lint+format clean against 0.4.x CI pin.
2026-05-07 10:41:27 -03:00
Igor Lins e Silva a1e19081d9 merge: develop into fix/1274-missing-hnsw-metadata-gate
Two conflicts, both because #1339 (bloated link payloads) merged into
develop after this branch was authored:

- mempalace/backends/chroma.py: _segment_appears_healthy now stacks
  three checks — bloated-link from #1339 (top), missing-metadata-with-
  data-floor from this branch (middle), pickle format sniff (bottom).
  All three are complementary; #1339 catches structural payload
  corruption, this branch catches pickle truncation, the original
  catches pickle protocol-byte corruption.

- tests/test_backends.py: kept both new imports (_segment_appears_healthy
  from this branch, quarantine_invalid_hnsw_metadata from #1285).

Local: 1618 tests pass, ruff lint+format clean against 0.4.x CI pin.
2026-05-07 10:35:19 -03:00
Igor Lins e Silva bdaac9d9a6 merge: develop into fix/1295-repair-max-seq-id-preflight
Three conflicts, all from develop landing #1285/#1310/#1312 after this
branch was authored:

- mempalace/cli.py: keep both import sets — this branch's
  maybe_repair_poisoned_max_seq_id_before_rebuild plus develop's
  RebuildCollectionError / _close_chroma_handles / _extract_drawers /
  _rebuild_collection_via_temp added in #1285.

- mempalace/repair.py: keep this branch's
  maybe_repair_poisoned_max_seq_id_before_rebuild definition; use
  develop's rebuild_index signature with the collection_name parameter
  added in #1312. Normalized print indent to 2 spaces matching the
  rest of the file.

- tests/test_repair.py: keep both this branch's max_seq_id preflight
  tests and develop's rebuild_from_sqlite + configured-collection-name
  tests; they exercise distinct code paths and don't overlap.

Local: 1617 tests pass, ruff lint+format clean against 0.4.x CI pin.
2026-05-07 10:32:29 -03:00
Igor Lins e Silva 88a2ebb85a Merge pull request #1339 from fatkobra/fix/1218-hnsw-link-payload-health
fix(storage): quarantine bloated HNSW link payloads (#1218)
2026-05-07 10:19:47 -03:00
Igor Lins e Silva e272ed3fbf Merge pull request #1359 from fatkobra/fix/1099-migrate-write-roundtrip
fix(migrate): verify write roundtrip before bailout (#1099)
2026-05-07 09:59:58 -03:00
Igor Lins e Silva 72685f3aef Merge pull request #1312 from mjc/stale-chroma-reconnect
fix: use configured collection in recovery paths
2026-05-07 09:40:29 -03:00
Igor Lins e Silva 52c70c9f88 Merge pull request #1402 from MemPalace/fix/1296-windows-mine-resilience
fix(miner): harden Windows mine against ONNX bad_alloc + silent partial exits (#1296)
2026-05-07 09:36:22 -03:00
Igor Lins e Silva e9aee19433 fix(tests): apply ruff format after rebase resolution
The collection_name plumbing rebase produced a few unformatted blocks
in test_mcp_server.py and test_searcher.py; bringing them in line with
the 0.4.x CI pin so test-windows / lint stay green.
2026-05-07 09:10:22 -03:00
Mika Cohen ec6d2dde01 fix: use configured collection in recovery paths 2026-05-07 09:10:00 -03:00
Igor Lins e Silva 5488e7bb22 fix(miner): harden Windows mine against ONNX bad_alloc + silent partial exits
Three small changes that together address the failure modes in #1296:

1. Add pnpm-lock.yaml and yarn.lock to SKIP_FILENAMES, mirroring the
   existing package-lock.json rule. A 24K-line pnpm-lock.yaml produced
   ~1124 chunks in one batch and tripped onnxruntime bad_alloc on
   Windows; pnpm/yarn lockfiles are no more useful to mine than npm's.

2. Skip any file that produces more than MAX_CHUNKS_PER_FILE (500)
   chunks, with a clear log line. Catches the broader class — generated
   CSV/JSON, build artifacts, etc. — that the named-file SKIP list will
   never fully cover. The cap is conservative (500 chunks * 800 chars ≈
   400 KB of source) so legitimate hand-written content still mines.

3. Print a partial-progress summary on any exception in _mine_impl, not
   just KeyboardInterrupt, then re-raise. Without this, an arbitrary
   exception (ONNX bad_alloc, chromadb HNSW error, OS fault) propagates
   silently — the operator sees only the last progress line and assumes
   the mine succeeded. The new path mirrors the KeyboardInterrupt
   summary (files_processed, drawers_filed, last_file) plus the
   exception type and message, then re-raises so the original traceback
   surfaces and the exit code is non-zero.

Tests cover: SKIP_FILENAMES contents, the chunk-cap path returning
(0, room) with no upserts, and the new mine-aborted summary surfacing
both the partial counters and the exception class.
2026-05-07 08:56:41 -03:00
Igor Lins e Silva 88493acd0d Merge pull request #1285 from mjc/hnsw-repair
fix: harden Chroma repair preflight and rollback recovery
2026-05-07 08:06:59 -03:00
Igor Lins e Silva 7cf9b17582 fix(repair): quote ChromaBackend annotation for Python 3.9 compatibility
`backend: ChromaBackend | None = None` evaluates the X | None union
eagerly at function-definition time, which Python 3.9 rejects with
TypeError: unsupported operand type(s) for |: 'ABCMeta' and 'NoneType'
since the new union syntax is 3.10+. Quoting matches the existing
forward-reference style in repair.py (sqlite_drawer_count, etc.) and
defers evaluation, restoring 3.9 compatibility.
2026-05-07 07:53:28 -03:00
Igor Lins e Silva be05a2e179 Merge pull request #1310 from potterdigital/fix/1308-rebuild-from-sqlite
fix(repair): add --mode from-sqlite to recover palaces with corrupt HNSW (#1308)
2026-05-07 07:51:42 -03:00
Igor Lins e Silva be6dc033fd merge: develop into hnsw-repair (resolve chroma.py + test_backends.py conflicts)
Develop (post-#1162 lock-plumbing era) refactored the per-open quarantine
pass into ChromaBackend._prepare_palace_for_open. This branch's
inline-expansion form added quarantine_invalid_hnsw_metadata as a third
check, plus a "discard from _quarantined_paths on inode swap" guard so
re-opens against a different physical DB re-run quarantine.

Resolution merges both:

- _prepare_palace_for_open now also calls quarantine_invalid_hnsw_metadata,
  gated by the same _quarantined_paths set.
- _client keeps the inode_changed -> _quarantined_paths.discard() guard
  before calling the helper, so a fresh inode triggers a fresh pass.
- make_client collapses to a single _prepare_palace_for_open() call.
- test_backends.py keeps both the pickle (#1285) and shutil (develop)
  imports — both are used.
2026-05-07 07:48:45 -03:00
Igor Lins e Silva 670aba974f test(repair): close ChromaBackend in _seed_palace to release Windows file locks
The helper opened a chromadb PersistentClient via ChromaBackend and never
closed it, leaving rust-side SQLite/HNSW file locks alive after the
helper returned. On Windows that blocks the in-place archive rename
inside rebuild_from_sqlite with WinError 32 on data_level0.bin,
causing test_rebuild_from_sqlite_in_place_archives_when_opted_in and
test_rebuild_from_sqlite_raises_on_upsert_failure to fail in the
test-windows CI job. No test consumes the returned collection, so
closing the backend in a try/finally is safe and drops the return.
2026-05-07 07:37:25 -03:00
Igor Lins e Silva 8d8f54a807 Merge remote-tracking branch 'origin/develop' into fix/1308-rebuild-from-sqlite 2026-05-07 07:30:56 -03:00
JP de Boer knt be95ea7f33 fix(mcp): retry tool_search once on Chroma "Error finding id" transient (#1315)
After a bulk CLI mine, ChromaDB's HNSW segment metadata can be unflushed
  for ~30-60s. Wing-scoped MCP search hits "Internal error: Error finding id"
  during that window, and the existing inode/mtime cache invalidation isn't
  enough — tool_search routes via search_memories -> palace.get_collection
  -> _DEFAULT_BACKEND._client, which has its own per-palace cache.

  This wraps tool_search with a single retry that drops both the MCP-local
  cache and _DEFAULT_BACKEND._clients/_freshness for the palace, sleeps 2s,
  retries once, and tags successful retries with index_recovered=True.

  Does not address tool_check_duplicate or other index-touching tools, nor
  the underlying flush window — options 1-3 from #1315 (auto-flush after
  mine, fail-fast detection, SQLite-only fallback) are still on the table
  for a complete fix.

  Refs #1315
2026-05-07 02:11:07 +02:00
Igor Lins e Silva 435f0ad348 Merge pull request #1391 from MemPalace/docs/auto-save-tools-on-develop
docs: add 30-day expiry callout + ship 4 auto-save tools
2026-05-06 20:18:44 -03:00
MillaJ 7c679ba625 fix(tools/render_jsonl): apply ruff format
Earlier commit fixed ruff lint but missed the formatter check.
This applies `ruff format` — adds standard PEP8 blank lines between
functions, splits one inline list. No behavior change.

Verified: both `ruff format --check` and `ruff check` pass cleanly.
Tool still renders correctly.
2026-05-06 16:12:34 -07:00
MillaJ 921ff5a6fa fix(tools/render_jsonl): split chained statements per ruff 0.4.x
Addresses CI lint feedback on PR #1391. No behavior change.
- Split `import json, sys` into separate lines (E401)
- Split chained `print(...); sys.exit(1)` into two lines (E702, two occurrences)
- Split inline `if ts: stamps.append(ts)` into two lines (E701)

Verified: `ruff check tools/render_jsonl.py` reports "All checks passed!"
Tool still renders correctly (3 turns from a real JSONL test, identical output to pre-fix).
2026-05-06 15:39:08 -07:00
MillaJ bddba59ae3 docs: add 30-day expiry callout + ship 4 auto-save tools
Adds a brief [!IMPORTANT] callout at the top of the README pointing
users to the urgent announcement at #1388. Claude Code auto-deletes
local JSONL transcripts after 30 days; users without the auto-save
hooks wired are losing transcript data off the rolling window.

Ships 4 small standalone tools at tools/:
- backup_claude_jsonls.sh — rsync ~/.claude/projects/ to a safe folder
- render_jsonl.py — convert JSONL transcripts to readable text
- find_orphan_claude_jsonls.sh — scan backup locations for orphan
  Claude Code transcripts (multi-line shape detection + topic preview)
- save.md — Claude Code slash command for manual /save into MemPalace

Tools verified by independent agent against v3.3.4 source.
Read-only on user data. POSIX bash + Python stdlib only.
2026-05-06 13:10:16 -07:00
sjhddhandClaude Opus 4.7 e28ac94603 docs: clarify contributor git identity setup
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 13:22:11 +00:00
Igor Lins e Silva f0d236019a Merge pull request #1377 from MemPalace/fix/get-collection-retry-on-exception
fix(mcp): retry _get_collection once on transient failure (#1286)
2026-05-06 05:18:04 -03:00
Igor Lins e SilvaandJeffrey Hein e334e257bf fix(mcp): retry _get_collection once on transient failure (#1286)
A transient chromadb exception inside `_get_collection` was swallowed by
the bare `except Exception: return None`, leaving every subsequent tool
call hitting the same poisoned cache silently. The fix wraps the body
in a `for attempt in range(2)` loop: on attempt 0 failure, log via
`logger.exception(...)` and clear `_client_cache` / `_collection_cache`
/ `_metadata_cache` so the next iteration forces `_get_client()` to
rebuild from scratch — that path now re-runs `quarantine_stale_hnsw`
(per #1322), so the second attempt heals the common stale-handle case
automatically. If both attempts fail, return `None` (matches the prior
contract for permanent failures).

Two new tests in `tests/test_mcp_server.py::TestCacheInvalidation`:
- `test_get_collection_retries_once_on_exception` — first attempt raises
  via a monkeypatched `_get_client`, second attempt succeeds; assert the
  caller gets the collection back, not None.
- `test_get_collection_returns_none_after_two_failures` — both attempts
  fail, assert we exhaust the loop and return None (no infinite retry).

Surgical extraction from PR #1286, which carried the same fix idea
(plus a fork-sync bundle that couldn't be merged); credit to the
original author below.

Co-authored-by: Jeffrey Hein <jp@jphein.com>
2026-05-06 04:52:18 -03:00
Brian potterandClaude Opus 4.7 d92c741084 fix(repair): address PR #1310 review feedback
Five small hardening fixes for the from-sqlite rebuild path, all from
mjc's review on #1310:

- repair.py: drawers collection name now resolves from
  MempalaceConfig().collection_name via _drawers_collection_name() (closets
  stays fixed by design — AAAK index references drawer IDs by string).
  Lines up with the broader configured-collection work in #1312 so that
  PR can rebase cleanly on top.
- repair.py: create_collection() moved inside the try block in
  _rebuild_one_collection so a Chroma "Collection already exists" failure
  surfaces as RebuildPartialError with archive_path, not an unstructured
  exception that strands the user without recovery instructions.
- repair.py: rebuild_from_sqlite wraps backend lifetime in try/finally
  with backend.close() so PersistentClient handles to dest_palace are
  released on every exit path. The from-sqlite path post-dates #1285's
  lifecycle hardening of the legacy rebuild, so this needed its own
  cleanup.
- cli.py: cmd_repair (from-sqlite mode) now exits non-zero when
  rebuild_from_sqlite returns {} (validation refusal sentinel), so
  unattended scripts/CI distinguish "invalid inputs" from a successful
  rebuild that legitimately found zero rows.
- tests/test_repair.py: test_extract_via_sqlite_returns_all_rows_with_metadata
  now asserts every backing segment is scope='METADATA', locking in the
  segment-layout assumption against future regressions that point the
  JOIN at the VECTOR segment.

New test coverage:
- test_rebuild_from_sqlite_honors_configured_drawer_collection_name
- test_cmd_repair_from_sqlite_validation_refusal_exits_nonzero
- test_cmd_repair_from_sqlite_success_does_not_exit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:37:22 -03:00
Brian potterandClaude Opus 4.7 cb6bfd5231 chore: gitignore .envrc for direnv users
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:36:39 -03:00
Brian potterandClaude Opus 4.7 a7c4ed24d7 fix(repair): add --mode from-sqlite to recover palaces with corrupt HNSW (#1308)
Both `--mode legacy` and the inline `cli.cmd_repair` rebuild path
call `Collection.count()` as their first read — the same call that
raises `chromadb.errors.InternalError: Failed to apply logs to the
hnsw segment writer` on the corruption class reported in #1308.
Repair would print "Cannot recover — palace may need to be re-mined
from source files" even though the underlying SQLite tables were
fully intact.

The new `--mode from-sqlite` reads `(id, document, metadata)` rows
directly from `chroma.sqlite3` via `segments` → `embeddings` →
`embedding_metadata` joins, never opens a chromadb client against
the corrupt palace, and re-upserts everything into a fresh palace.

  - `--source PATH` extracts from a corrupt palace already moved aside
  - `--archive-existing` handles the in-place case by renaming the
    existing palace to `<palace>.pre-rebuild-<timestamp>` first
  - Partial-rebuild failures raise `RebuildPartialError` with the
    archive path so users can recover; CLI exits non-zero
  - In-place mode calls `SharedSystemClient.clear_system_cache()` to
    drop chromadb's process-wide System registry (cross-palace use
    does not, to limit blast radius for library callers)
  - Source validation runs before any destructive moves

Verified end-to-end recovering a 52,300-row real-world corrupt
palace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:36:39 -03:00
Igor Lins e Silva 6741b6908e Merge pull request #1138 from anthonyonazure/fix/bugbear-cleanup-and-endpoint-scheme
fix: reject non-http(s) LLM endpoints + clear ruff bugbear/silent-except findings
2026-05-06 04:28:12 -03:00
Anthony ClendenenandClaude Opus 4.7 ca5899e361 refactor: fix ruff bugbear and silent-except findings
- B904: chain OSError/collection errors with "raise ... from e" in
  normalize.py and searcher.py so the original traceback is preserved.
- B007: rename unused loop variables to _name in dedup, dialect, layers,
  and room_detector_local.
- S110/S112: replace bare "try/except/pass" and "try/except/continue"
  with logger.debug(..., exc_info=True) in mcp_server, searcher,
  palace, palace_graph, miner, convo_miner, and fact_checker so
  background failures are observable without changing behaviour.

A module-level logger ("mempalace_mcp", matching mcp_server/searcher)
is added to the five files that didn't already have one. Configured
ruff checks (E/F/W/C901) and ruff --select B, S110, S112 all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:12:09 -03:00
Anthony ClendenenandClaude Opus 4.7 b68485dfd4 fix(closet_llm): reject non-http(s) endpoints
LLMConfig accepted any URL scheme from LLM_ENDPOINT / --endpoint,
so a misconfigured endpoint such as file:///etc/passwd would be
passed straight to urllib.request.urlopen. Validate the scheme at
construction time and raise ValueError on anything other than
http/https, preserving the "privacy by architecture" guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 04:10:05 -03:00
Igor Lins e Silva 01880f674d Merge pull request #1019 from cantenesse/swe/session-1-bug-mempalace-search-crashes-with-attrib
fix(searcher): guard against None metadata/doc in search result loops
2026-05-06 03:35:07 -03:00
Igor Lins e Silva 8a9b2bed63 Merge pull request #988 from bobo-xxx/clawoss/fix/978-negative-similarity
fix: clamp similarity scores to [0,1] to prevent negative values
2026-05-06 03:34:56 -03:00
Igor Lins e Silva f3d9801c73 Merge pull request #1293 from hzx945627450-eng/fix/mcp-ensure-ascii
fix: MCP server JSON output ensure_ascii=False for non-ASCII support
2026-05-06 03:34:32 -03:00
Igor Lins e Silva 9b24cfc93b Merge pull request #987 from alpiua/fix-mcp-null-payload
fix(mcp): handle null JSON-RPC request payloads safely
2026-05-06 03:33:45 -03:00
Igor Lins e Silva f4617b3d83 Merge pull request #1029 from eldar702/fix/searcher-effective-distance-clamp
fix(searcher): clamp effective_distance to valid cosine range [0, 2]
2026-05-06 03:20:59 -03:00
bobo-xxx f2bed9284f fix(layers): clamp similarity to [0,1] to avoid negative values 2026-05-06 02:20:47 -03:00
bobo-xxx eef053d750 fix(mcp_server): clamp similarity to [0,1] to avoid negative values 2026-05-06 02:20:47 -03:00
Igor Lins e Silva 74288f1cdd style: ruff format mcp_server.py (CI lint) 2026-05-06 02:20:00 -03:00
黄祖鑫(940219)andClaude Opus 4.7 7b49478ef7 fix: MCP server JSON output ensure_ascii=False for non-ASCII support
Without ensure_ascii=False, non-ASCII characters (e.g. Chinese) in tool
results and JSON-RPC responses are escaped as \uXXXX, which causes
downstream MCP clients to receive escaped text instead of the original
characters. This affects all platforms, not just Windows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 02:20:00 -03:00
Igor Lins e Silva 869ab38095 style: ruff format mcp_server.py + test_mcp_server.py (CI lint) 2026-05-06 02:19:57 -03:00
Oleksii Pylypchuk a85d432b54 feat: add validation for missing name parameter in tools/call requests 2026-05-06 02:19:57 -03:00
Oleksii Pylypchuk 55d79dc8cd fix: include null id in JSON-RPC invalid request error responses and add validation tests 2026-05-06 02:19:57 -03:00
Oleksii Pylypchuk 0fdb480e12 fix(mcp): handle null JSON-RPC request payloads safely
When the MCP client sends a malformed or null top-level request, prevent the AttributeError on request.get() by explicitly validating that the request is a dictionary. Returns standard JSON-RPC Error -32600 (Invalid Request) instead of crashing the server.
2026-05-06 02:19:57 -03:00
Igor Lins e Silva aac8437979 style: ruff format tests/test_searcher.py (CI lint) 2026-05-06 02:19:54 -03:00
eldar702 5347c2c71c fix(searcher): clamp effective_distance to valid cosine range [0, 2]
``search_memories`` computes ``effective_dist = dist - boost`` where
``boost`` can be as large as ``CLOSET_RANK_BOOSTS[0] == 0.40`` for a
rank-0 closet hit. When the raw drawer distance is small — any
near-exact match — the subtraction goes negative.

Two downstream effects:

1. Line 418 returns ``round(max(0.0, 1 - effective_dist), 3)`` as
   ``similarity``. With ``effective_dist = -0.30`` that yields
   ``similarity = 1.30``, outside the documented ``[0, 1]`` range.
   The ``max(0.0, ...)`` only prevents negative similarities; it does
   not cap above 1.
2. Line 427 stores ``_sort_key: effective_dist`` and line 435 sorts
   ``scored`` ascending by that key. A negative key drops *below* the
   rest, so the strongest hybrid matches end up sorting after weaker
   ones — ranking inversion under the exact conditions hybrid retrieval
   is supposed to serve best.

Clamp ``effective_dist`` to the valid cosine-distance range ``[0, 2]``.
The boost still wins (closet-backed hit still ranks first), it just no
longer flips the order.

Test added: mock drawer_col (base dist 0.08 / 0.35 for two sources) +
closet_col (rank-0 closet for the 0.08 source) → assert all hits have
``0 <= similarity <= 1`` and ``0 <= effective_distance <= 2``, and that
the closet-boosted source still ranks first.

Relationship to other PRs:

* **#988** clamps the output ``similarity`` alone. That does not fix
  the sort-key inversion or the invalid ``effective_distance`` in the
  returned dict. This PR clamps at the arithmetic source so both
  downstream users of the value stay in range.
* Orthogonal to **#979** (``tool_check_duplicate`` negative similarity).
2026-05-06 02:19:54 -03:00
Chris Antenesse 733e435332 fix(searcher): guard against None metadata/doc in search result loops
ChromaDB can return None entries in metadatas/documents lists under
partial-flush, mid-delete, upgrade-boundary, and interrupted-mine
states. Add `meta = meta or {}` and `doc = doc or ""` guards in the
three result loops (search display, closet hybrid, drawer scored) so
.get() and .strip() calls never crash on None.

Fixes #1007, #1011
2026-05-06 01:59:24 -03:00
Igor Lins e Silva 46d9eb5df0 Merge pull request #1375 from MemPalace/fix/lint-e402-test-hooks-cli
fix(lint): hoist hooks_cli_mod import to top of test_hooks_cli (E402)
2026-05-06 01:58:29 -03:00
Igor Lins e Silva f854da779f fix(lint): hoist hooks_cli_mod import to top of test_hooks_cli (E402)
The alias was placed below an explanatory comment block introduced by
#1305, which trips ruff E402 (module-level import not at top of file).
Moved next to the existing 'from mempalace.hooks_cli import (...)' line.

CI lint went red on develop after #1305 merged with the failing check;
this re-greens it so subsequent PRs do not inherit the failure.
2026-05-06 01:57:44 -03:00
Igor Lins e Silva 67cda9d455 Merge pull request #1030 from eldar702/fix/none-metadata-residual-guards
fix: guard None metadata/doc in tool_check_duplicate and Layer1/Layer2
2026-05-06 01:51:24 -03:00
Igor Lins e Silva 0c8314f919 Merge pull request #1060 from alonehobo/fix/stdio-utf8
fix(mcp): force UTF-8 on stdio to fix -32000 on non-ASCII payloads (Windows)
2026-05-06 01:49:56 -03:00
Igor Lins e Silva 642a073305 Merge pull request #1114 from Sathvik-1007/fix/list-drawers-pagination-total
fix: add total count to tool_list_drawers pagination response
2026-05-06 01:49:37 -03:00
Igor Lins e Silva d9ab5b7fd3 Merge pull request #1305 from lcatlett/upstream/respect-absent-palace-dir
fix(hooks): treat absent ~/.mempalace as auto-save off
2026-05-06 01:49:22 -03:00
Igor Lins e Silva ea6f2c0c4c Merge pull request #1162 from imtylervo/fix/palace-write-lock-queue-pattern
fix: serialize ChromaCollection writes through palace lock
2026-05-06 01:48:51 -03:00
Igor Lins e Silva d1e27b8c42 style: ruff format new test files (CI lint) 2026-05-06 01:47:46 -03:00
Igor Lins e Silva 5ae83d8ec3 Merge pull request #1370 from MemPalace/docs/changelog-v3.3.5-batch1
docs(changelog): batch entries for 7 v3.3.5 fixes
2026-05-06 01:40:21 -03:00
Igor Lins e Silva 2c0ef2c04e docs(changelog): document v3.3.5 fixes from #1214 #1105 #1215 #1107 #1282 #1167 #1160
Bundled CHANGELOG entries for the seven Tier-1 PRs merged today, including
the behavior-change call-out for #1167 (KG date validators now reject
non-ISO inputs that previously produced silent empty results).
2026-05-06 01:38:57 -03:00
Igor Lins e Silva 53675dd194 Merge pull request #1160 from mvalentsev/fix/mcp-kg-lazy-per-path-cache
fix(mcp): lazy per-path KnowledgeGraph cache (#1136)
2026-05-06 01:33:47 -03:00
Igor Lins e Silva 7ede231da9 Merge pull request #1167 from arnoldwender/fix/kg-date-validation
fix(kg): validate ISO-8601 date formats at MCP boundary
2026-05-06 01:33:27 -03:00
Igor Lins e Silva 3824ea610c Merge pull request #1282 from mvalentsev/fix/fact-checker-stdio-utf8
fix(cli, fact-checker): reconfigure stdio to UTF-8 on Windows
2026-05-06 01:33:15 -03:00
Igor Lins e Silva 778f830cd0 Merge pull request #1107 from sha2fiddy/fix/1073-closet-llm-paginate
fix: paginate closet_llm col.get (#1073)
2026-05-06 01:33:04 -03:00
Igor Lins e Silva e18981a527 Merge pull request #1215 from arnoldwender/fix/entity-registry-atomic-write
fix(entity_registry): atomic write to prevent partial corruption on crash
2026-05-06 01:32:46 -03:00
Igor Lins e Silva ef0e45ad92 Merge pull request #1105 from mvalentsev/fix/chroma-backend-close-releases-lock
fix(backends/chroma): release SQLite file lock on close_palace/close (#1067)
2026-05-06 01:32:30 -03:00
Igor Lins e Silva 0cfb4b3ef1 Merge pull request #1214 from arnoldwender/fix/kg-temporal-inversion-guard
fix(kg): reject inverted intervals in add_triple (valid_to < valid_from)
2026-05-06 01:32:16 -03:00
fatkobra 6b042982e8 fix(repair): preflight SQLite integrity before rebuild 2026-05-05 15:51:43 +00:00
fatkobra bb40a529fd fix(migrate): verify write roundtrip before bailout 2026-05-05 09:01:05 +00:00
fatkobra 37e7d394b8 fix(repair): preflight poisoned max_seq_id 2026-05-05 07:22:10 +00:00
fatkobra eff844b168 fix(storage): quarantine partial HNSW flush without metadata 2026-05-04 10:34:22 +00:00
Arnold Wender 2e441d17a2 fix(entity_registry): fsync parent dir after rename for ext4 durability
Without this, on ext4 (and similar) filesystems the rename ack does not
guarantee durability across power loss — a crash can revert to a state
where the temp file is present and the target is at the old version.

Suggested by @jphein on #1215.
2026-05-04 11:08:14 +02:00
Arnold Wender 4f36145c2e fix(entity_registry): atomic write to prevent partial corruption on crash
EntityRegistry.save() called Path.write_text() directly, which truncates
the target file and then writes — so a crash mid-write (power loss, OOM,
filesystem-full mid-flush) leaves an empty or half-written
entity_registry.json. The whole people/projects map is lost; the system
falls back to an empty registry on next load.

Switch to the standard atomic-write pattern: serialize to a sibling
.tmp file in the same directory (so os.replace stays on one filesystem),
fsync, chmod 0o600, then os.replace over the target. The replace is
atomic on POSIX and Windows, so any crash leaves the previous registry
intact instead of a truncated file.

Tests cover: no leftover .tmp on success, and previous content preserved
when os.replace itself raises mid-save.
2026-05-04 11:08:14 +02:00
fatkobra f9d939ae1b fix(storage): quarantine bloated HNSW link payloads 2026-05-04 06:45:29 +00:00
mvalentsev 285b3b4f2e refactor(stdio): extract Windows UTF-8 reconfigure into shared helper
Both cli.py and fact_checker.py carried identical 28-line Windows stdio
reconfigure helpers; pull the loop into mempalace/_stdio.py so the same
machine drives the CLI, the fact_checker --stdin entry point, and the
MCP server. The thin per-call-site wrappers stay so existing tests keep
importing _reconfigure_stdio_utf8_on_windows from the same module they
always have.

CLI / fact_checker policy unchanged: stdin=surrogateescape (don't crash
on a malformed redirected file), stdout/stderr=replace (don't crash
mid-print on a surrogate half round-tripped from a filename).
2026-05-03 22:25:31 +05:00
mvalentsev 75ad8ae781 ci: retrigger linux 3.13 (transient onnx download flake) 2026-05-03 22:04:22 +05:00
mvalentsev b8816e0fe2 fix(mcp): retry KG handlers once on concurrent close race
Race scenario: a KG tool handler calls _get_kg() and gets a live
KnowledgeGraph; another thread fires tool_reconnect() between that
return and the handler's kg.add_triple()/kg.query_entity()/etc call.
tool_reconnect drains _kg_by_path and closes the underlying
sqlite3.Connection; the handler then raises sqlite3.ProgrammingError:
'Cannot operate on a closed database', which surfaces as a -32000
to the MCP client even though the user just asked for a reconnect.

New _call_kg(op) helper wraps each handler's kg call in a one-shot
retry: catch exactly sqlite3.ProgrammingError, evict the stale entry
(only if the cache slot still points at the closed instance — another
thread may have already replaced it), and rerun op against a fresh
_get_kg(). Beyond one retry give up so a sustained close-stream
surfaces clearly instead of looping.

All five KG handlers (tool_kg_query, tool_kg_add, tool_kg_invalidate,
tool_kg_timeline, tool_kg_stats) now route through _call_kg.

Tests pin the contract:
  * retries with a fresh KG and returns the second result
  * non-ProgrammingError exceptions propagate without retry
  * gives up after exactly one retry on sustained close
2026-05-03 21:43:51 +05:00
mvalentsev 03643eb507 fix(cli, fact-checker): per-stream stdio errors policy on Windows
Previously all three streams reconfigured to UTF-8 with errors='strict'.
That kills 'mempalace search' the moment a drawer carrying a surrogate
half (round-tripped from a filename via surrogateescape) hits print(),
losing the rest of the result block. Same hazard for warning lines on
stderr.

Split the policy:
  stdin  -> surrogateescape (malformed bytes from a redirected file
            survive as lone surrogates instead of crashing the read)
  stdout -> replace (drawer text with a stray surrogate becomes U+FFFD
            instead of UnicodeEncodeError mid-print)
  stderr -> replace (same protection for logger / warning paths)

Applied identically in the cli.py and fact_checker.py helpers; the DRY
extraction into a shared module is a separate cleanup ask, kept out of
this fix to keep the diff narrow.

Tests updated for the new per-stream assertion.
2026-05-03 21:37:12 +05:00
mvalentsev 32f4dfa26d fix(cli): reconfigure stdio to UTF-8 on Windows
The primary `mempalace` console_script (`cli.py:main()`) reads non-ASCII
arguments via piped stdin and writes verbatim drawer text / wing names
through `print()`. On Windows, Python defaults stdio to the system ANSI
codepage (cp1252/cp1251/cp950), so:

- `mempalace search "..." > out.txt` mojibakes any drawer text containing
  non-Latin characters
- `mempalace ... < input.txt` mojibakes piped non-ASCII input

Reconfigure stdin/stdout/stderr to UTF-8 (`errors="strict"`) at the top
of `main()`, mirroring the helper added in this PR for fact_checker's
`__main__` block. Wrapped in try/except so a replaced stream (Jupyter,
test harness) logs a warning and continues rather than crashing the CLI.

The reconfigure cascades through every `mempalace` subcommand
(`init`/`mine`/`search`/`status`/`hook`/etc.) and through the interactive
flows that read non-ASCII names via `input()` (onboarding, entity
detector, room detector). With this commit the package's three
user-facing entry points (`mempalace`, `mempalace-mcp`, and
`python -m mempalace.fact_checker`) all reconfigure stdio identically on
Windows.
2026-05-03 21:33:54 +05:00
mvalentsev 7cee74c8c8 fix(fact-checker): reconfigure stdio to UTF-8 on Windows
The `python -m mempalace.fact_checker --stdin` entry point reads non-ASCII
text through the system ANSI codepage (cp1252/cp1251/cp950) on Windows,
which mojibakes characters before claim-extraction sees them. Reconfigure
stdin/stdout/stderr to UTF-8 with `errors="strict"`, wrapped in try/except
so a replaced stream (Jupyter, test harness) logs a warning rather than
crashing the CLI.

Mirrors the same fix shipped for `mcp_server.py:main()` (#400) and
`hooks_cli.py:run_hook()` (#1280) -- this is the third and last
stdin-reading entry point in the package.
2026-05-03 21:33:54 +05:00
mvalentsev 45df1a2657 fix(backends/chroma): release SQLite file lock on close_palace/close (#1067)
ChromaBackend.close_palace() and close() evicted cached PersistentClients
from self._clients without calling client.close(), so chromadb 1.5.x kept
the rust-side SQLite file lock until GC. Reopening the same palace path
after shutil.rmtree + re-create within one process then failed with
SQLITE_READONLY_DBMOVED (SQLite code 1032).

Add _close_client() helper with a try/except fallback for older chromadb,
and route close_palace(), close(), and the DB-file-missing invalidation
branch of _client() through it. The mtime/inode auto-invalidation branch
is left as-is: callers there may still hold a live ChromaCollection
handle, and closing out from under them clears the rust bindings mid-use.

Regression tests cover close_palace reopen-same-path and whole-backend
close for multiple palaces.
2026-05-03 19:16:25 +05:00
mvalentsev 0a62658051 fix(mcp): drain KG cache on tool_reconnect
tool_reconnect cleared ChromaDB caches but left _kg_by_path entries
intact. After an external replacement of knowledge_graph.sqlite3 the
server kept serving the old open sqlite3.Connection, returning stale
results.

Now iterate _kg_by_path under _kg_cache_lock, call close() best-effort,
and clear the dict so the next tool call reopens the KG from disk.
Two new tests in TestKGLazyCache verify cache invalidation and that a
failing close() does not block the clear.
2026-05-03 17:43:00 +05:00
mvalentsev 19f8a4ff68 style(mcp): drop issue-tracker comments from KG cache block
Inline comments referencing #1136 and #540 add no information the
identifiers do not already convey. PR description carries the context;
code stays quiet.
2026-05-03 17:43:00 +05:00
mvalentsev 84f9726a39 test(mcp): fix Windows subprocess env in KG lazy-init test
Passing a stripped env dict without SYSTEMROOT/WINDIR breaks Python
bootstrap on Windows (_Py_HashRandomization_Init). Inherit the parent
env and strip MEMPAL* vars instead, then override HOME/USERPROFILE to
the tmp dir.
2026-05-03 17:43:00 +05:00
mvalentsev c69a622a18 test(mcp): add multi-tenant and lazy-init tests for KG (#1136)
TestKGLazyCache covers the scenarios behind the lazy per-path refactor:

- test_lazy_init_no_import_side_effect: a fresh subprocess import does
  not create ~/.mempalace/knowledge_graph.sqlite3 (what closed PR #167
  was aiming at).
- test_get_kg_returns_same_instance: two _get_kg() calls under the same
  resolved path return the same object, cache has one entry.
- test_get_kg_different_paths_different_instances: rotating env var
  produces distinct KGs.
- test_multi_tenant_env_switch: the exact scenario from #1136 — write
  under path A, query under path B returns empty, switching back to A
  sees the fact.
- test_cache_thread_safe: 16 threads racing _get_kg() end up with one
  shared instance and one cache entry.
2026-05-03 17:43:00 +05:00
mvalentsev 9e730098e9 test(mcp): migrate _kg monkeypatches to _get_kg (#1136)
Direct module-attribute patching of _kg is obsolete after the lazy
cache refactor. Switch test helpers to patch _get_kg instead so the
fixture KG replaces the factory rather than a now-missing singleton.

- tests/test_mcp_server.py: _patch_mcp_server helper
- tests/benchmarks/test_mcp_bench.py: _patch_mcp_config helper
- tests/benchmarks/test_memory_profile.py: inline patch in test_tool_status_repeated_calls
2026-05-03 17:43:00 +05:00
mvalentsev beac5d9954 refactor(mcp): replace eager _kg with lazy per-path cache (#1136)
Swap the module-level KnowledgeGraph singleton for a lazy, per-path
cache keyed by the resolved sqlite path. Import no longer creates a
sqlite file as a side effect, and MCP servers started with --palace
now route KG calls to the correct tenant when MEMPALACE_PALACE_PATH
changes between calls, matching the per-call behavior of _get_client()
on the ChromaDB side.

Default-path behavior is preserved: without --palace at startup, KG
stays on DEFAULT_KG_PATH regardless of env var. The "no --palace but
env var set" case is #540's scope and is not changed here.
2026-05-03 17:43:00 +05:00
Igor Lins e Silva 1888b671e2 Merge pull request #1321 from MemPalace/fix/1313-init-palace-flag
fix(cli): honor --palace flag in cmd_init (#1313)
2026-05-03 03:54:06 -03:00
Igor Lins e Silva a91b7ee5c2 test(cli): prime monkeypatch undo so palace env doesn't leak
monkeypatch.delenv(name, raising=False) on a missing key registers no
undo entry, so the env var cmd_init writes leaked into test_config_from_file
on Python 3.13 / Windows / macOS.

Prime the slot with setenv before delenv so teardown rolls back the write.
2026-05-03 06:27:37 -03:00
Igor Lins e Silva 5380189f82 Merge pull request #1320 from MemPalace/fix/1314-kg-temporal-params
fix(mcp): forward valid_to and source params in kg_add/kg_invalidate (#1314)
2026-05-03 03:51:29 -03:00
Igor Lins e Silva 0e65c54978 docs(mcp): drop §5.5 from kg_add docstring/schema
The repo's anti-jargon meta-test bans §N markers outside the
sources/backends allowlist. mcp_server.py isn't allowlisted, so the
"RFC 002 §5.5" references added in this PR turned the test red.
Trim to "RFC 002" — section number isn't load-bearing for the description.
2026-05-03 06:28:12 -03:00
Igor Lins e Silva 2ad379b547 Merge pull request #1306 from MemPalace/feat/hybrid-candidate-union
feat(searcher): candidate_strategy="union" — BM25 candidates joined with vector pool before hybrid rerank
2026-05-03 03:40:51 -03:00
Igor Lins e Silva 3eb7980e55 fix(searcher): address Copilot review on #1306
- Dedup union candidates by (full_path, chunk_index), not basename —
  two files sharing a basename in different dirs no longer collide,
  and a vector hit on chunk N of a file no longer blocks BM25 from
  contributing chunk M of the same file.
- Validate candidate_strategy at the top of search_memories so invalid
  values fail consistently, not only when the call routes through the
  vector path.
- Trim hits back to n_results after the union+rerank pool grows;
  preserves the existing search_memories size contract that the MCP
  limit parameter is built on.
- Skip BM25-only injection when max_distance > 0.0; BM25-only
  candidates carry distance=None and would silently bypass the
  caller's strict vector-distance threshold.

Adds 4 tests covering: validation under vector_disabled, n_results
trim, max_distance honoring, and basename-collision dedup.
2026-05-03 06:09:10 -03:00
Igor Lins e Silva 3e6f6480c0 Merge pull request #1325 from MemPalace/security/mcp-omit-absolute-paths
fix(mcp): omit absolute filesystem paths from MCP tool responses
2026-05-03 03:20:11 -03:00
Igor Lins e Silva 7fc260f752 fix(mcp): basename source_file in tool_get_drawer responses
The MCP `mempalace_get_drawer` tool returned the entire raw drawer
metadata blob to any connected client, and the `source_file` field
in that blob is the absolute filesystem path written by the miners
(`miner.py`, `convo_miner.py` — `source_file = str(filepath)`). On
a single-user local deployment this is self-disclosure, but in
nested-agent or multi-server MCP topologies the client is a separate
trust domain and the host's directory layout has no documented
client-side use.

Mirror the mitigation that `searcher.search_memories()` already applies
on its own return path: reduce `source_file` to its basename via
`Path(source_file).name` before handing the metadata to the client.
Citations still work — the directory layout does not leak.

Companion to #1 (omit palace_path from tool_status). Same threat class,
different surface:

- mempalace_status — palace dir path     → fixed in #1
- mempalace_get_drawer — per-drawer source_file path → this PR

Other read tools were audited and do not leak host paths:
- mempalace_search    — already basenames source_file
- mempalace_list_drawers — returns wing/room/preview only
- mempalace_diary_read   — date/timestamp/topic/content only
- mempalace_reconnect    — success/message/drawers only
- mempalace_kg_*         — entity/predicate strings, counts
- mempalace_check_duplicate — wing/room/preview only

Changes:
- mempalace/mcp_server.py: tool_get_drawer() now basenames metadata.source_file
- tests/test_mcp_server.py: regression test asserting the absolute path
  and its parent directory do not appear anywhere in the response
- website/reference/mcp-tools.md: clarify the documented return shape
2026-05-03 05:58:46 -03:00
icciAaron b2f259c253 fix(mcp): omit palace_path from tool_status responses (+ docs)
The MCP `mempalace_status` tool was returning the server's absolute
`_config.palace_path` to any connected client on both the main
(ChromaDB-backed) path and the sqlite fallback path that runs when
HNSW divergence is detected (#1222). On a single-user local deployment
this is self-disclosure, but in nested-agent or multi-server MCP
topologies the client is a separate trust domain and the absolute
path has no documented client-side use.

Clients that legitimately need the palace path continue to have three
documented channels: the `MEMPALACE_PALACE_PATH` env var (primary) or
its legacy `MEMPAL_PALACE_PATH` alias, the `~/.mempalace/config.json`
file, and the `--palace` CLI flag on most subcommands.

Also corrects stale docs that claimed `mempalace_reconnect` returned a
`palace_path` field; the code returns `{success, message, drawers,
vector_disabled[, vector_disabled_reason]}` on success, plus a no-palace
shape and an exception shape.

- mempalace/mcp_server.py: drop palace_path from tool_status() and
  _tool_status_via_sqlite() result dicts
- website/reference/mcp-tools.md: update documented return shapes for
  mempalace_status (fix) and mempalace_reconnect (stale-docs correction)

Authored-by: Aaron Salsitz (ICCI LLC, @icciaaron). Claude Code was used
as an authoring and review-orchestration tool, with human-in-the-loop
oversight at every step: Aaron wrote the prompts, reviewed each draft,
called for three independent review passes (drafting / post-rebase
technical / CISA-aligned disclosure-leak), and verified the final patch
behavior before commit.
2026-05-03 05:58:46 -03:00
Igor Lins e Silva 6f88b2a34e Merge pull request #1322 from MemPalace/fix/1121-1132-1263-client-quarantine
fix(backends/chroma): wire quarantine_stale_hnsw into _client() (#1121 #1132 #1263)
2026-05-03 03:18:28 -03:00
Igor Lins e Silva a690eb398f Merge pull request #1323 from MemPalace/fix/1243-diary-case-insensitive
fix(mcp): case-insensitive agent name in diary read/write (#1243)
2026-05-03 03:18:11 -03:00
Igor Lins e SilvaandClaude Opus 4.7 339924a387 fix(cli): cmd_compress writes to mempalace_closets (#1244) (#1319)
* fix(cli): write compress output to mempalace_closets so palace can read them (#1244)

`cmd_compress` was writing AAAK-compressed drawers to a `mempalace_compressed`
collection, but every read path (`palace.get_closets_collection`,
`searcher.py`, `repair.py`) reads from `mempalace_closets`. Result: for
non-mined palaces (or any palace where the user ran `mempalace compress`
expecting to backfill the closet/index layer), the compressed output was
silently invisible — written to a collection nothing else opens.

Fix the writer rather than renaming the readers: "closets" is the
user-visible feature name baked into the public API
(`get_closets_collection`), the searcher hybrid path, repair/HNSW
diagnostics, and docs. Renaming the readers would churn 15+ call sites
and the README for no benefit. The compressed AAAK strings are exactly
what closets are conceptually — compact pointers scanned by an LLM to
locate the right drawer — so they belong in `mempalace_closets`.

Tests:
- Update `test_cmd_compress_stores_results` to assert the collection
  name passed to `get_or_create_collection` is `mempalace_closets`.
- Add `test_cmd_compress_output_readable_via_get_closets_collection`:
  end-to-end with a real ChromaBackend, seed a drawer, run cmd_compress,
  then read back via the same `get_closets_collection` helper that
  palace.py / searcher use. Regression test for the wrong-collection
  bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: ruff format cli.py (#1244)

CI requires ruff format --check on the whole touched file. Pre-existing drift, no logic change.

* style: ruff format tests/test_cli.py (PR #1319)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 03:17:45 -03:00
igorls 2397481158 style: ruff format tests/test_mcp_server.py (PR #1323) 2026-05-02 23:00:10 -03:00
igorls f854d86d2f style: ruff format tests/test_backends.py (PR #1322) 2026-05-02 23:00:08 -03:00
igorls 2857948c1e style: ruff format tests/test_cli.py (PR #1319) 2026-05-02 23:00:07 -03:00
igorls 6ffbf6ffc3 style: ruff format test_mcp_server.py (PR #1320) 2026-05-02 22:59:50 -03:00
igorls b4a9f2adf2 style: ruff format touched files (PR #1322)
CI requires whole-file format on touched files; pre-existing drift only.
2026-05-02 22:58:57 -03:00
igorls 4b0fc44451 style: ruff format cli.py (#1244)
CI requires ruff format --check on the whole touched file. Pre-existing drift, no logic change.
2026-05-02 22:58:45 -03:00
Igor Lins e SilvaandClaude Opus 4.7 e9222b4c7b fix(mcp): case-insensitive agent name in diary_write/diary_read (#1243)
`tool_diary_write` stored the `agent` metadata verbatim after `sanitize_name`
(which preserves case), while `tool_diary_read` filtered by exact match —
so writing as "Claude" and reading as "claude" silently returned zero rows.

Both endpoints now lowercase `agent_name` immediately after sanitization.
The default per-agent wing slug is also stable across casings since it's
derived from the same normalized form.

Behavior change: entries written prior to this fix under mixed-case agent
names will not match the new lowercase filter; documented under v3.3.5
in CHANGELOG with a `mempalace repair` pointer.

Adds a regression test (`test_diary_read_case_insensitive_agent`) and
updates the existing `test_diary_write_and_read` to assert the new
lowercase agent identity.

Closes #1243

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:57:09 -03:00
igorlsandClaude Opus 4.7 10733f1df4 fix(backends/chroma): wire quarantine_stale_hnsw into _client() to prevent SIGSEGV on stale HNSW (#1121, #1132, #1263)
PR #1173 wired quarantine_stale_hnsw into the static make_client() helper
but not into the instance _client() method. As a result every non-MCP
entry point (CLI mining, search, repair, status) — which all use
get_collection / _get_or_create_collection / _client() — skipped the
cold-start quarantine pass and could SIGSEGV on a stale HNSW segment
left over from a partial flush, replicated palace, or crashed-mid-write.

Refactor: extract the (_fix_blob_seq_ids + gated quarantine_stale_hnsw)
pre-open pass into a single private static helper
ChromaBackend._prepare_palace_for_open(). Both make_client() and
_client() now route through it, so the _quarantined_paths once-per-
palace-per-process gate is preserved (no runtime thrash on hot paths)
and behaviour stays identical — the fix is purely about extending the
existing protection to the path that was missing it.

Tests:

- test_client_quarantines_corrupt_segment_on_first_open mirrors the
  existing make_client test and verifies _client() actually renames a
  corrupt segment on first open.
- test_client_quarantines_only_on_first_call_per_palace verifies the
  cache gate prevents re-running quarantine across repeated _client()
  calls — important because _client() is hit on every backend op.

Closes #1121. Closes #1132. Closes #1263.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:56:36 -03:00
Igor Lins e Silva 01b3183e5d fix(cli): honor --palace flag in cmd_init (#1313)
cmd_init was instantiating MempalaceConfig() unconditionally, ignoring
args.palace and always writing the palace under ~/.mempalace. Mirror
the env-var pattern used by mcp_server.py (and consistent with how
cmd_mine / cmd_status / cmd_search resolve --palace) so every
downstream read of cfg.palace_path inside cmd_init — Pass 0,
cfg.init(), and the post-init mine — routes to the user-specified
location.

Adds tests/test_cli.py::test_cmd_init_honors_palace_flag covering the
regression: asserts Pass 0 receives the --palace value (not
~/.mempalace) and that MEMPALACE_PALACE_PATH is set in os.environ.

Closes #1313.
2026-05-02 22:56:31 -03:00
Igor Lins e SilvaandClaude Opus 4.7 e4e25ed186 fix(mcp): forward valid_to and source params in kg_add/kg_invalidate (#1314)
`tool_kg_add` previously accepted only `valid_from` and `source_closet`,
silently dropping `valid_to`, `source_file`, and `source_drawer_id` at
the MCP boundary. Backfilling already-ended historical facts therefore
collapsed to "still current," and adapter provenance never reached
the SQLite layer even though `KnowledgeGraph.add_triple` already
supported every column.

`tool_kg_invalidate` returned the literal string `"today"` whenever the
caller omitted `ended`, hiding the actual stamped date from anyone trying
to verify what got persisted.

Changes:
- Extend `tool_kg_add` signature + MCP input_schema with `valid_to`,
  `source_file`, `source_drawer_id`; forward all of them to
  `_kg.add_triple` and to the WAL log.
- Resolve `ended` to `date.today().isoformat()` in `tool_kg_invalidate`
  before logging / returning, so the response always reports the actual
  date stored in `valid_to`.
- Add regression tests for valid_to round-trip, source_file /
  source_drawer_id provenance, and the resolved-ended-date contract.
- Leave TODO(#1283) markers so the open ISO-8601 validation PR can drop
  `validate_iso_date` over `valid_from` / `valid_to` / `ended` cleanly.

The underlying `KnowledgeGraph.add_triple` already accepted these
kwargs (RFC 002 §5.5) — only the MCP edge needed wiring up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:54:32 -03:00
Igor Lins e SilvaandClaude Opus 4.7 cbd6e5d65d fix(cli): write compress output to mempalace_closets so palace can read them (#1244)
`cmd_compress` was writing AAAK-compressed drawers to a `mempalace_compressed`
collection, but every read path (`palace.get_closets_collection`,
`searcher.py`, `repair.py`) reads from `mempalace_closets`. Result: for
non-mined palaces (or any palace where the user ran `mempalace compress`
expecting to backfill the closet/index layer), the compressed output was
silently invisible — written to a collection nothing else opens.

Fix the writer rather than renaming the readers: "closets" is the
user-visible feature name baked into the public API
(`get_closets_collection`), the searcher hybrid path, repair/HNSW
diagnostics, and docs. Renaming the readers would churn 15+ call sites
and the README for no benefit. The compressed AAAK strings are exactly
what closets are conceptually — compact pointers scanned by an LLM to
locate the right drawer — so they belong in `mempalace_closets`.

Tests:
- Update `test_cmd_compress_stores_results` to assert the collection
  name passed to `get_or_create_collection` is `mempalace_closets`.
- Add `test_cmd_compress_output_readable_via_get_closets_collection`:
  end-to-end with a real ChromaBackend, seed a drawer, run cmd_compress,
  then read back via the same `get_closets_collection` helper that
  palace.py / searcher use. Regression test for the wrong-collection
  bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:54:14 -03:00
lcatlett 2d50b214d4 fix(hooks): use is_dir() for palace root check (review feedback)
Both @igorls and the Qodo bot flagged that `_palace_root_exists()` used
`Path.exists()`, which returns True for a regular file. A stray file at
`~/.mempalace` would let the kill-switch be bypassed and crash later in
`STATE_DIR.mkdir()` with NotADirectoryError.

Switched to `Path.is_dir()`. Also fold `_log()`'s inline check through
`_palace_root_exists()` so both kill-switch sites use the same predicate.

New test pins the behavior: a regular file at the palace root path is
treated as absent (hook short-circuits, _log does not crash, the stray
file is left untouched).
2026-05-02 20:37:47 -04:00
lcatlett 8472d553a3 fix(hooks): treat absent ~/.mempalace as auto-save off
When the user removes ~/.mempalace/ (a strong "do not auto-capture"
signal), the next hook fire would silently recreate the entire dir
hierarchy and ingest existing transcripts:

1. _log() at hooks_cli.py:148 unconditionally calls
   STATE_DIR.mkdir(parents=True, exist_ok=True), so the act of
   writing the hook log line recreated ~/.mempalace/hook_state/
2. With no config file present, hook_stop_auto_save and
   hook_precompact_auto_save defaulted to True (no override to read)
3. The full save path then ran, materializing palace/, wal/,
   knowledge_graph.sqlite3, and N drawers from existing transcripts
   in ~/.claude/projects/*.jsonl

All four entry points (hook_stop, hook_precompact, hook_session_start,
and _log itself) now check a new PALACE_ROOT = Path.home() / ".mempalace"
constant first and short-circuit (returning {} on stdout, never logging)
when the dir is absent. The user-removable directory is now a kill-switch.

Five unit tests in tests/test_hooks_cli.py cover: hook_stop /
hook_precompact / hook_session_start do not create the dir when absent;
_log() does not create it when absent; existing dir proceeds normally
(regression).

Caught in the wild on a downstream fork: ~146 drawers materialized in
under a second after a deliberate `rm -rf ~/.mempalace/`, into a planning
session that was explicitly not meant to be captured.
2026-05-02 20:33:58 -04:00
Mikhail Valentsev d07b730f08 fix(hooks): quote CLAUDE_PLUGIN_ROOT / CODEX_PLUGIN_ROOT in hooks.json (#1076) (#1077)
Shell splits hook command on whitespace after variable expansion, breaking
paths with spaces (e.g. C:\Users\Richard M on Windows). Wrapping the path
in double quotes preserves the token boundary.

Fixes the reported Stop/PreCompact pair in .claude-plugin/hooks/hooks.json
and applies the same fix to .codex-plugin/hooks.json (SessionStart/Stop/
PreCompact), which carries the identical bug.
2026-05-02 21:25:11 -03:00
Igor Lins e Silva 6509071b8e feat(searcher): add candidate_strategy="union" for vector∪BM25 reranking pool
Default search behavior is unchanged. Opt-in candidate_strategy="union"
also pulls top-K BM25-only candidates from sqlite FTS5 and merges them
into the rerank pool, catching docs with strong BM25 signal that the
vector index didn't surface in the over-fetch window.

Motivation
----------
The current hybrid path gathers candidates from the vector index only
(n_results * 3 over-fetch), then BM25-reranks within them. When the
query embeds close to the wrong content semantically, the right doc
never enters the rerank pool — *no matter how wide the over-fetch*.
Tested on a ~6K-document mixed corpus (knowledge prose + short structured
records): at *30x* over-fetch (~5% of the corpus) the target doc still
didn't surface for narrative-shaped queries targeting terminology guides.
Wider over-fetch isn't the answer; widening the pool's *source* is.

Concrete failure mode: a narrative-shaped query embeds close to records
sharing the same operational vocabulary (other narrative entries in the
corpus). A terminology / style guide is BM25-strong for the query
(rare keywords the guide repeats) but vector-distant. Vector-only
candidates don't include it; BM25 never gets to rerank it. The hybrid
path produces 0.00 recall on a probe that pure BM25 alone scores 1.00 —
the hybrid is worse than its component on the same input.

Behavior change
---------------
* New parameter ``candidate_strategy: str = "vector"`` on ``search_memories``.
  - ``"vector"`` (default): historical behavior, no change.
  - ``"union"``: also fetch top ``n_results * 3`` candidates via the
    existing ``_bm25_only_via_sqlite`` helper, dedupe by source_file,
    merge into the rerank pool. BM25-only candidates carry
    ``distance=None`` so they're scored on BM25 contribution alone
    (vec_sim coerces to 0).
* ``_hybrid_rank`` now handles ``distance=None`` explicitly, scoring
  such candidates as vector-unknown (vec_sim=0) rather than treating
  it as max-distance via shim.
* New strategies register via ``_CANDIDATE_MERGERS``; dispatch is in
  ``_apply_candidate_strategy`` so ``search_memories`` stays under the
  C901 complexity ceiling.

Bench numbers (~6K-doc internal mixed corpus, recall@10, 5 probes spanning
policy-exception lookup, temporal-decay, style retrieval, set-difference,
and pattern-recognition):

                              baseline ("vector")   "union"
  policy-exception probe        0.00                  0.50    +0.50
  temporal-decay probe          0.17                  0.50    +0.33
  style-retrieval probe         0.00                  1.00    +1.00 (PASSES)
  set-difference probe          0.00–0.06             0.06–0.09  ~
  pattern-recog probe           0.64 (stable)         0.50–0.71  variance, typ. +0.07
  macro recall                  0.16–0.17             0.51–0.56  +0.34 to +0.40

The pattern-recog variance points at a related issue worth a separate PR:
``_hybrid_rank`` computes BM25 IDF over the candidate set. Adding new
candidates re-normalizes BM25 for *existing* candidates non-monotonically.
Stable corpus-wide BM25 would remove this. Out of scope here.

Tests
-----
``tests/test_hybrid_candidate_union.py`` (6 tests, all pass):
- default behavior unchanged (explicit ``"vector"`` matches default)
- ``"union"`` surfaces a BM25-strong vector-distant doc
- ``"union"`` doesn't drop docs ``"vector"`` would have found
- empty-palace handling
- invalid ``candidate_strategy`` raises
- ``_hybrid_rank`` tolerates ``distance=None``

Existing ``test_hybrid_search.py`` (5) and ``test_searcher.py`` (27) pass.

Performance note
----------------
Each ``"union"`` query adds one sqlite open + FTS5 MATCH + metadata
fetch (via the existing ``_bm25_only_via_sqlite`` helper, which already
runs as the ``vector_disabled`` fallback path so the code is
well-trodden). Per-query overhead is small but unmeasured at corpus
scale. Default stays ``"vector"`` until a maintainer characterizes the
cost.
2026-05-02 00:50:19 -03:00
Igor Lins e Silva 5ddaf7abf6 Merge pull request #1303 from MemPalace/fix/mcp-server-missing-embedding-function
fix(mcp_server): pass embedding_function= on collection reopen (#1299)
2026-05-01 20:28:05 -03:00
Igor Lins e Silva cd98d6674e fix(mcp_server): address copilot review on #1303
- Resolve the EF inside the two reopen branches that actually call
  `client.get_collection` / `client.create_collection`, so warm-cache
  reads stay zero-cost (no `MempalaceConfig()` / `_resolve_providers`
  on every tool call).
- Reuse `ChromaBackend._resolve_embedding_function()` instead of
  duplicating its try/except + log message + None-fallback.
- Reword the inline + CHANGELOG explanation to clarify that ChromaDB 1.x
  persists the EF *identity* (its `name()`) but not the *instance/
  configuration* — `mempalace.embedding` documents this and spoofs
  `name()` to `"default"` precisely so the identity check passes; the
  bug was the *provider list* (lazy ONNX selection) silently differing.
2026-05-01 19:46:59 -03:00
Igor Lins e Silva ac6c2b6af6 fix(mcp_server): pass embedding_function= on collection reopen (#1299)
`mcp_server._get_collection` bypassed `ChromaBackend.get_collection`
and called `client.get_collection` / `client.create_collection` without
`embedding_function=`. ChromaDB 1.x does not persist the EF identity
with the collection, so the MCP server's reopen silently bound
chromadb's built-in `DefaultEmbeddingFunction` while the miner / Stop
hook ingest path bound `mempalace.embedding.get_embedding_function()`.

On bleeding-edge interpreters (python 3.14 + chromadb 1.5.x on Apple
Silicon, per #1299) the default EF's lazy ONNX provider selection could
SIGSEGV the host process on first `col.add()`, killing the MCP stdio
server and leaving every subsequent tool call returning
`Connection closed` until Claude Code was relaunched. Reads worked
because `col.get(ids=...)` and metadata fetches don't invoke the EF;
the auto-ingest path worked because mining routes through the backend
abstraction. Diary writes were the consistent failure surface.

Resolve the EF up front (matching `ChromaBackend._resolve_embedding_function`)
and pass it into both reopen branches. Falls back to the chromadb default
only if `mempalace.embedding.get_embedding_function` itself raises.

Regression test patches the chromadb client class to capture
`embedding_function=` on every `get_collection` / `create_collection`
call from `_get_collection(create=True)` and `_get_collection()`, and
fails if any call omits it.

Follow-up to #1262 / #1289 (which fixed the metadata-mismatch SIGSEGV
path); this addresses the EF-mismatch SIGSEGV path on the same surface.
2026-05-01 19:34:38 -03:00
Mika Cohen 0e32b9643c fix: avoid false hnsw divergence fallback 2026-05-01 12:42:40 -06:00
Mika Cohen f57f30025f fix(repair): close active backend before rollback restore
Rollback cleanup was instantiating a fresh ChromaBackend, so the live backend that had opened the PersistentClient could keep file handles alive during restore. Close the active backend instance instead so rollback and CLI recovery can release Windows-safe locks before copying the backup back into place.
2026-05-01 12:42:19 -06:00
Mika Cohen 2f509b4789 fix(cli): restore backup on repair failure 2026-05-01 12:42:18 -06:00
Mika Cohen 7fa27bd231 fix(repair): rebuild collections through temp staging 2026-05-01 12:42:18 -06:00
Mika Cohen c3e1104e75 fix(chroma): harden HNSW startup preflight 2026-05-01 12:42:18 -06:00
dependabot[bot] 10a0bc1a2b chore(deps): bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-01 08:55:01 +00:00
Milla J d53129ed12 Merge pull request #1232 from MemPalace/chore/release-3.3.4-prep
chore(release): v3.3.4
2026-04-30 20:30:54 -07:00
Igor Lins e Silva 58da0b15c3 docs(changelog): extend #1262 entry to cover #1289 MCP server companion
The same try/except split that #1262 applied at the backend layer
(ChromaBackend.get_collection) was needed at the parallel call site
in mcp_server._get_collection(create=True), which carries the same
metadata payload directly to chromadb's Python client. Both reopen
paths in mempalace now bypass get_or_create_collection on existing
collections, closing the SIGSEGV class for both tool_add_drawer
and tool_diary_write (the Stop hook's path).
2026-04-30 23:28:06 -03:00
Igor Lins e Silva 7a466054b6 Merge remote-tracking branch 'origin/develop' into chore/release-3.3.4-prep 2026-04-30 23:26:50 -03:00
Igor Lins e Silva 942f9ababa docs(changelog): bump 3.3.4 release date to 2026-04-30
The release was originally cut on 2026-04-27 but did not tag that day.
Three additional bug fixes have been folded in since then (#1262,
#1287, #1288) and the actual tag will happen on 2026-04-30. Update
the header date to match.
2026-04-30 22:58:51 -03:00
Igor Lins e Silva 95b60b8316 docs(changelog): add 3.3.4 entries for #1262, #1287, #1288
Three fixes landed on develop after the initial release-prep cut and
were brought in via the develop merge. Document them in the 3.3.4
Bug Fixes section so the release notes reflect what users will
actually receive.

- #1287 - HNSW divergence floor scales with hnsw:sync_threshold
  (resolves a silent-fallback regression introduced by the
  interaction between #1191 and #1227 in this release)
- #1262 - ChromaBackend get_or_create_collection split, fixing the
  stop-hook SIGSEGV class on legacy palaces with mismatched stored
  metadata (#1089)
- #1288 / #1254 - repair --mode max-seq-id heuristic now decodes
  BLOB-typed embeddings.seq_id, restoring the un-poison path added
  in #1135 for palaces where chromadb 1.5.x writes seq_ids natively
2026-04-30 22:57:19 -03:00
Igor Lins e Silva 3f254a5c31 Merge branch 'develop' into chore/release-3.3.4-prep 2026-04-30 22:54:58 -03:00
Igor Lins e Silva 5e540da06b Merge pull request #1289 from MemPalace/fix/mcp-server-collection-reopen-crash
fix(mcp_server): split get_or_create_collection on reopen (follow-up to #1262)
2026-04-30 22:41:24 -03:00
Igor Lins e Silva 9dd56ecb0a fix(mcp_server): split get_or_create_collection on reopen (follow-up to #1262)
#1262 split `get_or_create_collection` into `get_collection` + fallback
`create_collection` inside `ChromaBackend.get_collection`, fixing the
chromadb 1.5.x Rust-binding SIGSEGV that fires when stored collection
metadata differs from the call-site's `_HNSW_BLOAT_GUARD` payload.

The MCP server's `_get_collection(create=True)` carries the same metadata
payload at `mcp_server.py:287` and routes through chromadb's Python
client directly, bypassing the backend layer. Both `tool_add_drawer`
and `tool_diary_write` reach this site on every invocation, and the
Stop hook fires `mempalace_diary_write` at session end — which was
exactly the crash path #1089 named.

Apply the same try/except split here so legacy palaces whose stored
metadata predates the bloat-guard expansion no longer crash on the
MCP-server reopen path. Regression test patches
`get_or_create_collection` at the chromadb client class level (not the
instance — chromadb's mtime-change detection rebuilds the client between
calls, so an instance-level spy doesn't survive) and asserts the second
`_get_collection(create=True)` call never reaches it.
2026-04-30 22:35:18 -03:00
Igor Lins e Silva 73541d1606 Merge pull request #1262 from Legion345/fix/stop-hook-crash
fix(storage): stop ChromaDB from crashing when reopening an existing …
2026-04-30 22:30:08 -03:00
Igor Lins e Silva 96bb80a356 Merge pull request #1287 from messelink/fix/hnsw-divergence-scales-with-sync-threshold
fix(repair): scale HNSW divergence floor with hnsw:sync_threshold
2026-04-30 22:28:07 -03:00
Igor Lins e Silva 7bc6090026 Merge pull request #1288 from MemPalace/fix/repair-max-seq-id-blob-heuristic
fix(repair): decode BLOB embeddings.seq_id in max-seq-id heuristic (#1254)
2026-04-30 22:23:21 -03:00
Igor Lins e Silva 3b5ebcc9fc fix(repair): decode BLOB embeddings.seq_id in max-seq-id heuristic (#1254)
`_compute_heuristic_seq_id` ran `int(row[0])` directly on the result
of `MAX(e.seq_id)`. On palaces where chromadb 1.5.x has been writing
seq_ids natively (8-byte big-endian uint64 BLOB), that raises
`ValueError: invalid literal for int() with base 10: b'...'` before
the dry-run can print, leaving users with no path through the
recovery feature added in #1135 — the only documented un-poison
route for palaces hit by the original PR #664 shim bug.

Decode BLOB return values via `int.from_bytes(val, "big")` and
keep the existing `int(val)` path for INTEGER rows. Regression
test seeds a BLOB row in `embeddings.seq_id` and asserts the
heuristic surfaces the correct integer.
2026-04-30 22:04:41 -03:00
Pim MesselinkandClaude Opus 4.7 4a0f330cc1 fix(repair): scale HNSW divergence floor with hnsw:sync_threshold
The capacity probe added in #1227 hardcoded a 2,000-row floor for the
"diverged" decision. The comment justifying that number explicitly tied
it to chromadb's *default* sync_threshold of 1,000 — "Two synchronization
windows worth (2 × sync_threshold = 2000) is a safe steady-state ceiling".

#1191 then bumped sync_threshold to 50,000 via _HNSW_BLOAT_GUARD without
updating the floor. Result: any palace created with the bloat guard
flips between OK and DIVERGED on every flush cycle. Steady-state
divergence sits at 0–50K (the natural queue depth), and the 2,000 floor
trips the guardrail the moment the queue exceeds 10% of sqlite_count.
The MCP server then routes search to BM25-only and disables duplicate
detection for ~80% of the write cycle on actively-mined ≥100K palaces,
even though chromadb is behaving correctly.

This change reads the configured `hnsw:sync_threshold` from
`collection_metadata` per palace and scales the floor to 2 × that value.
The 10% relative term and the original #1222 detection capability are
unchanged — a 91%-missing-of-192K palace (the actual #1222 reproducer)
still trips, regardless of whether the collection was created with
sync_threshold=1000 or 50000.

Behavior summary:

| Collection's sync_threshold | New floor | Old floor |
|---|---|---|
| Missing (legacy palace)     | 2000      | 2000 (unchanged)
| 1000 (chromadb default)     | 2000      | 2000 (unchanged)
| 50000 (#1191 bloat guard)   | 100000    | 2000 (the bug)

Tests:
- test_capacity_status_tolerates_lag_under_large_sync_threshold (regression
  for the #1191/#1227 conflict — 100K sqlite + 50K HNSW + sync=50K → OK)
- test_capacity_status_still_flags_real_corruption_under_large_sync (#1222
  shape with bloat-guard collection — still detects corruption)
- test_capacity_status_default_threshold_when_no_sync_metadata (legacy
  palaces without the metadata row use the 2000 fallback floor)
- test_unflushed_path_also_uses_dynamic_floor (the never-flushed branch
  scales too — 30K under sync_threshold=50000 is no longer flagged)

All 18 pre-existing tests in tests/test_hnsw_capacity.py and 45 tests
in tests/test_backends.py still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:31:47 +00:00
Arnold Wender abe85763d4 fix(kg): reject partial ISO dates to avoid silent empty result sets
Per qodo-ai review on PR #1167: sanitize_iso_date() previously accepted
YYYY and YYYY-MM, but KnowledgeGraph.query_entity() compares valid_from/
valid_to TEXT columns lexicographically against as_of. Lexicographic
comparison treats '2026-01-01' as greater than '2026' (because '-' >
end-of-string), so partial as_of values silently excluded valid facts —
re-introducing the silent-empty-results problem this PR was meant to
fix.

Tighten _ISO_DATE_RE to require YYYY-MM-DD only. Update docstring and
error message accordingly. Invert the two test cases that asserted
partials were accepted.
2026-04-30 15:21:18 +02:00
Arnold Wender 4d98b05240 fix(kg): validate ISO-8601 date formats at MCP boundary
tool_kg_query (as_of), tool_kg_add (valid_from), and tool_kg_invalidate
(ended) accepted any string and forwarded it to SQLite without format
validation. Parameterized queries prevent SQL injection, but invalid
date strings silently produce empty result sets — callers cannot
distinguish "no fact at this time" from "your date format was
unrecognized." This is especially painful for natural-language LLM
callers that synthesize dates like "March 2026" or "Jan 2025".

Add sanitize_iso_date() in config.py alongside the other input
validators. It accepts YYYY, YYYY-MM, and YYYY-MM-DD forms; passes
through None/empty; and raises ValueError with a field-named message
on anything else. Call it from the three kg MCP tool wrappers before
values reach the storage layer so the caller gets a clear error
instead of a silent miss.

Closes #1164
2026-04-30 15:21:17 +02:00
sha2fiddy db28bf1e84 fix: paginate closet_llm col.get (#1073)
Mirror the pagination pattern PR #851 landed in miner.py:status().
A single drawers_col.get(limit=total, ...) on palaces larger than
SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) crashes inside chromadb.

Fetch drawers in batch_size=5000 chunks, stepping offset until the
collection is drained. by_source aggregation semantics are preserved
exactly — grouping, wing filter, meta capture all unchanged.

Closes #1073. Related: #802, #850, #1016.
2026-04-29 19:01:54 -04:00
Igor Lins e Silva 37f373124e style: ruff format three test files 2026-04-28 22:18:22 -03:00
Igor Lins e Silva d7ca3c59a3 docs(changelog): drop #1232 self-reference from auto-ingest entry
The PR documenting the fix is #1232; referencing it from inside its
own changelog entry is circular.
2026-04-28 21:53:28 -03:00
Igor Lins e Silva 446795ab0c docs(changelog): drop duplicate max_seq_id entry from shipped 3.3.0
The fix landed this cycle and is documented under 3.3.4. The 3.3.0
Bug Fixes block is shipped history and shouldn't grow new entries
retroactively.
2026-04-28 21:52:49 -03:00
copilot-swe-agent[bot]andigorls 4cfbd60639 fix: address release review feedback
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/01a1089d-da46-4dc8-85e8-d7e50763dd58

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-28 23:23:18 +00:00
Legion345 d7f4638157 fix(storage): stop ChromaDB from crashing when reopening an existing palace 2026-04-28 13:08:04 -07:00
Igor Lins e Silva fdfaf017ab Merge pull request #1234 from MemPalace/feat/normalize-gemini-cli
feat(normalize): Gemini CLI session JSONL adapter
2026-04-27 20:42:06 -03:00
copilot-swe-agent[bot]andigorls e7fe6cae14 fix(normalize): discard user/gemini turns before session_metadata sentinel
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/4511e9aa-38e7-440e-a6f8-eda91e576f0f

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-27 21:41:48 +00:00
copilot-swe-agent[bot]andigorls a3e3691e86 docs(normalize): add Gemini CLI JSONL to module-level supported formats list
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/a32f48bb-2a78-494a-9698-e69304732d3f

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-27 19:00:18 +00:00
Igor Lins e Silva 4ffd0bd57a Merge pull request #1233 from MemPalace/feat/privacy-consent-prompt
feat(privacy): blocking consent gate for env-fallback LLM API keys
2026-04-27 15:54:11 -03:00
MSL f4440f1ce0 feat(normalize): Gemini CLI session JSONL adapter
Adds a fifth format adapter to mempalace.normalize alongside the
existing Claude Code, Codex, Claude.ai, ChatGPT, and Slack parsers.
After this lands, mempalace mine --mode convos ingests Gemini CLI
session history without manual export.

Why now: Claude Code and Codex CLI are already supported by convo_miner;
adding Gemini closes the major-CLI-tool coverage gap. After this lands,
the README's "verbatim conversation history" promise is honestly
delivered for all three top-tier API-keyed coding CLIs (Claude Code,
Codex CLI, Gemini CLI), not just two of them. This is the third leg
of the trio Aya pushed for so the public claim matches the actual
ingest pipeline.

Gemini CLI stores sessions at ~/.gemini/tmp/<project_hash>/chats/ as
JSONL. The on-disk schema (per google-gemini/gemini-cli#15292):

    {"type":"session_metadata","sessionId":"...","projectHash":"...",...}
    {"type":"user","id":"msg1","content":[{"text":"Hello"}]}
    {"type":"gemini","id":"msg2","content":[{"text":"Hi"}]}
    {"type":"message_update","id":"msg2","tokens":{"input":10,"output":5}}

The new _try_gemini_jsonl parser:

  - requires a session_metadata record so it does not false-positive
    against Claude Code or Codex JSONL passing through the dispatch
    chain in _try_normalize_json
  - extracts user/gemini message text from each entry's content array
    of {"text": "..."} blocks, joining multiple blocks per message
    in order
  - skips message_update entries (token-count deltas with no message
    text) and any other unknown record types
  - returns None when fewer than two conversational messages are
    present, mirroring the codex parser's >=2-message guard

Test coverage: 9 new unit tests in tests/test_normalize.py mirroring
the codex test pattern - happy path, multi-turn, missing session
metadata, message_update skip, single-message rejection, multi-block
content concatenation, empty content skip, malformed-line resilience,
and explicit no-match against codex JSONL fixtures. Schema-level only;
real Gemini CLI session fixtures are a follow-up once a real user file
is available.

Closes part of #59 (the Gemini CLI portion of the umbrella request).
2026-04-27 01:25:03 -07:00
MSL 72cbfb5967 feat(privacy): blocking consent gate for env-fallback LLM API keys
Adds api_key_source provenance ('flag' | 'env' | None) to LLMProvider
so cmd_init can distinguish a key passed via --llm-api-key (explicit
opt-in) from one silently picked up via OPENAI_API_KEY / ANTHROPIC_API_KEY
shell env (stray credential).

When the endpoint is external AND api_key_source == 'env', init now
prints a blocking [y/N] prompt before any data is sent. Anything other
than 'y' drops the LLM and falls back to heuristics-only.

Adds --accept-external-llm flag for CI / non-interactive bypass.

Completes the UX gap in #1224: the URL-based warning was informational
and init kept running, so a user who didn't notice the line had already
leaked. The consent prompt is the actual gate; explicit flag-passed keys
remain treated as already-consented.
2026-04-27 00:44:57 -07:00
Igor Lins e Silva 7a61ac75a4 chore(release): v3.3.4
Bumps every version source from 3.3.3 to 3.3.4:
- pyproject.toml
- mempalace/version.py (canonical)
- .claude-plugin/plugin.json
- .claude-plugin/marketplace.json
- .codex-plugin/plugin.json
- README.md badge

Dates the CHANGELOG section and adds entries for the bug fixes that
landed this cycle (#1135, #1191, #1230, #1231) plus expands the #1194
entry to credit the lookup-side recovery path from #1197.

Pre-tag verification:
- 1441 passed, 1 skipped (full suite minus benchmarks, all platforms)
- ruff check + format clean
- 44/44 in test_version_consistency + test_readme_claims (6-file sync)
- JPH invariant: pyproject.toml + .claude-plugin/plugin.json both
  reference mempalace-mcp
- Wheel build + fresh-venv install: mempalace --version reports 3.3.4,
  mempalace-mcp --help works (catches the v3.3.2-class regression)
2026-04-27 03:46:25 -03:00
Igor Lins e Silva de7801ecff Merge pull request #1191 from funguf/fix/hnsw-index-bloat-rebased
fix: prevent HNSW index bloat from resize+persist cycles
2026-04-27 03:37:57 -03:00
Igor Lins e Silva 003e569b39 Merge pull request #1135 from sha2fiddy/feature/max-seq-id-shim-fix
fix: narrow `_fix_blob_seq_ids` + add `repair --mode max-seq-id`
2026-04-27 03:21:49 -03:00
Igor Lins e Silva c3ec708b12 Merge pull request #1197 from wahajahmed010/fix/1194-hyphenated-wing-tunnels
fix(tunnels): normalize wing names in topic tunnel lookup for hyphenated dirs
2026-04-27 03:21:16 -03:00
Igor Lins e Silva f80c9ffa56 Merge pull request #1195 from MemPalace/fix/wing-name-normalization-tunnels
fix(graph): normalize wing slug at init so topic tunnels fire for hyphenated dirs (#1194)
2026-04-27 03:20:46 -03:00
igorls 342270d6e5 fix(palace_graph): defer annotation eval for Python 3.9 compat
``def _normalize_wing(wing: str | None) -> str | None`` uses PEP 604
union syntax which requires Python 3.10+ at runtime. The project still
declares ``python_requires=">=3.9"`` and CI runs the test-linux (3.9)
matrix, where every test in ``tests/test_palace_graph*`` errors out
before collection with ``TypeError: unsupported operand type(s) for |``.

Added ``from __future__ import annotations`` so all annotations in
this module are evaluated lazily as strings — the union syntax is then
accepted on 3.9 without needing to rewrite to ``Optional[str]``.

Surfaced after rebasing this PR onto current develop.
2026-04-27 03:15:09 -03:00
igorls cfca40c5ec test(cli): mock _run_pass_zero so wing-name test survives corpus-origin
cmd_init now invokes ``_run_pass_zero`` unconditionally (#1221, #1223
landed on develop after this PR's branch point). The pass reads sample
content via ``builtins.open``; with that mocked to MagicMock, the
downstream ``"\\n\\n".join(samples)`` in
``corpus_origin.detect_origin_heuristic`` raises
``TypeError: expected str instance, MagicMock found``.

This test only cares about the wing-slug write to the registry, so
stub the pass-zero call directly rather than try to satisfy its full
sample-gathering contract.
2026-04-27 03:14:02 -03:00
Igor Lins e SilvaandClaude Opus 4.7 3bebef1503 fix(miner,convo_miner): close remaining wing-name normalization gaps (#1194)
Two follow-ups against the review on this PR:

1. ``miner.load_config`` no-yaml fallback was returning the raw dirname
   as the wing, while ``cmd_init`` writes ``topics_by_wing`` under the
   normalized slug. A hyphenated project mined without a ``mempalace.yaml``
   file silently lost every topic tunnel — same key-miss class as #1194,
   just down the no-yaml branch (raised by Qodo on this PR).

2. ``convo_miner`` was applying the lower/replace rule inline at one
   call site. Now folded through ``normalize_wing_name`` so all wing-slug
   producers — ``cmd_init``, ``room_detector_local``, ``miner.load_config``
   fallback, ``convo_miner`` — share a single source of truth. No
   behavior change for any input; pure consolidation.

Added ``test_load_config_no_yaml_normalizes_hyphenated_wing`` to lock
the fallback path to the normalized slug — fails on develop without
the miner change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 03:12:06 -03:00
bensigandClaude Opus 4.7 b7f0a8af01 fix(graph): normalize wing slug at init so topic tunnels fire for hyphenated dirs (#1194)
`init` was recording `topics_by_wing[<raw-dirname>]` while `mempalace.yaml`
got the lower-cased separator-collapsed slug. At mine time the miner
read the slug from the yaml and missed the registry key, so
`_compute_topic_tunnels_for_wing` returned 0 silently for every project
whose folder contained a `-` or a space — the most common shape in the
wild.

Extracted the rule into `config.normalize_wing_name()` and routed both
`cli.cmd_init` (registry write) and `room_detector_local.detect_rooms_local`
(yaml write) through it. Added a regression test in `test_cli.py`
asserting the registry call uses the normalized slug, plus four direct
unit tests for the helper.

Refs #1180.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 03:12:06 -03:00
Wahaj Ahmed 347464146d fix(tunnels): normalize wing names in topic tunnel lookup for hyphenated dirs (#1194) 2026-04-27 03:05:14 -03:00
igorls 04c48dd0fe fix(chroma): write blob-fix marker even when narrowing skips all rows
The narrowed _fix_blob_seq_ids returned early when safe_rows was empty,
but #1177's marker contract requires the marker to be written on every
successful pass — even no-op — so subsequent opens skip the sqlite3
connection entirely. Without this, palaces that have no genuine 0.6.x
BLOBs but DO have sysdb-10-prefixed rows would re-open sqlite3 on every
call, defeating the #1090 corruption guard.

Restructured the conditional so the marker write is unconditional after
a successful sqlite scan, regardless of whether any rows were updated.

Surfaced by test_fix_blob_seq_ids_writes_marker_when_already_integer
during the develop-rebase of this PR. The author's branch predates the
marker contract from #1177 (merged 2026-04-26), so this is a rebase-edge
fix-up rather than a logic change to their narrowing behaviour.
2026-04-27 03:01:41 -03:00
eblander f5c8b095dd fix: narrow _fix_blob_seq_ids shim + add repair --mode max-seq-id
The BLOB-seq_id migration shim (PR #664) ran int.from_bytes(..., 'big')
over every BLOB in max_seq_id, including chromadb 1.5.x's own native
format (b'\x11\x11' + 6 ASCII digits). That conversion yields a ~1.23e18
integer that silently suppresses every subsequent embeddings_queue write
for the affected segment (queue filter is seq_id > start), causing
silent drawer-write drops after a 1.5.x upgrade.

Two-part fix:

1. Shim narrowing (mempalace/backends/chroma.py)
   - Drop max_seq_id from the shim loop. chromadb owns that column's
     format; we don't reinterpret it.
   - Defense-in-depth: skip rows in embeddings whose seq_id BLOB has the
     sysdb-10 b'\x11\x11' prefix rather than misconvert.

2. Recovery command (mempalace/repair.py, mempalace/cli.py)
   - mempalace repair --mode max-seq-id [--segment <uuid>]
     [--from-sidecar <path>] [--dry-run] [--yes] [--no-backup]
   - Detects poisoned rows via threshold (seq_id > 2**53).
   - Default heuristic: MAX(embeddings.seq_id) over the collection owning
     the poisoned segment. Matches METADATA max exactly; VECTOR segments
     get a few seq_ids ahead (queue skips an already-indexed window — an
     acceptable loss vs. resetting to 0 and re-processing everything).
   - --from-sidecar copies clean values from a pre-corruption sqlite db.
   - Backs up chroma.sqlite3, closes chroma handles, atomic UPDATEs,
     post-repair verification that raises MaxSeqIdVerificationError if
     any row is still above threshold.

Tests: 8 new in tests/test_repair.py (detection, heuristic, sidecar,
dry-run, segment filter, no-op, backup, rollback-on-verify-failure).
3 new in tests/test_backends.py (max_seq_id untouched by shim,
sysdb-10 prefix skipped in embeddings, legacy big-endian u64 BLOBs
still convert). Full suite: 1103 passed.
2026-04-27 02:57:01 -03:00
Fergus Chingandrobot-rocket-science 88a53b2ffa fix: prevent HNSW index bloat via batch_size + sync_threshold metadata
Sets `hnsw:batch_size` and `hnsw:sync_threshold` to 50_000 at every
collection-creation call site:

* `mempalace/backends/chroma.py` — `get_collection(create=True)` and the
  legacy `create_collection()` path. Preserves existing `hnsw:space`,
  `hnsw:num_threads=1` (race fix from #976), and `**ef_kwargs`
  (embedding-function plumbing from a4868a3).
* `mempalace/mcp_server.py` — the direct `client.get_or_create_collection`
  path used when a palace is first opened by the MCP server. Without this
  third site, MCP-bootstrapped palaces would skip the guard and could
  still trigger the original bloat.

Without these defaults, mining ~10K+ drawers triggers ~30 HNSW index
resizes and hundreds of persistDirty() calls. persistDirty uses relative
seek positioning in link_lists.bin; accumulated seek drift across resize
cycles causes the OS to extend the sparse file with zero-filled regions,
each cycle compounding the next. Result: link_lists.bin grows into
hundreds of GB sparse, after which `status`, `search`, and `repair` all
segfault and the palace is unrecoverable.

Empirical: rebuilt a palace from scratch on 39,792 drawers across 5
wings with this fix applied. Final palace 376 MB, link_lists.bin stays
at 0 bytes across both Chroma collection dirs, status and search both
return cleanly. Same workload without the fix bloated the palace to
565 GB sparse (30 GB on disk) and segfaulted at ~15K drawers.

Migration note: chromadb 1.5.x exposes a
`collection.modify(configuration={"hnsw": {...}})` retrofit path for
already-created collections (`UpdateHNSWConfiguration`), but this PR
doesn't pursue it — by the time link_lists.bin has bloated the index
is already corrupt and the only known recovery is a fresh mine.

Tests assert both keys land on the persisted collection metadata in
both `ChromaBackend` code paths, which also covers the #1161 "config
silently dropped" concern at CI time. A separate smoke test was used
to verify the metadata round-trips through `chromadb.PersistentClient`
reopen on chromadb 1.5.8.

Closes #344
Supersedes #346

Co-authored-by: robot-rocket-science <robot-rocket-science@users.noreply.github.com>
2026-04-27 02:54:19 -03:00
Igor Lins e Silva bc5d3fa911 Merge pull request #1231 from MemPalace/fix/hooks-convos-additive-mining
fix(hooks): always mine the active transcript as convos, additive to MEMPAL_DIR
2026-04-27 02:49:54 -03:00
Igor Lins e Silva 3deebfed19 test(hooks): skip bash subprocess validator test on Windows
`bash` on the Windows CI runner resolves to `wsl.exe` which fails with
"Windows Subsystem for Linux has no installed distributions." The shell
hooks themselves are POSIX-only — Windows users use the Python entry
point — so the bash-subprocess exercise is non-applicable on win32.

The static-grep validator tests still run on every platform, so the
shell-side validation is still asserted under Windows; only the live
bash invocation is skipped.
2026-04-27 02:45:04 -03:00
Igor Lins e Silva fe56797762 fix(hooks): consolidate transcript ingest, harden shell parsers (#1231 review)
Address Copilot review on #1231:

1. Stop double-mining the transcript on the Python side. ``_get_mine_targets``
   now returns only the ``MEMPAL_DIR`` projects target — the convos target
   for the transcript dir is dropped because ``_ingest_transcript`` already
   handles it on every hook fire. The duplicate spawn was using
   ``sys.executable`` (vs ``_mempalace_python()``) and a different ``--wing``,
   so each Stop/PreCompact event was writing the same transcript into two
   wings under asymmetric interpreters and overwriting the single
   ``_MINE_PID_FILE`` lock.

2. ``_maybe_auto_ingest`` and ``_mine_sync`` now spawn via
   ``_mempalace_python()`` so the resolved interpreter matches the venv
   that owns mempalace (matters under GUI-launched harnesses where
   ``sys.executable`` may resolve to a system Python without chromadb).

3. Replace ``eval $(...)`` in both shell hooks with a ``mapfile``-based
   reader. Sanitized values are still emitted by the same Python parser,
   but the shell now does plain variable assignment instead of executing
   the parser's stdout — smaller blast radius if the sanitizer is ever
   bypassed.

4. Mirror ``_validate_transcript_path`` in the shell hooks via a
   ``is_valid_transcript_path`` helper — extension + traversal-segment
   rejection, parity with the Python validator. The convos mine in each
   shell hook is now gated on the validator instead of bare ``-f``.

5. Tighten the ``..`` traversal test that previously exercised the
   suffix gate by mistake (``../../etc/passwd`` lacks ``.json[l]``).
   Use ``.jsonl`` paths with traversal segments to actually hit the
   ``..`` rejection branch.

6. README: add a one-liner pointing at ``mempalace sweep`` for users
   who want per-message recall on top of the file-level chunks the
   hooks produce. The sweeper was undiscoverable previously.

Tests: 1418 passed, 1 skipped (full suite minus benchmarks).
2026-04-27 02:26:53 -03:00
Igor Lins e Silva 8bb17724a7 Merge pull request #1230 from MemPalace/fix/hooks-convos-mode-1232
fix(hooks): pass --mode convos when mining Claude Code transcript dirs
2026-04-27 02:05:38 -03:00
imtylervo f30fdf2672 fix: serialize ChromaCollection writes through palace lock
#976 protects `mempalace mine`, but MCP/direct backend writers still call
ChromaCollection.add/upsert/update/delete without the palace lock. This
moves the lock boundary to the Chroma backend seam so all Chroma writes
share the same palace-level serialization, with a re-entrant guard for
miner paths that already hold the lock.

mine_palace_lock(palace_path) gains a per-thread re-entrant guard
(threading.local + pid-tag against fork inheritance) so
ChromaCollection write methods can take the lock without
self-deadlocking when called from inside miner.mine()'s outer hold.

ChromaCollection.__init__ accepts an optional palace_path; when set,
add/upsert/update/delete wrap their underlying chromadb call with
mine_palace_lock(palace_path). palace_path=None preserves the legacy
no-lock behaviour for direct callers and tests. ChromaBackend's
get_collection/create_collection pass palace_path through;
mcp_server._get_collection forwards _config.palace_path so all MCP
write tools inherit the wrapping.

Tests: 5 new in tests/test_chroma_collection_lock.py covering opt-in,
writer-blocks-during-mine, re-entrant-inside-mine, two-process
serialization, and a source-level read-path-not-locked pin. Plus 1 new
+ 1 rewritten in tests/test_palace_locks.py for the re-entrant
semantics. 52 passed in 1.01s including the existing test_backends.py
regression suite.

Refs #1161.
2026-04-27 14:16:20 +10:00
Igor Lins e Silva eb4de04339 fix(hooks): always mine the active transcript as convos, additive to MEMPAL_DIR
#1230 fixed --mode convos for the case where MEMPAL_DIR was unset, but
left two configurations broken:

  - MEMPAL_DIR set to a project dir: convos never mined (MEMPAL_DIR
    overrode the transcript path); only project files were ingested.
  - MEMPAL_DIR set to a conversations dir per the old hooks/README: the
    projects miner ran on JSONL — same wrong-miner behaviour.

The shell hooks (mempal_save_hook.sh, mempal_precompact_hook.sh) had
the same MEMPAL_DIR-overrides-transcript bug AND were missing --mode
on every spawned `mempalace mine` call.

Make the auto-ingest *additive*. _get_mine_dir → _get_mine_targets,
returning a list of (dir, mode) pairs:

  - MEMPAL_DIR (when valid) contributes (dir, "projects")
  - A valid transcript JSONL contributes (parent, "convos")
  - Both can appear together; the hook spawns one ingest per target

Same change applied to the shell save and precompact hooks. Precompact
also gained transcript_path parsing so it can run the convos mine
synchronously before context is compressed. hooks/README.md updated to
describe MEMPAL_DIR as a project-files target, never a convos target.
2026-04-27 00:32:35 -03:00
copilot-swe-agent[bot]andigorls 6a8beef604 fix(hooks): harden _get_mine_dir path validation
- Normalize MEMPAL_DIR via Path.expanduser().resolve() so ~/proj paths
  are correctly accepted instead of falling through to transcript fallback
- Replace bare Path.expanduser().is_file() transcript check with the
  existing _validate_transcript_path() which adds .resolve(), enforces
  .jsonl/.json extension, and rejects '..' path-traversal components
- Update tests to compare resolved paths (cross-platform correctness)
- Add tests for tilde expansion, path-traversal rejection, and
  non-jsonl extension rejection in _get_mine_dir

Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/f69176c7-d752-40ef-ba71-d0e4adc3a689

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-27 02:40:01 +00:00
Igor Lins e Silva 1e3e89a78f fix(hooks): pass --mode convos when mining a Claude Code transcript dir
The Stop and PreCompact hooks spawn `mempalace mine <dir>` with no
`--mode` flag, which defaults to `projects` in cli.py. When MEMPAL_DIR
is unset, _get_mine_dir falls back to the parent of the transcript
JSONL — and miner.py's READABLE_EXTENSIONS includes `.jsonl`, so the
projects miner happily ingests Claude Code session JSONL as if it were
source code instead of conversation.

Make _get_mine_dir return (dir, mode): MEMPAL_DIR keeps `projects`,
the JSONL fallback yields `convos`. Both _maybe_auto_ingest and
_mine_sync now thread the mode into the spawned command.
2026-04-26 23:25:12 -03:00
Igor Lins e Silva 9dbb4ced83 Merge pull request #1227 from MemPalace/fix/hnsw-capacity-divergence-1222
fix(repair): detect HNSW capacity divergence and fall back to BM25 (#1222)
2026-04-26 21:58:57 -03:00
Igor Lins e Silva 57ac669dbc fix(repair): address Copilot review on #1227
Five Copilot review issues + the Python 3.9 CI failure rolled into one
follow-up:

* Replace ``dict | None`` annotated assignment with a type-comment so
  module load doesn't evaluate PEP 604 syntax on Python 3.9 (CI red).
* Drop ``mempalace repair rebuild`` — the CLI only ships ``mempalace
  repair`` (rebuild) and ``mempalace repair-status``. Updated all
  user-facing messages, docstrings, and test assertions.
* Replace ``_get_client()`` in ``tool_search`` with the safe
  ``_refresh_vector_disabled_flag`` probe so the fallback isn't
  defeated by the very chromadb client load it's trying to avoid.
* Short-circuit ``tool_status`` to a pure-sqlite reader
  (``_tool_status_via_sqlite``) when divergence is detected so wing /
  room counts come back without ever opening the persistent client.
* Wrap the recency-window query in ``_bm25_only_via_sqlite`` with an
  ``id``-ordered fallback so legacy schemas missing ``created_at``
  don't break BM25 search.

New test covers the sqlite-status short-circuit. 1409 tests pass.
2026-04-26 21:53:56 -03:00
Igor Lins e Silva 0d349c3d86 fix(repair): detect HNSW capacity divergence and fall back to BM25 (#1222)
When chromadb's HNSW segment freezes at a stale max_elements while
sqlite keeps accumulating embeddings, the next chromadb open segfaults
the MCP server on every tool call. Adds a pure-filesystem capacity probe
(zero chromadb interaction), a `mempalace repair-status` read-only
health check, and a BM25-only sqlite fallback so the palace stays
reachable even when vector search is unavailable.

* `hnsw_capacity_status` reads sqlite + index_metadata.pickle directly
  via a tight-allowlist unpickler — no hnswlib import, no segment load.
* MCP server runs the probe at startup and after every reconnect; sets
  `_vector_disabled` and routes search to the sqlite FTS5 + BM25 path.
* `tool_status` and `tool_reconnect` surface the fallback state.
* Threshold tuned for chromadb 1.5.x async-flush lag (2× sync_threshold).
2026-04-26 19:54:00 -03:00
Igor Lins e Silva 899a5ec4c6 Merge pull request #1225 from MemPalace/feat/privacy-warn-tailscale-cgnat
feat(privacy): treat Tailscale CGNAT range (100.64.0.0/10) as local
2026-04-26 19:40:58 -03:00
Igor Lins e Silva 22f3d9be30 Merge pull request #1223 from MemPalace/chore/corpus-origin-merge-followup
chore(corpus-origin): tag merged evidence by tier + pin confidence-source contract
2026-04-26 19:39:01 -03:00
MSL a0b7ba005d feat(privacy): treat Tailscale CGNAT range (100.64.0.0/10) as local
2 files changed, 60 insertions, 0 deletions. 2 new tests (RED-first).

Follow-up to #1224's privacy warning. The URL-based heuristic in
``mempalace.llm_client._endpoint_is_local`` shipped without recognizing
Tailscale's CGNAT range (100.64.0.0/10), so a user running LM Studio,
Ollama, or any local LLM accessible via a Tailscale-assigned 100.x.x.x
address would currently get a wrong privacy warning — Tailscale
addresses are network-private (only reachable inside the user's
Tailnet) but they're not RFC1918, so the heuristic was treating them
as external.

This PR adds CGNAT recognition: when the hostname starts with ``100.``
AND the second octet is between 64 and 127 inclusive, it's classified
as local. Addresses in 100.x.x.x outside that range (i.e. second octet
< 64 or > 127) are regular allocated public space and remain external,
so a user pointing at a public 100.0.0.1 still gets the warning.

Concrete user impact:

  Before: ``mempalace init --llm-provider openai-compat --llm-endpoint http://100.100.50.50:1234``
          (LM Studio on Tailnet) → triggers privacy warning incorrectly.

  After:  same command → no warning. data stays inside the user's
          Tailnet, which is what the warning is supposed to protect against.

TDD: 2 tests added in ``tests/test_llm_client.py``, both RED-first.

1. ``test_openai_compat_provider_tailscale_cgnat_endpoint_is_local``
   — covers three Tailscale CGNAT addresses (start, middle, near-end
   of the range) and pins they're all classified local. This was the
   RED that drove the implementation.

2. ``test_openai_compat_provider_outside_tailscale_cgnat_is_external``
   — pins the boundary on both sides: addresses with second octet 0-63
   and 128-255 stay external. Prevents future "treat all 100.x.x.x as
   local" overcorrection.

Tests: 1388 total mempalace tests pass. 2 pre-existing environmental
failures unrelated to this change (chromadb optional dep). Ruff check
+ format both clean.

Backwards compatible: only widens the local-recognition set. Anything
classified local before is still classified local; anything classified
external before remains so unless it's specifically in the CGNAT range.

Out of scope (tracked for future iteration based on real user feedback,
not built speculatively): pre-init confirmation prompt before sending
to external API, persistent ``private-only`` config flag that refuses
external endpoints entirely, explicit cloud-provider name detection
("Using Anthropic's hosted API at ..." vs the current generic warning).
2026-04-26 15:31:44 -07:00
Igor Lins e Silva 5e33592ba2 chore(corpus-origin): address Copilot review on #1223
- cli.py: stringify each evidence entry exactly once before the
  startswith check (was calling str(e) twice per element).
- tests: replace brittle `confidence != 0.90` assertion with an
  equality check against detect_origin_heuristic on the same samples.
  The original would have spuriously fired if the heuristic ever
  legitimately produced 0.90 for these samples; the new form pins the
  contract directly.
2026-04-26 19:18:57 -03:00
Igor Lins e Silva c92256f08f chore(corpus-origin): tag merged evidence by tier + pin confidence-source contract
Two follow-ups to PR #1221's merge-fields behavior, both raised by the
Copilot review on that PR:

- Evidence merge now prefixes each entry with `Tier-1 heuristic: ` or
  `Tier-2 LLM: ` so the on-disk `origin.json` audit record retains tier
  provenance. The pre-#1221 code labeled heuristic evidence; the
  merge-fields refactor flattened that. Re-prefixing is idempotent.

- Tests now assert that the merged `confidence` is the heuristic's, not
  the LLM's. Added inline assertions to the two existing
  contradiction/disagreement tests, plus a dedicated
  `test_merge_tier_fields_confidence_matches_heuristic_call` that
  compares to `detect_origin_heuristic` directly so a future regression
  letting Tier 2 confidence leak through cannot pass silently.

Tests: 1378 pass. Ruff check + format both clean (CI-pinned 0.4.x).
2026-04-26 19:18:57 -03:00
Igor Lins e Silva 4d33f14988 Merge pull request #1224 from MemPalace/feat/privacy-warn-external-llm
feat(privacy): warn when LLM tier sends content to external API
2026-04-26 19:18:13 -03:00
MSL 4400734867 feat(privacy): warn when LLM tier sends content to external API
4 files changed, 248 insertions, 0 deletions. 7 new tests (4 unit + 3 integration), all RED-first.

Per @milla-jovovich's question to @igorls during PR #1221 review: users
running `mempalace init` with an external LLM provider (Anthropic API,
OpenAI hosted, etc.) need a clear, explicit warning that their folder
content will be sent to the provider, that MemPalace doesn't control
how the provider logs/retains/uses that data, and how to opt out.
@igorls confirmed this should be a small follow-up PR scoped to the
warning itself, before the v3.3.4 tag.

This PR adds:

- `_endpoint_is_local(url)` helper in `mempalace/llm_client.py` —
  URL-based heuristic returning True if the hostname is on the user's
  machine or private network. Covers: localhost, 127.0.0.1, ::1,
  hostnames ending in .local (mDNS/Bonjour), IPv4 RFC1918 ranges
  (10/8, 172.16-31/12, 192.168/16), and IPv6 unique-local addresses
  (fc00::/7).

- `is_external_service` property on the `LLMProvider` base class.
  Subclasses inherit; the URL determines (no provider-specific
  hardcoding). This means: Ollama on localhost = local. LM Studio on
  LAN = local. Anthropic with default `https://api.anthropic.com` =
  external. A user proxying Anthropic through localhost (advanced
  setup) = local, no false-positive warning.

- One-line warning print in `cmd_init` after successful provider
  acquisition, gated on `is_external_service`:

      ⚠ {provider_name} is an EXTERNAL API. Your folder content will be
      sent to the provider during init. MemPalace does not control how
      the provider logs, retains, or uses your data. Pass --no-llm to
      keep init fully local.

  The warning fires AFTER `LLM enabled: ...` so users see both that
  the LLM is engaged AND the privacy implications of where it lives,
  before Pass 0 / entity detection actually runs.

LOCAL providers (Ollama on localhost, LM Studio on localhost or LAN,
llama.cpp on localhost, vLLM on localhost) DO NOT trigger the warning —
nothing leaves the user's machine/network in those configurations.

TDD: 7 tests added across 2 files.

Unit tests in `tests/test_llm_client.py` (4 tests, all RED-first):

1. test_ollama_provider_default_endpoint_is_local — pins that the
   default `http://localhost:11434` is classified local.
2. test_openai_compat_provider_localhost_endpoint_is_local — covers
   the LM Studio / llama.cpp / vLLM common case (localhost,
   127.0.0.1, and 192.168.x LAN).
3. test_openai_compat_provider_cloud_endpoint_is_external — pins
   that pointing openai-compat at https://api.openai.com (or any
   non-local URL) classifies as external.
4. test_anthropic_provider_default_endpoint_is_external — pins that
   AnthropicProvider's default endpoint is external (the dominant
   user-facing case for `--llm-provider anthropic`).

Integration tests in `tests/test_corpus_origin_integration.py` (3 tests,
RED-first; 1 was the critical RED — the other 2 passed by accident
since nothing printed "EXTERNAL API" before this PR):

5. test_init_prints_privacy_warning_when_provider_is_external —
   captures stdout from cmd_init with a mocked external provider,
   asserts the warning text contains "EXTERNAL API" + "--no-llm" +
   language about MemPalace not controlling provider behavior.
6. test_init_no_privacy_warning_when_provider_is_local — same flow
   with a mocked local provider, asserts the warning text does NOT
   appear.
7. test_init_no_privacy_warning_with_no_llm_flag — pins the --no-llm
   path: no provider acquisition attempted, no warning fires.

Tests: 1382 total mempalace tests pass. 2 pre-existing environmental
failures unrelated to this change (chromadb optional dep). Ruff check +
format both clean.

Backwards compatible: `is_external_service` is a new property; existing
callers don't reference it. The warning is a new print statement that
fires only when an external endpoint is acquired. The `--no-llm` opt-out
existed before this PR and continues to work identically.

Out of scope for follow-up (deliberately not in this PR per Igor's
"small PR" guidance): Tailscale CGNAT (100.64.0.0/10) treatment,
pre-init confirmation prompt, persistent privacy-mode config flag,
explicit cloud-provider name detection. Tracked for future iteration.
2026-04-26 14:43:20 -07:00
Igor Lins e Silva 414aa3e20b Merge pull request #1198 from jphein/fix/tokenize-none-guard
fix(searcher): tolerate None documents in BM25 reranker
2026-04-26 18:41:07 -03:00
Igor Lins e Silva 30fc0ab865 Merge pull request #1201 from jphein/fix/palace-graph-none-metadata
fix(palace_graph): skip None metadata in build_graph
2026-04-26 18:37:52 -03:00
Igor Lins e Silva 025dd03047 Merge pull request #1177 from jphein/fix/blob-seq-marker-guard
fix: skip _fix_blob_seq_ids sqlite open on already-migrated palaces (#1090)
2026-04-26 18:28:55 -03:00
Igor Lins e Silva 0908786921 Merge pull request #1221 from MemPalace/feat/corpus-origin-merge-tier-fields
feat(corpus-origin): merge LLM fields into heuristic result instead of replacing
2026-04-26 18:27:51 -03:00
Igor Lins e Silva 8a1fd95b1b style: drop unused pathlib.Path imports in marker tests 2026-04-26 18:23:31 -03:00
Igor Lins e Silva 4aa93e82ad Merge pull request #1173 from jphein/fix/quarantine-on-make-client
fix: call quarantine_stale_hnsw() in make_client(); lower threshold to 5min
2026-04-26 18:22:14 -03:00
Igor Lins e Silva 43aa1aa24e style: ruff format under CI-pinned 0.4.x 2026-04-26 18:19:15 -03:00
MSL 7294b69ac8 feat(corpus-origin): merge LLM fields into heuristic result instead of replacing
2 files changed, 260 insertions, 7 deletions. 4 new tests (all RED-first).

Per @igorls's review of PR #1211 (https://github.com/MemPalace/mempalace/pull/1211#issuecomment-4322762236):
the corpus-origin Pass 0 currently lets a Tier 2 LLM result REPLACE the
heuristic result wholesale. With ``--llm`` default-on (since #1211) and a
small local model like Ollama gemma4:e4b, the LLM can return a wrong
``likely_ai_dialogue=False, confidence=0.90`` that overrides a confident
heuristic ``True``. Tier 2's persona/user/platform extraction is the whole
reason to run it; the YES/NO call should stay with the heuristic.

This PR changes ``_run_pass_zero`` in ``mempalace/cli.py`` to merge fields
instead of replacing:

  - ``likely_ai_dialogue``  → KEEP heuristic's (don't let weak LLM flip)
  - ``confidence``          → KEEP heuristic's (paired with the bool above)
  - ``primary_platform``    → TAKE LLM's when LLM provides one
  - ``user_name``           → TAKE LLM's when LLM provides one
  - ``agent_persona_names`` → TAKE LLM's when LLM provides any
  - ``evidence``            → COMBINE both signal trails

This preserves the persona-extraction value of Tier 2 (the whole point of
running it) while preventing a weak local model from flipping a confident
heuristic.

TDD: 4 tests added in tests/test_corpus_origin_integration.py covering
the four state combinations:

1. test_merge_tier_fields_heuristic_yes_llm_no_keeps_heuristic_bool —
   The exact failure mode Igor caught. Heuristic confidently flags
   AI-dialogue; mocked LLM contradicts. Asserts merged result keeps
   heuristic's True AND merges LLM's persona/user/platform fields.
   This test was the RED that drove the implementation.

2. test_merge_tier_fields_heuristic_no_no_personas_leak —
   Both tiers agree NOT AI-dialogue, both report empty personas. Pins
   that the merge doesn't accidentally introduce personas.

3. test_merge_tier_fields_heuristic_yes_llm_yes_combines_evidence —
   Both tiers agree AI-dialogue, LLM extracts personas. Pins that
   evidence from BOTH tiers ends up in the merged audit trail and
   persona/user/platform come from LLM.

4. test_merge_tier_fields_no_llm_provider_returns_heuristic_only —
   Backwards compat: with no LLM provider (``--no-llm`` path), the
   merge logic doesn't fire and behavior is identical to v3.3.4.

Tests: 1367 pass on the full mempalace suite. 2 pre-existing
environmental failures unrelated to this change (chromadb optional
dep). Ruff check + format both clean.
2026-04-26 13:23:38 -07:00
jpandClaude Opus 4.7 247744296d fix(blob-seq-marker): tests + style nit per @igorls #1177 review
Three new tests cover the marker contract:

- test_fix_blob_seq_ids_writes_marker_after_blob_path — marker is
  written after a successful BLOB → INTEGER conversion.
- test_fix_blob_seq_ids_writes_marker_when_already_integer — marker
  is written even on a no-op (already-INTEGER) path. The point of
  the marker is to skip sqlite3.connect() on subsequent calls, not
  to record that a conversion happened.
- test_fix_blob_seq_ids_skips_sqlite_when_marker_present — verifies
  the load-bearing property via monkeypatched sqlite3.connect: when
  the marker exists, the function never opens sqlite. This is the
  bug #1090 fix — opening Python's sqlite3 against a live ChromaDB
  1.5.x WAL DB corrupts the next PersistentClient call, and we
  must never do it again post-migration.

Also folded in @igorls's style nit:

- Path(marker).touch() instead of open(marker, "a").close().
  Same effect, reads cleaner. Moved Path import to the top of the
  module since it didn't yet exist there.

35/35 backend tests pass. The "Production: fresh MCP server + stop
hook + mempalace mine subprocess" checkbox in the PR body is checked
in the PR reply — the canonical 151K palace has been running this
fix since #1177 was filed (via Syncthing-replicated source on the
disks daemon at v1.7.0); zero PersistentClient corruption since.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:21:50 -07:00
jpandClaude Opus 4.7 942aae3ed5 fix(hnsw): address @igorls's #1173 review
Restore three `_pin_hnsw_threads` tests that the previous integrity-gate
commit deleted. The function is still live code on develop (defined at
chroma.py:207, called from chroma.py:705 + mcp_server.py), so the
deletion left the import unused (ruff F401) and dropped coverage on a
function unrelated to this PR's scope. Restored verbatim from main.

Plus three nits @igorls flagged:

- **Thread-safety doc**: `_quarantined_paths` mutation is lock-free;
  documented that idempotency of `quarantine_stale_hnsw` is the safety
  property (concurrent same-palace calls produce a benign redundant
  rename attempt that no-ops, no need for a lock).
- **Pickle protocol assumption**: `_segment_appears_healthy` requires
  PROTO ≥ 2 (`0x80`). Documented; matches what chromadb writes today,
  and a future protocol-0/1 emission would conservatively quarantine
  + lazy-rebuild rather than mis-classify as healthy.
- Class-level vs module-level scope: keeping class-level — the
  conftest reset is the controlled case, and module-level wouldn't
  remove the foot-gun, just relocate it. Conftest reset documented in
  the existing comment is the right pattern for test isolation.

Style nit (`Path(marker).touch()` vs `open(marker, "a").close()`)
deferred — that pattern lives in #1177's territory, not #1173's.

37/37 tests pass on the PR branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:20:14 -07:00
Igor Lins e Silva 9d18a1c32e Merge pull request #1211 from MemPalace/feat/corpus-origin
feat(init): context-aware corpus detection
2026-04-26 16:53:36 -03:00
MSL b99e54546b feat(init): context-aware corpus detection
10 files changed. 2,563 insertions, 30 deletions. 48 new tests, including end-to-end coverage live-tested with Anthropic Haiku 4.5.

This PR overhauls the first-run experience of `mempalace init` end-to-end, ships a new corpus-origin detection module from scratch, wires it into entity classification and LLM refinement, adds a graceful-fallback path that means `init` never crashes on a missing LLM, and ships a meta-test that prevents internal-coordination jargon from leaking into source or tests.

The headline change is that `mempalace init` now understands what kind of folder you're pointing it at — AI conversations, regular writing, code, narrative — and adapts how it classifies entities accordingly. The same folder containing `Echo`, `Sparrow`, and `Cipher` (names you've assigned to AI agents) used to dump those into your "people" list alongside biological humans. Now they go into a separate `agent_personas` bucket, and your `people` list stays clean.

But the broader change is that `mempalace init` got upgraded across the board — smarter defaults, smarter degradation, smarter classification, smarter persistence, and a new way to refresh as your folder grows. Built and live-verified with Anthropic Haiku 4.5; runs unmodified on the local LLM runtimes mempalace already supports.

## What changes for users (in order, from `pip install` onwards)

**Install** — `pip install mempalace` is unchanged. The package itself didn't shift.

**First run — `mempalace init <folder>`:**

1. **`init` examines your folder before classifying anything.** A free regex heuristic decides in milliseconds: AI conversations, regular writing, narrative, or code? If an LLM is reachable, a second pass extracts the corpus author's name and any agent persona names from the dialogue. v3.3.3 had no such step — it dove straight into entity detection with no corpus context.

2. **LLM-assisted classification is now ON by default.** v3.3.3 made `--llm` opt-in. The LLM-assisted path is qualitatively better (extracts persona names, refines ambiguous classifications, gives the model corpus context) so it now runs by default. The provider abstraction is unchanged from v3.3.3 — three buckets are supported by `mempalace.llm_client`:
   - **Anthropic** (`--llm-provider anthropic` + `ANTHROPIC_API_KEY`) — the official Messages API. **This is the path live-verified end-to-end in this PR with Haiku 4.5.** Cost: ~\$0.01 per `init`.
   - **Ollama** (`--llm-provider ollama` — the default) — local models via `http://localhost:11434`. Fully offline. Honors the "zero-API required" promise.
   - **OpenAI-compatible** (`--llm-provider openai-compat` + `--llm-endpoint`) — per the v3.3.3 `mempalace/llm_client.py` docstring, this covers "OpenRouter, LM Studio, llama.cpp server, vLLM, Groq, Fireworks, Together, and most self-hosted setups." We did not test each of those individually as part of this PR; the abstraction has been stable since v3.3.3. If you try this PR with a specific provider and hit a quirk, please file an issue or comment here.

3. **`init` never blocks on a missing LLM.** No Ollama running, no API key set? `init` prints a one-line message pointing at `--no-llm` and falls through to the heuristic-only path. New default behavior, new graceful fallback to support it. `--no-llm` is the new explicit opt-out.

4. **`init` shows you what it detected.** A one-line banner — `Detected: Claude (Anthropic) (user: Jordan, agents: Echo, Sparrow, Cipher)` or `Corpus origin: not AI-dialogue (confidence: 0.98)` — tells you at a glance whether mempalace understood your folder.

5. **Entity classification gets smarter across the board.** Even non-persona candidates benefit: the LLM has corpus context (this is AI-dialogue, this is the user's name, these are agent names) and uses it to disambiguate ambiguous candidates that aren't personas at all.

6. **Agent personas live in their own bucket.** Names you've assigned to AI agents (Echo, Sparrow, Cipher) go into a new `agent_personas` bucket instead of your `people` list. Your real-person entity list stays clean.

7. **Detection result persists to `<palace>/.mempalace/origin.json`** with a `schema_version: 1` envelope, so downstream tools can read it.

8. **Re-running `init` is now idempotent.** Bug fix — running `init` twice on the same folder used to give different classification results because the detection step was sampling its own `entities.json` output. Caught by integration testing during this PR.

**Later — when your folder grows:**

9. **`mempalace mine --redetect-origin`** is a new flag for refreshing the stored detection without redoing the whole `init`. Heuristic-only by design (the flag is meant to be cheap). If you want the full LLM-extracted detection refreshed (persona names, user name, etc.), run `mempalace init <yourfolder>` again — `init` is now idempotent (item 8), so re-running it on the same folder is safe.

## Behind the changes

- **New module** `mempalace/corpus_origin.py` (422 lines) with two-tier detection: regex heuristic with co-occurrence rule (suppresses ambiguous terms like `Claude` / `Gemini` / `Haiku` when no unambiguous AI signal is present, so French novels, astrology forums, poetry corpora, llama-rancher journals don't false-positive), and LLM tier that extracts `user_name` and `agent_persona_names` from dialogue structure with belt-and-suspenders user-vs-agent disambiguation.

- **Entity-classification consumer wiring.** `entity_detector.detect_entities` and `project_scanner.discover_entities` accept an optional `corpus_origin` kwarg. When present and the corpus is identified as AI-dialogue, candidates whose name case-insensitively matches an `agent_persona_name` are routed into the `agent_personas` bucket instead of `people`. Per-entity `type` is rewritten to `"agent_persona"`.

- **LLM-refine consumer wiring.** `llm_refine.refine_entities` accepts the same `corpus_origin` kwarg and prepends a `CORPUS CONTEXT` preamble to its system prompt giving the LLM the platform / user / persona context. Existing `TOPIC` / `PERSON` / `PROJECT` / `COMMON_WORD` / `AMBIGUOUS` labels are unchanged.

- **`init` overhaul.** Pass 0 (corpus-origin detection) inserted before existing Pass 1 (entity discovery). `--llm` flipped to default-on. `--no-llm` added. Graceful-fallback path replaces the previous hard-error on missing LLM. Provider precedence unchanged from the existing `llm_client` module.

- **`mine` flag.** `mempalace mine --redetect-origin` re-runs corpus-origin detection on the current corpus state and overwrites `<palace>/.mempalace/origin.json`.

- **`CLAUDE.md` design principle reworded** — "Local-first, zero external API by default." Local LLMs running on `localhost` (Ollama, LM Studio, llama.cpp, vLLM, unsloth studio) are part of the user's machine, not external APIs. External BYOK providers (Anthropic, OpenAI, Google) are supported but always opt-in, never default, never silent fallback.

## Cost story

- **Anthropic (verified path):** ~\$0.01 per `init` via Haiku 4.5 with `ANTHROPIC_API_KEY`.
- **Ollama / local LLM runtime:** zero cost. Fully offline.
- **OpenAI-compatible service:** depends entirely on the service. The abstraction supports any service speaking the standard `/v1/chat/completions` API; specific quirks vary per provider. Try it and tell us how it goes.
- **No LLM at all:** graceful fallback to heuristic-only. Zero cost. `init` never blocks.

## Backwards compatibility

- All public function signatures gained the `corpus_origin` kwarg as optional (default `None`). Callers that don't pass it see the v3.3.3 return shape unchanged — no `agent_personas` key, no behavioral change.
- The `--llm` CLI flag is preserved as a deprecated alias of the default. Existing scripts that pass it continue to work.
- `corpus_origin=None` keeps `llm_refine.SYSTEM_PROMPT` byte-identical to v3.3.3.

## Test coverage

- **19 unit tests** in `tests/test_corpus_origin.py` covering both tiers, the co-occurrence rule, ambiguous-term suppression, word-boundary brand matching, and user/persona disambiguation.
- **29 integration tests** in `tests/test_corpus_origin_integration.py` covering end-to-end through `mempalace init`, persona reclassification, the `--redetect-origin` flag, the `--llm` default flip, graceful fallback paths, and re-init idempotency. Of those 29, five specifically cover the intersection with develop's other in-flight work (Pass 0 ↔ auto-mine ordering, topics + agent_personas bucket coexistence, entities.json shape, the `wing=` kwarg threading, llm_refine TOPIC label + corpus_origin preamble composition).
- **1354 total mempalace tests pass.** 2 pre-existing environmental failures (`test_mcp_stdio_protection` — chromadb optional dep) unrelated to this change; they fail on plain `develop` too.
- **Live-smoke-tested** with real Anthropic Haiku 4.5 on AI-dialogue and narrative fixtures.

## Hygiene guardrail

This PR also adds a meta-test (`test_no_internal_coordination_jargon_in_source_or_tests`) that walks the source tree and asserts no internal-coordination jargon (e.g. development-phase markers, internal review-section references) leaks into runtime code, comments, docstrings, or LLM prompts. RED if anything slips in. Allowlist for legitimate RFC/spec section citations in `sources/`, `backends/`, `knowledge_graph.py`, and `i18n/`.
2026-04-26 12:37:26 -07:00
Igor Lins e Silva 5de5b0923d Merge pull request #936 from shaun0927/fix/diary-topic-sanitize
fix: sanitize topic parameter in tool_diary_write
2026-04-26 16:12:37 -03:00
jpandClaude Opus 4.7 74ff5e6b98 fix(hnsw): integrity gate in quarantine_stale_hnsw — corruption vs flush-lag
Previous: quarantine fired whenever sqlite_mtime - hnsw_mtime exceeded
the (lowered, in #1173) 300s threshold. ChromaDB 1.5.x flushes HNSW
asynchronously and a clean shutdown does not force-flush, so the on-
disk HNSW is *always* meaningfully older than chroma.sqlite3 — that's
the steady state, not corruption. Quarantine renamed valid HNSW
segments on every cold-start, chromadb created empty replacements,
vector recall went to 0/N until rebuild.

Confirmed in production on the disks daemon journal, 2026-04-26
06:56:45: three of three HNSW segments quarantined on cold-start with
538-557s mtime gaps (post-clean-shutdown flush lag), leaving a
151,478-drawer palace with vector_ranked=0. Drift directories at
*.drift-20260426-065645/ each contained a complete 253MB data_level0.bin
plus 18MB index_metadata.pickle — clearly healthy indexes, renamed by
the false-positive heuristic.

Fix: two-stage gate.

  1. mtime gate (existing) — gap > stale_seconds is necessary.
  2. integrity gate (new) — sniff index_metadata.pickle for chromadb's
     expected protocol/terminator bytes (PROTO 0x80 head, STOP 0x2e
     tail) and a non-trivial size, WITHOUT deserializing the file.
     Healthy segment with mtime drift → keep in place; truncated /
     zero-filled / partial-flush → quarantine.

Format-sniff is deliberately non-deserializing — pickle deserialization
can execute arbitrary code, and the PROTO+STOP byte presence + size
floor is sufficient to distinguish a complete chromadb write from
truncation, zero-fill, or a partial flush during process kill. Real
load failures (the rare case where the bytes look right but chromadb
fails to load) still surface to palace-daemon's _auto_repair, which
calls quarantine_stale_hnsw directly on observed HNSW errors and
bypasses this gate.

The cold-start gate from 70c4bc6 (row 24) remains as a perf optimization
— even with the integrity check, repeating the sniff on every reconnect
is unnecessary work — but its load-bearing role is now covered by this
deeper fix.

4 new tests in test_backends.py:

  - test_quarantine_stale_hnsw_renames_corrupt_segment (drift + bad meta)
  - test_quarantine_stale_hnsw_leaves_healthy_segment_with_drift_alone
    (drift + valid meta — the production case at 06:24)
  - test_quarantine_stale_hnsw_leaves_segment_without_metadata_alone
    (fresh / never-flushed, no meta file)
  - test_quarantine_stale_hnsw_renames_truncated_metadata (under-floor
    size, partial-flush shape)

Existing test_quarantine_stale_hnsw_renames_drifted_segment renamed
to renames_corrupt_segment with explicit corrupt meta_bytes — the old
"renames any drift" contract is gone.

Suite 1366/1366 pass.

Coordinated cross-repo with palace-daemon's auto-repair-on-startup
workaround (separate agent's commit ed3a892). With this fork-side fix
the auto-repair becomes belt-and-suspenders; the structural cause
of empty-HNSW-on-restart is addressed at the quarantine layer.

CLAUDE.md row 26 + README fork-change-queue row + test count
1363→1366.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:25 -07:00
jpandClaude Opus 4.7 e5e7a57930 fix(hnsw): gate quarantine_stale_hnsw to cold-start, not every reconnect
Symptom on the canonical disks daemon: drift quarantines firing every
10–30 minutes throughout the day under steady write load. Logs show
.drift-* directories accumulating despite the daemon being the only
writer (no Syncthing replication of palace data).

Root cause is a false-positive thrash in the quarantine heuristic:

- chroma.sqlite3 mtime bumps on every write (millisecond cadence).
- HNSW segment files (data_level0.bin) only flush to disk on
  chromadb's internal cadence, which can lag minutes behind sqlite
  under continuous write load.

Once the gap exceeds the 300s threshold, quarantine_stale_hnsw renames
a perfectly valid HNSW segment, chromadb rebuilds it from scratch, and
the cycle repeats as soon as the next batch of writes lands. The 300s
threshold (lowered from 3600s in PR #1173 after a 0.96h-drift production
segfault) is correct for the cross-machine-replication failure mode it
was designed for, but wrong for a daemon-strict deployment whose only
"drift" source is its own benign flush lag.

Fix: gate the proactive quarantine check to the first ``make_client()``
invocation per palace per process (``ChromaBackend._quarantined_paths``
set). Real cold-start drift (replication, partial restore, crashed-mid-
write) still gets caught — that's exactly when a fresh daemon process
opens the palace. Real runtime drift on observed HNSW errors still gets
caught via palace-daemon's ``_auto_repair`` which calls
``quarantine_stale_hnsw`` directly, bypassing this gate.

Two new tests in test_backends.py verify single-fire-per-palace and
per-palace independence. Conftest clears the gate between tests.

Suite 1362/1362 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:40:25 -07:00
jp db80f6e26c fix: call quarantine_stale_hnsw() in make_client(); lower threshold to 5min
make_client() called _fix_blob_seq_ids but skipped quarantine_stale_hnsw,
so every fresh process (stop hook, precompact hook, CLI) opened a drifted
palace and segfaulted in chromadb_rust_bindings before any write-path
protection could fire.

#1062 wires the quarantine call at MCP server startup (covers long-lived
server processes). This fix adds it to make_client() itself — the call
site that all callers (server, hooks, CLI, tests) pass through — so every
fresh PersistentClient open is protected regardless of entry point.

Also lowers stale_seconds default from 3600 to 300: a 0.96h-drifted
segment caused production segfaults before the 1h threshold fired.
ChromaDB's HNSW flush cadence means legitimate drift is seconds to low
minutes; 5min gives headroom without admitting clearly corrupt segments.
2026-04-26 09:39:53 -07:00
Arnold Wender 0b8c2c158f fix(kg): reject inverted intervals in add_triple (valid_to < valid_from)
A triple with valid_to < valid_from satisfies neither of the temporal
filter clauses in query_entity():

    valid_from <= as_of AND valid_to >= as_of

so the triple is invisible to every query — silently corrupt. Reject
at write time with a clear error instead of letting bad data pile up
in the SQLite store.

The guard only fires when both bounds are present; open intervals
(only valid_from or only valid_to) are still accepted, and same-day
intervals (valid_from == valid_to, point-in-time facts) are explicitly
allowed.
2026-04-26 13:00:27 +02:00
Igor Lins e Silva 6890948e09 Merge pull request #1210 from MemPalace/fix/repair-extraction-cap-detection
fix(repair): refuse to overwrite when extraction looks truncated (#1208)
2026-04-26 04:49:01 -03:00
bensig 452630e927 fix(repair): refuse to overwrite when extraction looks truncated (#1208)
The user-reported case in #1208: a palace with 67,580 drawers had its
HNSW files manually quarantined to recover from corruption. ``mempalace
repair`` then ran cleanly and reported "Drawers found: 10000 ... Repair
complete. 10000 drawers rebuilt." Backup was the v3.3.3 chroma.sqlite3
that did contain the full 67,580 — but the rebuilt collection only had
the first 10K. 85% data loss, no warning.

Root cause: ChromaDB's collection-layer get() silently caps at
``CHROMADB_DEFAULT_GET_LIMIT = 10_000`` rows when reading from a
collection whose segment metadata is stale (typical post-quarantine
state). col.count() returns the same capped value, so neither the
loop bound nor the extraction count flagged the truncation.

Fix is defense-in-depth, not a recovery mechanism. Repair now:

1. After extraction, queries chroma.sqlite3 directly via a read-only
   sqlite3 connection: COUNT(*) FROM embeddings JOIN segments JOIN
   collections WHERE name='mempalace_drawers'. If that count exceeds
   the extracted count, abort with a clear message before any
   destructive operation.
2. Falls back to a weaker check when the SQLite query can't run
   (chromadb schema drift, locked file): if extracted exactly equals
   CHROMADB_DEFAULT_GET_LIMIT, that's a strong-enough cap signal to
   refuse without explicit acknowledgement.
3. Adds ``--confirm-truncation-ok`` (CLI) and ``confirm_truncation_ok``
   (rebuild_index kwarg) to override after independent verification.
   Useful for the rare case of a palace genuinely sized at exactly
   10,000 drawers.

The guard logic lives in ``repair.check_extraction_safety()`` so the
two extraction paths (CLI ``cmd_repair`` and the lower-level
``rebuild_index``) share a single implementation. Raises
``TruncationDetected`` carrying the printable message.

Tests: 9 new cases covering the safe path (counts match, SQLite
unreadable but well under cap), both abort paths (SQLite higher than
extracted, unreadable + at cap), the override flag, and end-to-end
behavior of ``rebuild_index`` with the guard wired in. Plus two
``sqlite_drawer_count`` tests for the missing-file and bad-schema
cases.

What's NOT in this PR: actually recovering the missing 57,580
drawers from the user's case. The on-disk SQLite still holds them;
recovery is a separate flow (direct-extract from chroma.sqlite3,
bypass the chromadb collection layer entirely). This PR's job is
to stop repair from making it worse.

Refs #1208.
2026-04-25 23:34:05 -07:00
Igor Lins e Silva 5e57404502 Merge pull request #935 from shaun0927/fix/repair-crash-safety
fix: guard against data loss in repair, migrate, and CLI rebuild
2026-04-25 20:49:23 -03:00
Igor Lins e Silva 34bc08fb30 Merge pull request #1205 from MemPalace/ci/python-313-windows-macos
ci: bump Windows and macOS jobs to Python 3.13
2026-04-25 20:03:12 -03:00
Igor Lins e Silva 57854c7e2e ci: bump Windows and macOS jobs to Python 3.13
3.11 is mid-life; 3.13 is already on the Linux matrix and gives ~3.5
years of upstream support. Aligns the single-version platform jobs with
the top of the Linux matrix. requires-python and lint job left alone.

Refs #1192 (Option A).
2026-04-25 19:59:05 -03:00
jpandClaude Opus 4.7 5b07b869b0 fix(palace_graph): skip None metadata in build_graph
ChromaDB can return None for drawers without metadata (legacy data,
partial writes — same root cause as upstream #1020 / our PR #1094).
build_graph at line 95 called meta.get("room", "") unconditionally,
which AttributeErrors on None and takes out every consumer of
build_graph for the whole call path: graph_stats, find_tunnels,
traverse, and (most visibly) the daemon's /stats endpoint.

Caught 2026-04-25 by palace-daemon's verify-routes.sh smoke test
against the canonical 151K-drawer palace — /stats was 500-ing on a
single None drawer.

Adds `if meta is None: continue` guard. Closes the same gap upstream's
#999 None-metadata audit closed in searcher.py / mcp_server.py /
miner.status, just in a different file the audit didn't reach. The
graph-build is recoverable: skipping a single None drawer doesn't
distort the graph since build_graph already filters
`room and room != "general" and wing` — a missing-metadata drawer was
never going to participate anyway.

Test: TestBuildGraph::test_none_metadata_does_not_crash mixes a None
entry into a 3-drawer fixture and asserts the two real drawers are
processed normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:06:32 -07:00
jp bc24aa14e2 fix: skip _fix_blob_seq_ids sqlite open on already-migrated palaces (#1090)
Opening chroma.sqlite3 via Python's sqlite3.connect() against a live
ChromaDB 1.5.x WAL-mode database leaves state that segfaults the next
PersistentClient call — the same failure mode tracked at #1090.

_fix_blob_seq_ids runs unconditionally on every make_client() call, so
every fresh process (MCP server, stop hook, CLI) re-triggers the sqlite
open → corrupt → segfault cycle on palaces that have already completed
the 0.6.x → 1.5.x seq_id migration.

Guard with a .blob_seq_ids_migrated marker file in the palace directory:
- If marker exists, return immediately — skip sqlite entirely
- After successful migration (or confirmation that no BLOBs remain),
  write the marker so subsequent opens take the fast path
- Palaces that never had BLOB seq_ids also get the marker on first open,
  so they too avoid the redundant sqlite open after that
- Already-migrated palaces can touch the marker manually to opt in

Test plan: Direct test — run _fix_blob_seq_ids twice against a fresh
palace; second call returns immediately because marker exists. 1094
existing tests pass.
2026-04-25 07:42:05 -07:00
jpandClaude Opus 4.7 67248330c5 chore: ruff format tests/test_searcher.py
CI lint job runs `ruff format --check`; the new tests in TestBM25NoneSafety
needed the standard "blank line after import-inside-function" + line-length
wrap. No logic change — formatter pass only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:22:53 -07:00
jpandClaude Opus 4.7 ee12c07c54 fix(searcher): tolerate None documents in BM25 reranker
`_tokenize` calls `text.lower()` unconditionally; when ChromaDB returns a
drawer with `documents` containing `None`, the hybrid-rerank path raises
`AttributeError: 'NoneType' object has no attribute 'lower'`.

Observed in production daemon log (2026-04-24 21:07:05) during a search
that triggered `_hybrid_rank → _bm25_scores → _tokenize`:

    File "mempalace/searcher.py", line 81, in _bm25_scores
        tokenized = [_tokenize(d) for d in documents]
    File "mempalace/searcher.py", line 52, in _tokenize
        return _TOKEN_RE.findall(text.lower())
    AttributeError: 'NoneType' object has no attribute 'lower'

Closes the gap left by the upstream None-metadata audit (#999), which
covered metadata loops but not BM25 helpers. Returns `[]` for falsy input
so a None doc gets score 0.0 while the rest of the corpus reranks normally.

Three regression tests in TestBM25NoneSafety lock the behavior and reference
the production trace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 06:27:14 -07:00
Igor Lins e Silva 0d9929c0dd Merge pull request #976 from felipetruman/fix/hnsw-race-and-fanout
fix: HNSW graph corruption, PreCompact deadlock, mine fan-out (closes #974, #965, #955)
2026-04-25 05:00:14 -03:00
Igor Lins e Silva 7773432bca chore(rebase): reconcile with develop and apply ruff format
After rebasing onto current develop:
- chroma.py: keep develop's quarantine_stale_hnsw + UnsupportedFilterError
  validation alongside this PR's _pin_hnsw_threads retrofit.
- tests/test_backends.py: combine quarantine_stale_hnsw and
  _pin_hnsw_threads test sections; ruff format.
- miner.py: propagate the new `files=` kwarg (added on develop in #1183
  for the init -> mine flow) through _mine_impl so the caller can pass
  a pre-scanned file list under the global lock.
2026-04-25 04:39:31 -03:00
Felipe Truman 8df944a54d fix: best-effort HNSW thread-pin retrofit + drop dead attempt-cap constant
Addresses remaining PR #976 review items after rebase on develop.

`get_collection(create=False)` previously returned existing collections without
re-applying `hnsw:num_threads=1`, so palaces created before the fix kept the
unsafe parallel-insert path. Add `_pin_hnsw_threads()` helper that calls
`collection.modify(configuration=UpdateCollectionConfiguration(
hnsw=UpdateHNSWConfiguration(num_threads=1)))` best-effort on every
`get_collection` call (including the MCP server's `_get_collection`).

In chromadb 1.5.x the runtime config does not persist to disk across
`PersistentClient` reopens, so the retrofit is re-applied each process start
rather than being a one-shot migration. Fresh palaces keep the metadata-based
pin as primary defense; legacy palaces now also get per-session protection
without requiring `mempalace nuke` + re-mine.

After the rebase on develop, `hook_precompact` delegates to `_mine_sync` and
no longer emits `decision: block`, so the attempt-cap constant was orphaned.
Grep confirms 0 usages in the repo — remove it.

- `_pin_hnsw_threads` retrofits legacy collection (num_threads None -> 1)
- `_pin_hnsw_threads` swallows all errors (never raises)
- `ChromaBackend.get_collection(create=False)` applies retrofit on legacy palace
- 62 tests pass (10 backends + 6 palace locks + 46 hooks_cli)
2026-04-25 04:36:29 -03:00
Felipe TrumanandClaude Sonnet 4.6 40d7958ca1 test: remove attempt-cap tests obsoleted by develop's pass-through approach
PR #863 on develop eliminated precompact blocking entirely. After rebasing,
the attempt-cap tests (test_precompact_first_two_attempts_block,
test_precompact_passes_through_after_cap, test_precompact_counter_is_per_session)
would always fail because hook_precompact now mines synchronously and
passes through unconditionally. Remove them to keep the suite green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 04:34:30 -03:00
Felipe Truman 1998aede66 fix: Windows CI compat for palace lock tests and path normalization
Addresses the two actionable Copilot comments from the 2nd review pass.

tests/test_palace_locks.py (#7, #8)
  multiprocessing.get_context("fork") is unavailable on Windows, so the
  cross-process tests would crash the Windows CI runner. Added
  `_get_mp_context()` that picks "spawn" on Windows and "fork" elsewhere.
  Spawn re-imports the module in the child; it inherits os.environ
  (including the monkeypatched HOME), which is all these tests need.

mempalace/palace.py (#10)
  The per-palace lock key was computed from os.path.abspath(palace_path).
  On Windows the filesystem is case-insensitive, so `C:\\Palace` and
  `c:\\palace` would hash to different keys and two concurrent mines
  could touch the same on-disk palace. Switched to
  `os.path.normcase(os.path.realpath(...))` so:
    * realpath resolves symlinks and `..` segments
    * normcase folds case on Windows (no-op on POSIX)

Testing
  pytest tests/test_palace_locks.py tests/test_hooks_cli.py
         tests/test_backends.py tests/test_cli.py
  → 98 passed, 0 failed.
2026-04-25 04:34:30 -03:00
Felipe Truman 99b820cb42 fix: address PR review — per-palace lock, MCP server path, hook timeout, tests
Addresses the six Copilot review comments on the initial commit.

1) #6 (critical) — mcp_server.py `_get_collection` bypassed ChromaBackend

   The MCP server creates its palace collection directly via
   `chromadb.PersistentClient.get_or_create_collection` in `_get_collection`,
   not through `ChromaBackend.get_collection`. That path was missing the
   `hnsw:num_threads=1` metadata, so the primary crash surface for #974
   and #965 was untouched by the original patch. Fixed by passing
   `hnsw:num_threads=1` at the mcp_server create site too. Documented
   in a code comment that the setting is only honored at creation
   time — existing palaces created before this fix still need a
   `mempalace nuke` + re-mine to gain the protection.

2) #3 — mine_global_lock over-serialized mines across unrelated palaces

   Replaced the single global lock file `mine_global.lock` with a
   per-palace lock keyed by `sha256(os.path.abspath(palace_path))`
   (`mine_palace_<hash>.lock`). Mines against the same palace still
   collapse to a single runner (the correctness boundary), but mines
   against *different* palaces are now free to run in parallel.
   `mine_global_lock` is kept as a backward-compatible alias for
   `mine_palace_lock` so any external callers that imported the
   previous name keep working.

3) #1 — hook_precompact swallowed OSError but not subprocess.TimeoutExpired

   `subprocess.run(..., timeout=60)` raises `TimeoutExpired` on slow
   palaces. The previous `except OSError` clause didn't catch it, so
   the hook could raise and fail to emit any JSON decision — leaving
   the harness without a block/passthrough signal. Fixed by catching
   `(OSError, subprocess.TimeoutExpired)` together and always falling
   through to the block decision so the hook reliably emits a response.

4) #2 + #4 — tests

   - tests/test_hooks_cli.py: added
     `test_precompact_first_two_attempts_block`,
     `test_precompact_passes_through_after_cap`, and
     `test_precompact_counter_is_per_session` to lock in the #955
     deadlock fix.
   - tests/test_palace_locks.py (new): covers `mine_palace_lock`
     single-acquire, reuse-after-release, cross-process serialization
     on the same palace, non-interference across different palaces,
     path normalization, and the `mine_global_lock` back-compat alias.

5) #5 — known limitation, documented but not auto-fixed

   Copilot suggested detecting collections missing `hnsw:num_threads=1`
   and calling `collection.modify(metadata=...)` to retrofit existing
   palaces. Verified against chromadb 1.5.7: `modify(metadata=...)`
   replaces metadata rather than merging, and re-passing
   `hnsw:space="cosine"` then raises `ValueError: Changing the
   distance function of a collection once it is created is not
   supported currently.` The HNSW runtime configuration
   (`configuration_json`) also does not expose `num_threads` in
   chromadb 1.5.x, so the flag appears to be read only at creation
   time. Rather than paper over the limitation with a best-effort
   `modify` that silently drops `hnsw:space`, documented in the
   mcp_server comment that pre-existing palaces need a
   `mempalace nuke` + re-mine to gain the protection. Fresh palaces
   are always protected.

Testing
- pytest tests/test_palace_locks.py tests/test_hooks_cli.py
  tests/test_backends.py tests/test_cli.py → **98 passed, 0 failed**.
- Runtime validation with two concurrent `mempalace mine` calls:
  - Different palaces → both complete in parallel ✓
  - Same palace     → one completes, the other exits with
    "another `mine` is already running against <palace> — exiting
    cleanly." ✓
2026-04-25 04:34:30 -03:00
Felipe TrumanandClaude Sonnet 4.6 7e18a70796 fix: resolve hooks_cli.py merge conflict + add mine_global_lock tests
- Resolve UU conflict in hooks_cli.py: take develop/HEAD approach
  (mine synchronously via _mine_sync, then pass through unconditionally).
  _mine_sync already catches subprocess.TimeoutExpired — fixes Copilot #1.
- Add tests/test_palace_locks.py: 4 tests covering mine_global_lock
  non-blocking semantics (acquire, second-acquire raises MineAlreadyRunning,
  reusable after release, release on exception) — fixes Copilot #4.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 04:34:30 -03:00
Igor Lins e Silva 91a60263e3 Merge pull request #1168 from arnoldwender/fix/security-tunnels-permissions
fix(security): restrict tunnels.json file permissions
2026-04-25 04:21:44 -03:00
Igor Lins e Silva 320aab31e3 Merge pull request #939 from mvalentsev/ci/pip-cache-and-python-bump
ci: add pip caching and bump Python on macOS/Windows
2026-04-25 04:12:57 -03:00
Igor Lins e Silva 5ed24ad061 Merge pull request #969 from MemPalace/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-04-25 04:04:37 -03:00
Igor Lins e Silva 036742e888 Merge pull request #968 from MemPalace/dependabot/github_actions/actions/upload-pages-artifact-5
chore(deps): bump actions/upload-pages-artifact from 3 to 5
2026-04-25 04:04:18 -03:00
Igor Lins e Silva 000acc1e33 Merge pull request #967 from MemPalace/dependabot/github_actions/actions/deploy-pages-5
chore(deps): bump actions/deploy-pages from 4 to 5
2026-04-25 04:03:58 -03:00
Igor Lins e Silva 9b7536a1f7 Merge pull request #1101 from wahajahmed010/fix/hooks-tutorial-1037
docs: fix HOOKS_TUTORIAL.md paths, matcher, and missing timeout (#1037)
2026-04-25 03:48:36 -03:00
Igor Lins e Silva 374fb5656b Merge pull request #1183 from MemPalace/feat/init-mine-ux
feat(cli): init prompts to mine, mine handles Ctrl-C gracefully (#1181, #1182)
2026-04-25 01:24:23 -03:00
Igor Lins e Silva c4eeec8642 test: use shlex.quote in resume-hint assertions for Windows
The pre-existing test_maybe_run_mine_prompt_declined_prints_hint
asserted the bare unquoted form `mempalace mine {tmp_path}`. After
the production code switched to shlex.quote on the resume hint, this
passed on Linux/macOS (POSIX paths have no characters that trigger
quoting) but failed on Windows where backslashes always get wrapped
in single quotes.

Mirror the production code in the assertion via shlex.quote so it's
portable across platforms; do the same for the two new
spaces-in-path tests for consistency.
2026-04-25 01:18:31 -03:00
Igor Lins e Silva 8faf0042b5 fix(cli,mine): shell-quote project_dir in resume hints
The "Skipped. Run mempalace mine <dir>" hint after declining the init
prompt and the "Re-run mempalace mine <dir> to resume" hint after a
Ctrl-C interruption both interpolated project_dir without shell-quoting.
A path containing spaces or metacharacters produced a copy-paste-broken
command.

Both spots now use shlex.quote(project_dir). Adds regression tests
covering each hint with a path that contains a space.
2026-04-25 01:10:17 -03:00
Igor Lins e Silva 23d534f8f3 fix(init): split --auto-mine from --yes; show file-count estimate before mine prompt
Reviewer feedback on the previous commit flagged two real problems:

1. Overloading --yes to also auto-mine was a silent behaviour change for
   scripted callers. Today --yes only auto-accepts entities — making it
   ALSO trigger a multi-minute ChromaDB write breaks every script that
   currently runs `mempalace init --yes <dir>` for the fast non-interactive
   entity path. Add a separate `--auto-mine` flag instead. Combinations:

     mempalace init --yes <dir>              # entities auto, STILL prompt mine
     mempalace init --auto-mine <dir>        # prompt entities, skip mine prompt
     mempalace init --yes --auto-mine <dir>  # fully non-interactive

   --yes behaviour is now identical to pre-PR.

2. The mine prompt was firing without telling the user how big the job
   was. On a real corpus mine takes minutes-to-tens-of-minutes; hitting
   Enter on default-Y with no size cue is a footgun. Show a one-line
   estimate computed from scan_project (the same walk we hand into mine)
   BEFORE the prompt:

     ~423 files (~12 MB) would be mined into this palace.
     Mine this directory now? [Y/n]

   The estimate uses a single corpus walk: scan_project's output is
   passed into mine() via a new optional files= kwarg, so we never walk
   the tree twice.

Tests: replaced the old "--yes auto-mines" assertion with a regression
guard that --yes alone STILL prompts; added coverage for --auto-mine
alone, --yes --auto-mine together, and the pre-prompt estimate line.
2026-04-25 01:02:09 -03:00
Igor Lins e Silva f13b9a46a2 feat(cli): init prompts to mine, mine handles Ctrl-C gracefully
`mempalace init` now ends with a `Mine this directory now? [Y/n]`
prompt and runs `mine()` in-process when accepted; `--yes` skips the
prompt and auto-mines for non-interactive callers. Declining prints
the resume command. Removes the "remember to type the next command"
friction since rooms + entities just got set up.

`mempalace mine` now wraps its main loop in `try / except
KeyboardInterrupt` and prints `files_processed`, `drawers_filed`, and
`last_file` before exiting with code 130 on Ctrl-C. Re-mining is safe
because deterministic drawer IDs make the upsert idempotent. The
hooks PID lock at `~/.mempalace/hook_state/mine.pid` is now actively
removed in a `finally` when its entry points at us, on clean exit,
error, or interrupt — preventing the next hook fire from briefly
waiting on a stale PID.

Closes #1181, #1182.
2026-04-25 01:01:24 -03:00
Igor Lins e Silva 91c1d159af Merge pull request #1179 from MemPalace/fix/search-metric-quality
fix(search): CLI hybrid rerank, legacy-metric warning, invariant tests (3.3.4)
2026-04-25 00:57:25 -03:00
Igor Lins e Silva ec5f4eba9d fix(test): use tmp_path for full-stack invariant test (Windows CI)
`test_fresh_palace_via_full_stack_gets_cosine` used `tempfile.Temporary-
Directory()` as a context manager, which tries to delete the temp path
on exit. On Windows, ChromaDB still holds SQLite file handles to
`chroma.sqlite3` when the context closes, producing:

    PermissionError: [WinError 32] The process cannot access the file
    because it is being used by another process: '...\\chroma.sqlite3'
    NotADirectoryError: [WinError 267] The directory name is invalid

Other tests in the same file use pytest's `tmp_path` fixture, which
defers cleanup to session end (when the process is exiting and the
file-lock contention is moot). Align this one with the rest of the
file.

CLAUDE.md already documents the 80% Windows coverage allowance due to
"ChromaDB file lock cleanup" — the fix is to stop fighting the lock.
2026-04-25 00:39:37 -03:00
Igor Lins e Silva 133dfbfb41 fix(search): BM25 hybrid rerank, legacy-metric warning, invariant tests
Three tightly-coupled search-quality fixes for v3.3.3:

1. CLI `mempalace search` now routes through the same `_hybrid_rank`
   the MCP path already used. Drawers whose text contains every query
   term but embed as file-tree noise (directory listings, diffs, log
   fragments) were scoring cosine distance >= 1.0 — the display formula
   `max(0, 1 - dist)` then floored every result to `Match: 0.0`, with
   no way for the user to tell a lexical match from a total miss. BM25
   catches these cleanly; the display surfaces both `cosine=` and
   `bm25=` so users see which component is firing.

2. Legacy-palace distance-metric warning. Palaces created before
   `hnsw:space=cosine` was consistently set silently use ChromaDB's
   default L2 metric, which breaks the cosine-similarity formula (L2
   distances routinely exceed 1.0 on normalized 384-dim vectors). The
   search path now detects this at query time and prints a one-line
   notice pointing at `mempalace repair`. Only fires for legacy
   palaces; new palaces already set cosine correctly.

3. Invariant tests pinning `hnsw:space=cosine` on every collection-
   creation path — legacy `get_or_create_collection`, legacy
   `create_collection`, RFC 001 `get_collection(create=True)`, the
   public `palace.get_collection`, and a round-trip through reopen.
   Locks down the correctness that new-user palaces already have so a
   future refactor can't silently regress it.

Also adds a `metadata` property to `ChromaCollection` so callers can
read the underlying hnsw:space without reaching into `_collection`.

Tests:
- New regression: simulate three candidates at distance 1.5 (cosine=0),
  one containing query terms — must rank first with non-zero bm25.
- New: legacy metric (empty or non-cosine) produces stderr warning.
- New: correctly-configured palace produces no warning.
- New: all five creation paths pin cosine metadata.

All existing tests still pass.
2026-04-25 00:39:37 -03:00
Igor Lins e Silva b9e41286fa Merge pull request #1189 from MemPalace/openarena-claim
chore: add OpenArena owner claim verification file
2026-04-24 23:27:49 -03:00
Igor Lins e Silva 8d49b009e0 Merge pull request #1184 from MemPalace/feat/cross-wing-topic-tunnels
feat(graph): cross-wing tunnels by shared topics (#1180)
2026-04-24 23:25:55 -03:00
Igor Lins e Silva 0197b2eea9 chore: add OpenArena owner claim verification file 2026-04-24 23:19:29 -03:00
Igor Lins e Silva 865a36bc5c feat(graph): namespace topic-tunnel rooms with "topic:" prefix + kind field
Previously a cross-wing topic tunnel for "Angular" stored the room as
"Angular" — colliding with a wing's literal folder-derived "Angular" room
at follow_tunnels/list_tunnels read time, and exposing raw topic strings
(which may contain characters rejected by sanitize_name) to the MCP
surface.

Topic tunnels now store their room as "topic:<original-casing>" and carry
kind="topic" on the stored dict. Explicit tunnels get kind="explicit"
(default). follow_tunnels("wing", "Angular") on a literal Angular room
no longer surfaces topic connections for the same name, and any LLM
scanning list_tunnels has a visible discriminator.
2026-04-24 23:06:26 -03:00
Igor Lins e Silva fe051adc73 feat(graph): cross-wing tunnels by shared topics (#1180)
When two wings have one or more confirmed TOPIC labels in common, the
miner now drops a symmetric tunnel between them at mine time so the
palace graph reflects shared themes (frameworks, vendors, recurring
concepts).

- llm_refine: TOPIC label routes to a dedicated `topics` bucket so the
  signal survives confirmation instead of getting collapsed into
  `uncertain` and dropped.
- entity_detector / project_scanner: bucket plumbed through the
  detection pipeline; `confirm_entities` returns confirmed topics
  alongside people/projects.
- miner.add_to_known_entities: optional `wing` parameter records the
  confirmed topics under `topics_by_wing` in
  `~/.mempalace/known_entities.json`. Wing names do NOT leak into the
  flat known-name set used by drawer-tagging.
- palace_graph: `compute_topic_tunnels` and `topic_tunnels_for_wing`
  create symmetric tunnels via the existing `create_tunnel` API so they
  share dedup and persistence with explicit tunnels.
- miner.mine: post-file-loop pass calls `topic_tunnels_for_wing` for
  the freshly-mined wing. Failures are logged but never abort the mine.
- config: `topic_tunnel_min_count` knob (env
  `MEMPALACE_TOPIC_TUNNEL_MIN_COUNT` or `~/.mempalace/config.json`),
  default 1.

Tests cover topic persistence through init->mine, tunnel creation when
wings share a topic, no tunnel below threshold, cross-wing tunnel
retrieval via `list_tunnels`, dedup on recompute, case-insensitive
overlap, and the end-to-end mine-time wiring.

Out of scope for this PR (called out in the PR body): manifest-
dependency overlap, per-topic allow/deny lists, search-result surfacing.
2026-04-24 23:06:26 -03:00
Igor Lins e Silva ed2ba726c9 Merge pull request #1185 from MemPalace/perf/batched-upsert-gpu
perf(mining): batch per-chunk upserts + optional GPU acceleration
2026-04-24 20:34:28 -03:00
copilot-swe-agent[bot]andigorls 031512438e test: isolate embedding module state with monkeypatch
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3213a67a-6871-4bb2-9ae0-23fa11001a22

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 23:11:29 +00:00
copilot-swe-agent[bot]andigorls 3d529e7028 test: tidy embedding follow-up imports
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3213a67a-6871-4bb2-9ae0-23fa11001a22

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 23:10:20 +00:00
copilot-swe-agent[bot]andigorls 9fbdba17ca test: isolate embedding device env override tests
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3213a67a-6871-4bb2-9ae0-23fa11001a22

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 23:09:23 +00:00
copilot-swe-agent[bot]andigorls 25c885ae0b test: use tmp_path for embedding device config tests
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3213a67a-6871-4bb2-9ae0-23fa11001a22

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 23:08:26 +00:00
copilot-swe-agent[bot]andigorls fbd0904799 test: cover embedding device fallback and bounded upserts
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3213a67a-6871-4bb2-9ae0-23fa11001a22

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 23:06:50 +00:00
Igor Lins e Silva a4868a3589 perf(mining): batch per-chunk upserts and add optional GPU acceleration
The miner upserted one drawer per ChromaDB call, paying tokenizer +
ONNX session setup per chunk. The embedding device was CPU-only because
no EmbeddingFunction was ever wired through the backend.

Two changes, each a speedup in its own right; stacked they give ~10x
end-to-end on a medium corpus (20 files, 568 drawers):

1. Batched upsert. `process_file` and `_file_chunks_locked` now collect
   all chunks of a file into a single `collection.upsert(...)` so the
   embedding model runs one forward pass per file instead of N.

2. Hardware-accelerated embedding function. New `mempalace/embedding.py`
   wraps `ONNXMiniLM_L6_V2` with configurable `preferred_providers`.
   `MEMPALACE_EMBEDDING_DEVICE` (or `embedding_device` in config.json)
   selects auto / cpu / cuda / coreml / dml. Unavailable accelerators
   log a warning and fall back to CPU.

   The factory subclasses `ONNXMiniLM_L6_V2` and spoofs its `name()` to
   `"default"` so the persisted EF identity matches existing palaces
   created with ChromaDB's bare `DefaultEmbeddingFunction` -- same
   model, same 384-dim vectors, no rebuild needed when turning GPU on.

   `ChromaBackend.get_collection` / `create_collection` now pass the
   resolved EF on every call so miner writes and searcher reads agree.

Benchmarks (i9-12900KF + RTX 3090, medium scenario, 568 drawers):

  per-chunk + CPU   19.77s ·  29 drw/s   (baseline)
  batched   + CPU    8.07s ·  70 drw/s   (2.4x)
  batched   + CUDA   2.15s · 264 drw/s   (9.2x)

Reproducible via `benchmarks/mine_bench.py`.

Install paths:
  pip install mempalace[gpu]       # NVIDIA CUDA
  pip install mempalace[dml]       # DirectML (Windows)
  pip install mempalace[coreml]    # macOS Neural Engine

Mine header now prints `Device: cpu|cuda|...` so users can confirm the
accelerator engaged.
2026-04-24 19:42:35 -03:00
Arnold Wender 5fd09d3693 fix(security): restrict tunnels.json file permissions
~/.mempalace/tunnels.json (introduced in #790) was created via plain
open(..., "w") with no chmod, and its parent dir via os.makedirs()
without mode=0o700. On Linux with default umask 022 both end up
world-readable (0o644 / 0o755).

Tunnels reveal cross-wing connections — which projects, people, and
rooms the user has explicitly linked — so they are sensitive metadata
that should not be readable by other local users on shared systems.

Apply the same 0o700 / 0o600 pattern that #814 established for the
other sensitive palace files. Chmod calls are wrapped in try/except
(OSError, NotImplementedError) for Windows / unsupported-filesystem
compatibility.

Closes #1165
2026-04-24 22:57:34 +02:00
Igor Lins e Silva 94f168929a Merge pull request #1159 from MemPalace/develop
release: v3.3.3 — sync develop → main for tag cut
2026-04-24 14:56:33 -03:00
Igor Lins e Silva 7a757916b3 Merge pull request #1176 from MemPalace/docs/changelog-3.3.3-init-overhaul
docs(changelog): document init entity-detection overhaul in 3.3.3
2026-04-24 14:34:09 -03:00
Igor Lins e SilvaandCopilot 174ecaf42c Update CHANGELOG.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 14:33:51 -03:00
Igor Lins e Silva 431e42a720 docs(changelog): document init entity-detection overhaul in 3.3.3
Adds entries to the 3.3.3 section for the work that landed via #1148,
#1150, #1157, and #1175 (rescued from stacked feature branches into
develop via #1175). Without these entries the 3.3.3 release notes on
main would advertise only the hook/diary/search fixes that made it to
develop through the first direct merge.

Covers:
- Manifest + git-author entity detection (#1148)
- Regex detector accuracy improvements (#1148)
- Optional --llm classification with Ollama / openai-compat / Anthropic
  provider abstraction and interactive UX (#1150)
- Claude Code conversation scanner (#1150)
- Init → miner registry wire-up so confirmed entities actually reach
  drawer metadata tagging (#1157)
- Case-insensitive project dedup across all sources (#1175)
- `mempalace mine` skips the generated entities.json artifact
2026-04-24 14:25:13 -03:00
Igor Lins e Silva f246d25b7f Merge pull request #1166 from arnoldwender/fix/security-palace-path-env-normalize
fix(security): normalize MEMPALACE_PALACE_PATH env var with abspath+expanduser
2026-04-24 14:16:58 -03:00
Igor Lins e Silva 8a6ebbe363 Merge pull request #1175 from MemPalace/chore/rescue-stacked-prs-into-develop
chore: rescue merged stacked PRs #1150 and #1157 into develop
2026-04-24 14:14:50 -03:00
Igor Lins e Silva 55c83e9f3d fix(init): case-insensitive project dedup across manifest and convo sources
`discover_entities` was deduping the convo_scanner results against the
manifest/git scan with a case-sensitive key, while every other dedup
path in the pipeline (`_merge_detected`, `miner.add_to_known_entities`)
uses case-insensitive matching. A project named `foo` in a manifest
plus `Foo` as a Claude Code `cwd` variant would surface as two review
entries instead of collapsing to one.

Fix keys `by_name` by `name.lower()` while preserving the first-seen
casing, matching the rest of the pipeline. Flagged by Copilot on #1175.

Regression test asserts a manifest project + a CamelCase-variant convo
cwd for the same real project collapse to one entry.
2026-04-24 14:11:54 -03:00
Igor Lins e Silva 19ce58c143 chore: rescue merged stacked PRs #1150 and #1157 into develop
#1148, #1150, and #1157 were reviewed and merged on GitHub, but the two
stacked children landed on their parent feature branches (now stale)
rather than on develop. Only #1148's commits reached develop via the
direct merge. Release PR #1159 (develop → main for v3.3.3) is therefore
missing the LLM refinement, Claude-conversation scanner, and miner-
registry wire-up that were ostensibly part of the release.

This merge brings the stale `feat/llm-entity-refine` branch (which
contains the rolled-up merge commit for #1157 → #1150 → everything
below) into develop so the release tag includes it.

No code changes here — only history recovery.
2026-04-24 13:49:12 -03:00
Igor Lins e Silva 61d6c3cc3c Merge pull request #1157 from MemPalace/feat/wire-entities-to-miner
feat(init): wire confirmed entities into the miner's known-entities registry
2026-04-24 13:24:56 -03:00
Igor Lins e Silva a851c7a7df Merge pull request #1148 from MemPalace/feat/project-scanner-entity-detection
feat(init): scan manifests and git authors for real entity signal (v1)
2026-04-24 13:23:43 -03:00
Arnold Wender ae1c52e43b test(config): drop tilde-absence assertion for Windows 8.3 compatibility
Windows 8.3 short paths legitimately contain tildes (e.g. the CI runner's
USERPROFILE resolves to C:\Users\RUNNER~1\...), so asserting "~" is absent
from the expanded path fails on Windows even when expanduser worked
correctly. The equality check against os.path.abspath(os.path.expanduser())
is authoritative; drop the redundant absence heuristic.
2026-04-24 11:20:30 +02:00
Arnold Wender 02a88b0864 test(config): make palace_path tests portable across POSIX and Windows
The new abspath+expanduser normalization means /env/palace no longer
round-trips literally on Windows (abspath prepends the current drive,
producing D:\env\palace). Rewrite the env-var tests to compare against
os.path.abspath(os.path.expanduser(raw)) instead of hardcoded Unix
strings, and build raw paths with os.path.join so backslash-vs-slash
differences don't leak into assertions. Covers test_env_override, the
three new tests, and the legacy-alias test in test_config_extra.
2026-04-24 11:13:51 +02:00
Arnold Wender bcd07916a3 fix(security): normalize MEMPALACE_PALACE_PATH env var with abspath+expanduser
MEMPALACE_PALACE_PATH (and legacy MEMPAL_PALACE_PATH) read from the
environment was returned as-is from Config.palace_path, while the
sibling --palace CLI path gets os.path.abspath() applied at
mcp_server.py:62. That inconsistency means env-var callers can end
up with literal '~' or unresolved '..' segments in the path, which
(a) breaks user intuition and (b) lets a caller who can set env vars
on the target user's session redirect palace storage to an
unexpected location.

Apply os.path.abspath(os.path.expanduser(...)) to the env-var branch
so both code paths converge on the same resolved absolute path.

Closes #1163
2026-04-24 11:06:30 +02:00
Ben Sigman 8ac98f038c Merge pull request #1147 from MemPalace/fix/3.3.3-followups
fix(3.3.3): two followups from #1145 before tag cut
2026-04-24 00:07:12 -07:00
mvalentsev b291d2a240 ci: re-run to verify pip cache restore 2026-04-24 12:01:11 +05:00
mvalentsev e2444a190c ci: add pip caching and bump Python on macOS/Windows
Enable setup-python's built-in pip cache on all CI jobs to avoid
re-downloading ~300 MB of dependencies (chromadb, onnxruntime, hnswlib)
on every run.

Bump macOS and Windows from Python 3.9 to 3.11 -- Linux matrix already
covers 3.9 compatibility, and 3.11 is faster on these platforms.
2026-04-24 12:01:11 +05:00
copilot-swe-agent[bot]andigorls 1b1854e5ae fix(init): address registry review feedback
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/76794fde-2383-4674-ab36-f89ad803eeb2

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 05:25:34 +00:00
Igor Lins e Silva 4631d6a7db feat(init): wire confirmed entities into the miner's known-entities registry
The init step's output was a dead file. miner.py has always read
`~/.mempalace/known_entities.json` to tag drawer metadata with
recognized names, but nothing ever wrote it — so init's careful
manifest + git + LLM detection work stopped at `<project>/entities.json`
and never reached the path that actually uses it.

Measured delta on a representative prose snippet (eight sentences
mentioning six real people and four real projects):
- Empty registry: 0 entities recognized (multi-word names fail the
  frequency threshold; lowercase/hyphenated project names don't match
  the CamelCase regex).
- Registry populated by init: 12 entities recognized (all correct, zero
  false positives).

Every recognized name becomes a semicolon-separated metadata tag on the
drawer, which ChromaDB uses for entity-filtered search.

Implementation:

- `miner.add_to_known_entities({category: [names]})` reads the existing
  registry, unions each category (case-insensitively, preserving first-
  seen casing), and writes back. The function is tolerant of the two
  on-disk shapes miner already supports: list of names, or dict mapping
  name → code (dialect-style). In the dict case new names are added as
  keys with `None` values so existing codes aren't overwritten.
- Invalidates the in-process mtime cache so same-process callers
  (`cmd_init` → `cmd_mine` in one run) see the write immediately.
- Writes with `ensure_ascii=False` so non-ASCII names (Gergő Móricz,
  Arturo Domínguez, etc.) stay readable on disk.
- Chmods 0o600 — the registry mirrors confirm-step PII from the user's
  git authors and local paths.

cmd_init now calls this at the end of the confirm-entities step, after
the per-project `entities.json` is written (which is kept as an audit
trail the user can inspect or hand-edit). The per-project file is still
excluded from mining via `SKIP_FILENAMES` from the earlier fix.

17 new tests cover: fresh-file creation, list-category union, case-
insensitive dedup, preservation of untouched categories, dict-format
registries, malformed/non-dict file recovery, cache invalidation,
unicode round-trip, and an end-to-end verification that the miner's
`_extract_entities_for_metadata` picks up every registered name.
2026-04-24 02:09:32 -03:00
Igor Lins e Silva b150d33398 fix(mine): skip generated entities file 2026-04-24 01:42:19 -03:00
Igor Lins e Silva 035fe6d658 fix(llm): tighter refinement — word boundaries, JSON extraction, authoritative sources
Addresses issues found while reviewing the initial phase-2 implementation
against real data:

**Bug: uncertain bucket starved from the LLM.**
`discover_entities` was dropping the regex-uncertain bucket whenever real
git/manifest signal existed — which is exactly when `--llm` is most useful
for cleaning up prose noise. The uncertain candidates never reached the
refinement step. Fixed: only drop when `llm_provider is None`.

**Context collection: word boundaries, not substring.**
`_collect_contexts` used substring matching on lower-cased lines, so the
name "Go" matched "good", "going", "forgot". Switched to a
`(?<!\w)…(?!\w)` regex so short names only match at token boundaries.

**Authoritative-source detection replaces confidence threshold.**
Previously the refinement step skipped entries with `confidence >= 0.95`
to avoid second-guessing manifest-backed projects. That threshold was
fragile — the regex detector produces 0.99 confidence for things like
`code file reference (5x)` on framework names (OpenAPI, etc.), so those
skipped the LLM despite being regex-only noise. New helpers
`_is_authoritative_person` / `_is_authoritative_project` look at the
actual signal strings (commits, package.json, etc.) to decide.

**Now also refines regex-derived people.**
After #1148's high-pronoun-signal fix, the regex detector can promote
non-people to the `people` bucket (e.g. a capitalized common noun that
happened to appear near pronouns). The LLM now gets a chance to clean
those up, while git-authored people are still skipped.

**Robust JSON extraction.**
Small local models routinely wrap JSON output in prose ("Sure, here's
the classification: {…}"). The previous code-fence stripper failed on
that. `_extract_json_candidates` now does balanced-bracket extraction
with string-aware quote handling, so it recovers JSON from:
- raw responses
- markdown fenced blocks
- JSON embedded inside surrounding text
- multiple candidate objects/arrays

**Prompt guidance for frameworks vs user projects.**
Added an explicit instruction: frameworks, runtimes, APIs, cloud
services, and third-party vendors (Angular, OpenAPI, Terraform, Bun,
Google, etc.) are TOPIC unless the context clearly says it's the user's
own codebase. Directly addresses a false-positive pattern observed
during dev runs.

**Defensive mtime.**
`convo_scanner._safe_mtime` catches OSError during `stat()` — permission
changes, filesystem races, broken symlinks — and sorts the affected file
to the end of the newest-first order rather than crashing the scan.

**Cosmetic:** merged two adjacent f-strings on the same line in
`backends/chroma.py` and `llm_client.py` (no behaviour change).

15 new tests cover the OSError fallback, word-boundary matching, JSON
extraction variants, authoritative-source helpers, refining high-
confidence regex projects, and end-to-end LLM refinement preserving the
uncertain bucket.
2026-04-24 01:30:40 -03:00
shaun0927 659cb815ea fix(migrate): harden swap rollback against partial cross-device copy
shutil.move() can partially create palace_path before raising, which would
trip a bare os.replace(stale_path, palace_path) rollback (dest exists).

- Switch the primary swap to os.replace so same-filesystem moves stay atomic
- Branch on errno.EXDEV before falling back to shutil.move, so real errors
  (permissions, EIO) surface instead of silently attempting a slow copy
- Extract rollback into _restore_stale_palace which clears any partial
  destination and, if the restore itself fails, logs both stale_path and
  palace_path so the operator can recover by hand

Adds three regression tests covering clean rollback, partial-copy cleanup,
and logged failure on rollback-failure.

Flagged by the Qodo reviewer on #935.
2026-04-24 13:12:10 +09:00
copilot-swe-agent[bot]andigorls 9486d8b129 test(project-scanner): make gitdir fixtures portable
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3c277c46-20b3-4a43-8eb7-8ee2eb3cb55a

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 03:53:43 +00:00
copilot-swe-agent[bot]andigorls d4cc367261 test(project-scanner): harden git helper execution
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3c277c46-20b3-4a43-8eb7-8ee2eb3cb55a

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 03:52:37 +00:00
copilot-swe-agent[bot]andigorls ec9084f4d8 refactor(project-scanner): tidy manifest priority helpers
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3c277c46-20b3-4a43-8eb7-8ee2eb3cb55a

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 03:51:21 +00:00
copilot-swe-agent[bot]andigorls 851ebebc29 test(project-scanner): tighten git helper env handling
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3c277c46-20b3-4a43-8eb7-8ee2eb3cb55a

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 03:50:13 +00:00
copilot-swe-agent[bot]andigorls 70d4c5471e fix(project-scanner): address review feedback
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3c277c46-20b3-4a43-8eb7-8ee2eb3cb55a

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 03:48:47 +00:00
Igor Lins e Silva 36a8f219c2 feat(init): wire --llm flag and convo_scanner into discover_entities
Extends the init orchestrator to consume two new signal sources:

1. Claude Code conversation dirs: when the target is a
   `~/.claude/projects/` root, convo_scanner contributes ProjectInfo
   entries alongside the git/manifest projects. Dedup is by name,
   preferring the entry with more user-authored activity.
2. Optional LLM refinement: when --llm is passed, discover_entities
   constructs the provider, validates availability, and runs
   llm_refine.refine_entities on the merged candidates. Status
   summary (reclassified / dropped / cancelled / batch errors)
   prints to stderr.

New init flags (opt-in, default remains zero-API):
- --llm: enable refinement
- --llm-provider: ollama (default) | openai-compat | anthropic
- --llm-model: default gemma4:e4b for Ollama
- --llm-endpoint: URL (required for openai-compat)
- --llm-api-key: falls back to env ($ANTHROPIC_API_KEY or
  $OPENAI_API_KEY depending on provider)

Provider check_available runs before the scan, so the user sees an
immediate error ("Run: ollama pull <model>" or "ANTHROPIC_API_KEY not
set") rather than a mid-scan failure.
2026-04-24 00:47:14 -03:00
Igor Lins e Silva 10a743d5d8 feat(llm): interactive entity refinement with batching and cancellation
Takes the candidate set produced by phase-1 detection (manifests, git
authors, regex on prose) and asks an LLM to reclassify each candidate
as PERSON / PROJECT / TOPIC / COMMON_WORD / AMBIGUOUS.

Scale approach: never feed the raw corpus to the LLM. For each
candidate, collect up to 3 context lines from sampled prose, cap each
at 240 chars, batch 25 candidates per call. Keeps total input around
50-100K tokens even on large corpora and completes in a few minutes
on a 4B local model.

Interactive UX:
- Stderr progress bar with the current candidate name, updates
  per-batch.
- Ctrl-C interrupts cleanly: returns a RefineResult with
  `cancelled=True` and whatever was classified before the interrupt.
  The partial result is safe to pass straight to confirm_entities.
- Per-batch errors (transport, parse) are recorded in `errors` and
  don't abort the whole run.

Refinement scope: only `uncertain` and low-confidence `projects`
entries are sent. Manifest-backed projects (conf >= 0.95) and git-
authored people are already authoritative and skip the LLM.

Response parser is defensive — accepts `label` or `type` keys,
lowercase/uppercase variants, top-level list or wrapped object, and
strips markdown code fences. Unknown labels become AMBIGUOUS so the
user reviews them rather than silently accepting a bad classification.

`collect_corpus_text` provides a simple stratified prose sampler
(recent first, capped per-file) so callers don't need to build their
own corpus window.

28 tests with a FakeProvider (no network). Covers context collection,
prompt building, response parsing variants, classification apply,
end-to-end refine, and Ctrl-C partial-result behavior.
2026-04-24 00:46:59 -03:00
Igor Lins e Silva df6c7d0dc3 feat(llm): pluggable provider abstraction for entity refinement
Three providers cover the useful space while keeping the zero-API
default:

- `ollama` (default): local models via http://localhost:11434. Works
  fully offline. Tag-matching check accepts both `model` and
  `model:latest` forms.
- `openai-compat`: any /v1/chat/completions endpoint. Covers
  OpenRouter, LM Studio, llama.cpp server, vLLM, Groq, Together,
  Fireworks, and most self-hosted frameworks. API key falls back to
  $OPENAI_API_KEY. Endpoint normalization is forgiving about trailing
  `/v1`.
- `anthropic`: Messages API v2023-06-01. API key falls back to
  $ANTHROPIC_API_KEY. Concatenates multi-block text responses.

JSON mode is normalized across providers — Ollama uses
`format: "json"`, OpenAI-compat uses `response_format`, Anthropic uses
prompt-level instruction. Callers request JSON once; this module
handles the provider-specific plumbing.

No external SDK dependency; stdlib `urllib` throughout. HTTP errors
are wrapped into a single `LLMError` class so callers don't need to
distinguish transport, auth, and parse failures at the call site.

26 tests, all with mocked HTTP — suite runs offline with no real
provider required.
2026-04-24 00:46:43 -03:00
Igor Lins e Silva c7bd2cd8e4 feat(convo): parse Claude Code conversation dirs into project entities
Claude Code stores sessions under `~/.claude/projects/<slug>/<id>.jsonl`
where `<slug>` is the original CWD with `/` replaced by `-`. That
encoding is lossy — can't distinguish `foo-bar` (one segment) from
`foo/bar` (two) — so slug-decoding alone produces wrong names for any
hyphenated project.

Fortunately, every message record carries a `cwd` field with the true
path. This scanner reads one record per session to recover the
accurate project name deterministically, falling back to slug-decoding
only if the JSONL is malformed or empty.

Output shape matches project_scanner.ProjectInfo so the discover
orchestrator can union results across sources. Session count doubles
as a density signal for ranking.

22 unit tests cover: root detection, cwd extraction with malformed
input tolerance, fallback slug decoding, name resolution using the
newest session (so renames win), and dedup when two encoded dirs
resolve to the same project.
2026-04-24 00:46:31 -03:00
Igor Lins e Silva 14d7444abe fix(deps): add tomli fallback for Python <3.11
`tomllib` is stdlib only in Python 3.11+. On Python 3.9/3.10 (and the
macOS runner) the scanner's toml parsing returned empty, so manifest
lookups for `pyproject.toml` / `Cargo.toml` produced no name. CI
surfaced this via 4 test_project_scanner.py failures on the 3.9 matrix.

Add `tomli>=2.0.0` as a conditional dependency for `python_version <
'3.11'` and fall back to it in `project_scanner.py`. The project still
declares `requires-python = ">=3.9"` so the fallback is the correct
shape.
2026-04-24 00:27:09 -03:00
Igor Lins e Silva 9e7fa1ceb5 feat(init): scan manifests and git authors for real entity signal
`mempalace init` previously leaned entirely on regex-based entity
extraction from prose. That path works for text-only folders but wastes
signal in any codebase: the project's own name is already in
`package.json` / `pyproject.toml` / `Cargo.toml` / `go.mod`, and the
people who worked on it are in `git log`.

This adds `project_scanner.py`, which becomes the primary signal source
when real signal is available, with the regex detector preserved as the
fallback for prose-only folders (diaries, research notes, writing).

What it does:
- Walks the target directory, parses manifests for canonical project
  names, and detects git repos by the presence of a `.git` directory.
- For each repo, reads `git log` for authors and filters obvious bots
  (`[bot]`, `dependabot`, `renovate`, `github-actions`, names ending in
  `bot`, `-autoroll`). Importantly does NOT filter
  `@users.noreply.github.com` - that's GitHub's privacy-protected human
  email, used by real contributors.
- Resolves author aliases with a union-find: commits that share a name
  OR an email collapse into one person. Picks the most-frequent
  real-name variant as display, ignoring handles and single-token
  usernames.
- Flags "mine" projects: user is top-5 committer OR has >=10% of
  commits OR >=20 commits. Ordered by user_commits in the UX.
- `discover_entities()` merges scanner results with the regex detector
  case-insensitively (so `mempalace` from pyproject absorbs `MemPalace`
  from docs), and suppresses the regex `uncertain` bucket when real
  signal is already found - the user doesn't need to adjudicate prose
  noise when the answer is already in git.

Integration: `cmd_init` now calls `discover_entities` instead of
running the regex detector directly. Same output shape, so
`confirm_entities` works unchanged.

Ships with 39 new tests covering manifest parsing, bot filtering,
union-find dedup, git repo discovery, scan integration, and
merge/fallback behavior. Existing 56 regex-detector tests all pass.
2026-04-24 00:20:53 -03:00
Igor Lins e Silva 6aebf458ff fix(entity): reduce noise in regex-based detection
The pattern-matching detector had several systematic false positives that
crowded the init review with nonsense. Concrete fixes:

- CamelCase extraction: add `[A-Z][a-z]+(?:[A-Z][a-z]+|[A-Z]{2,})+` to
  candidate patterns so `MemPalace`, `ChromaDB`, `OpenAI`, `ChatGPT` are
  visible. Previously `MemPalace` fragmented into `Mem` + `Palace`.
- Dialogue `^NAME:\s` requires >=2 matches to count. A single metadata
  line like `Created: 2026-04-21` was scoring as dialogue and classifying
  `Created` as a person.
- Versioned/hyphenated pattern tightened to `\b{name}[-_]v?\d+(?:\.\d+)*\b`
  (version-only). The previous `\b{name}[-v]\w+` matched `context-manager`,
  `multi-word`, etc. - every hyphenated compound.
- Skip LICENSE/COPYING/NOTICE/AUTHORS/PATENTS files during scan. They
  produce pure-English-prose noise (`Contributor`, `Software`, `Covered`,
  `Before`).
- Extra SKIP_DIRS: `.terraform`, `vendor`, `target`.
- Expand stopword list with capitalized participles/descriptors that
  commonly appear at sentence start: `created`, `updated`, `extracted`,
  `processed`, `total`, `summary`, `auto`, `multi`, `hybrid`, `context`,
  `bridge`, `batch`, `local`, `native`, `never`, `before`, `after`, etc.
- classify_entity: high-pronoun single-category signal now classifies as
  person. A diary's main character gets referenced with pronouns, not
  dialogue markers - requiring two signal categories demoted `Lu` (16
  pronoun hits across 30 mentions) to uncertain. Gate on
  `pronoun_hits >= 5 AND pronoun_hits / frequency >= 0.2` so common
  sentence-start words (`Never`, `Before`) with incidental proximity
  stay uncertain.
2026-04-24 00:20:32 -03:00
Igor Lins e Silva 6fcfd34aa4 docs(changelog): log #1145 fixes in 3.3.3 section
Two follow-up fixes from the v3.3.3 smoke test get folded into 3.3.3
before the tag is cut. Also syncs uv.lock with the 3.3.3 version
bump merged via #1144.
2026-04-23 23:39:41 -03:00
Igor Lins e Silva 1fd16daac2 fix(mcp): diary_read(wing='') spans all wings for agent (#1145)
#1097 fixed mempalace_search to treat empty-string wing/room as
no filter, matching how LLM agents default to filling every optional
parameter with ''. The same pattern wasn't applied to diary_read:
passing wing='' defaulted to wing_<agent_name>, siloing away entries
that hooks had written to project-derived wings per #659.

When wing is empty/omitted, filter only on agent + room=diary so
callers get a unified view of the agent's journal across every wing
it has written to. Explicit wing=<name> continues to scope reads
to that wing only.

Adds test covering empty-wing read after writing to both the default
and a non-default wing.
2026-04-23 23:39:34 -03:00
Igor Lins e Silva d1583750e8 fix(hooks): derive project wing from non-macOS transcript paths (#1145)
_wing_from_transcript_path only matched '-Projects-<name>' segments,
so Linux users with code under ~/dev/, ~/code/, or ~/src/ fell through
to the wing_sessions fallback and lost the per-project diary scoping
introduced in #659.

Broaden the heuristic to derive the project from the final
dash-separated token of the encoded project-folder name under
.claude/projects/. Keeps the legacy -Projects- regex as a secondary
match for transcripts living outside the standard Claude Code path.

Covers macOS Users layout, Linux dev/code layouts, and deeper nested
source paths while preserving existing Projects/ behavior.
2026-04-23 23:39:23 -03:00
Igor Lins e Silva 6d252a0de4 Merge pull request #1144 from MemPalace/chore/release-3.3.3-prep
release: v3.3.3 — restore install integrity
2026-04-23 21:56:02 -03:00
bensig 4f799afd76 release(3.3.3): bump README version badge
test_readme_badge_matches_version_py compares the version in the
README badge URL against version.py. Missed it in the initial bump.
2026-04-23 16:51:41 -07:00
bensig 102372b179 release: v3.3.3
Restore-integrity release. Unbreaks fresh `pip install mempalace` from
v3.3.2 by re-tagging current develop, which carries both the plugin.json
consumer (shipped in 3.3.2) and the matching mempalace-mcp entry point
in pyproject.toml (added on develop ~10h after the 3.3.2 tag via #340
by @messelink). #1093 diagnosed by @jphein.

Bumps (all 5 sources agree per Version Guard / CLAUDE.md):
- mempalace/version.py              3.3.2 → 3.3.3
- pyproject.toml                     3.3.2 → 3.3.3
- .claude-plugin/plugin.json         3.3.2 → 3.3.3
- .claude-plugin/marketplace.json    3.3.2 → 3.3.3
- .codex-plugin/plugin.json          3.3.2 → 3.3.3
- CHANGELOG.md                        new [3.3.3] entry

No code changes. The fix for #1093 is already on develop via merged PRs
#340, #1021, #851, #942, #833, #673, #661, #659, #1097, #1051, #1001,
#945.

Branch name intentionally outside the `release/*` ruleset so follow-up
CI-fix commits aren't gated behind a nested PR. (Supersedes #1143 —
closed for exactly that reason after it missed 3 of 5 version files.)

Smoke-tested locally from a fresh develop clone:
  grep mempalace-mcp pyproject.toml .claude-plugin/plugin.json   # both ✓
  python -m build --wheel                                        # ✓
  pip install …-py3-none-any.whl                                 # ✓
  which mempalace-mcp                                            # ✓
  mempalace-mcp --help                                           # ✓
2026-04-23 16:44:22 -07:00
Kunal Garhewal 9947ad06df fix: treat empty string as no filter in mempalace_search wing/room (#1097)
* fix: treat empty string as no filter in mempalace_search wing/room

* fix: also treat whitespace-only strings as no filter
2026-04-23 15:19:18 -07:00
Jeffrey HeinandClaude Sonnet 4.6 df3ee289fc fix: add wing param to diary_write/diary_read, derive from transcript path (#659)
* fix: add wing param to diary_write/diary_read, derive from transcript path

Without a wing override, all diary entries from the stop hook land in
wing_session-hook regardless of which project the session is in, making
per-project diary search impossible.

- tool_diary_write(): add optional `wing` param; sanitize and use it when
  provided, fall back to wing_{agent_name} when omitted
- tool_diary_read(): add optional `wing` param for filtering by target wing
- TOOLS dict: expose `wing` in input_schema for both diary tools
- hooks_cli: add _wing_from_transcript_path() helper that extracts the
  project name from Claude Code paths like
  ~/.claude/projects/-home-jp-Projects-kiyo-xhci-fix/... → kiyo-xhci-fix
- hook_stop: derive project wing and append wing= hint to block reason so
  Claude writes diary entries to the correct per-project wing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: sanitize wing param, cross-platform paths, tighten test assertions

Addresses Copilot review feedback on #659.

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

* fix: wing_ prefix + agent filter on diary_read

Addresses bensig's 2-issue review on this PR.

1. _wing_from_transcript_path() was returning bare project names
   (e.g. "myproject") while all existing wings follow the wing_*
   convention from AAAK_SPEC. Entries landed in wing="myproject"
   while diary_read defaulted to wing="wing_<agent_name>" —
   orphaning every diary entry written by the stop hook. Now
   returns "wing_<project>" and falls back to "wing_sessions".

2. tool_diary_read() did not include agent_name in the ChromaDB
   where filter when a custom wing was provided — any caller with
   a shared wing could read entries written by other agents.
   Add {"agent": agent_name} to the $and clause. Also flagged by
   Qudo and left unresolved until now.

Tests updated to expect the wing_ prefix (6 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 15:07:25 -07:00
Ben Sigman 818b7f4d48 Merge pull request #1118 from MemPalace/ben/crystal-lattice-brand
feat(website): Crystal Lattice brand — fonts, palette, hero copy
2026-04-22 22:15:40 -07:00
bensigandClaude Opus 4.6 4f00390335 feat(website): apply Crystal Lattice brand — fonts, palette, hero copy
- Swap Inter/Cormorant Garamond/Geist → Neue Machina/Satoshi/Onest (all free/web)
- Align color palette to Crystal Lattice decision (0002): void #080C18, cyan-vivid #38BDF8, ice #DBE7F5
- Update hero: "Memory *is* identity." with italic blue "is", white "identity"
- New hero subtext: "Every conversation, every idea, every small decision… held somewhere safe."
- JetBrains Mono unchanged (already OFL)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 20:24:39 -07:00
Sathvik-1007 c2e053176c fix: add total count to tool_list_drawers pagination response
The list_drawers response only included count (current page size) with
no total field, making it impossible for callers to know when pagination
is exhausted. A page returning count == limit is ambiguous — it could
be the last exact-fit page or there could be more results.

Add a total field that reports the full number of matching drawers.
For unfiltered requests this uses col.count(); for filtered requests
(wing/room) it uses a lightweight col.get(include=[]) to count
matching IDs without fetching documents.
2026-04-23 01:40:38 +05:30
wahajahmed010 b595cb3978 docs: fix HOOKS_TUTORIAL.md paths, matcher, and missing timeout
Fixes four issues causing silent hook failures:

1. **Relative paths** → Absolute paths (/absolute/path/to/hooks/...)
   Claude Code resolves hooks from working directory, not repo root.

2. **Wrong matcher** → Stop uses *, PreCompact has no matcher
   PreCompact doesn't use matcher (only Stop hooks do).

3. **Missing timeout** → Added timeout: 30 to both hooks
   Matches hooks/README.md specification.

4. **Ambiguous target** → Specified ~/.claude/settings.local.json
   Clarified global vs project-scoped config.

Also added executable chmod instructions and path replacement note.

Fixes #1037
2026-04-22 11:31:40 +02:00
Ben Sigman 9b35d9f760 Merge pull request #661 from jphein/perf/graph-cache
Re-reviewed: both requested changes addressed (threading.Lock on cache globals, col-ignoring documented). Merging.
2026-04-21 17:38:31 -07:00
Ben Sigman 23ee2a02fd Merge pull request #673 from jphein/feat/deterministic-hook-save
Clean squash by jphein on 2026-04-21. Backwards-compatible via hook_silent_save config flag. Save marker now only advances after confirmed write — strictly safer than status quo.
2026-04-21 17:38:21 -07:00
Ben Sigman 02aafc0715 Merge pull request #1021 from jphein/upstream-fix/silent-save-visibility
silent_guard default fixed to True per 2026-04-19 review. ImportError/AttributeError handling narrowed. CI all green.
2026-04-21 17:38:13 -07:00
Ben Sigman 810f9a56c4 Merge pull request #851 from vnguyen-lexipol/fix/status-paginate-large-palaces
Merging to fix active pagination crash — 3 users hit this on v3.3.2 in the last 20 hours (54K, 114K, 465K drawer palaces). None-guard regression fixed. 9-day production soak on 165K-drawer palace confirms stability.
2026-04-21 17:38:04 -07:00
jp 74e9cbcfd3 feat: deterministic hook saves — zero data loss via silent Python API
Adds a `hook_silent_save` mode (default `true` in new installs) where
the stop and precompact hooks write diary entries directly via the
Python API — no AI block, no MCP tool roundtrip, no possibility of the
AI forgetting or ignoring the save instruction.

**Two modes, controlled by `hook_silent_save` in `~/.mempalace/config.json`:**

1. **Silent mode** (default): Direct call to `tool_diary_write()`. Plain
   text, no AI involved, deterministic. Save marker advances only after
   the write is confirmed, so mid-save failures do not lose exchanges.
   Shows `"✦ N memories woven into the palace"` as a systemMessage
   notification so the user knows the save fired.

2. **Block mode** (legacy): Returns `{"decision": "block"}` asking the
   AI to call the MCP tool chain. Non-deterministic — the AI may ignore,
   summarize lossy, or fail. Kept for backward compatibility.

**Extras rolled in:**
- Block reasons name "MemPalace" explicitly and instruct the AI not to
  write to Claude Code's native auto-memory (.md files) — prevents the
  two memory systems from stepping on each other.
- Codex transcript handling (`event_msg` payloads) in
  `_count_human_messages` + `_extract_recent_messages`.
- Tightened stopword leak in diary summaries; docstring polish; test
  hermeticity fixes (per-test `STATE_DIR` patching).

**Tests:** hooks_cli tests cover silent-save path, save-marker
advancement after confirmed write only, and systemMessage formatting.

Rebased fresh on upstream/develop. Only touches files germane to the
feature (hooks_cli.py, tests, hooks/README.md, HOOKS_TUTORIAL.md) —
stale fork-local `.sh` wrapper and plugin manifest changes dropped.
2026-04-21 13:20:52 -07:00
alonehobo 35b033d77f fix(mcp): force UTF-8 on stdio to fix -32000 on non-ASCII payloads
On Windows, Python defaults sys.stdin/sys.stdout to the system codepage
(e.g. cp1251 on Russian locales, cp1252 on Western European), while MCP
JSON-RPC is always UTF-8. Non-ASCII payloads (Cyrillic, CJK, accented
European) get mis-decoded before reaching handlers, causing json.loads
to fail or tool handlers to receive garbled strings. Both surface to
the client as a generic MCP error -32000.

Reproduction:
  1. On Windows with a non-Latin locale, call mempalace_add_drawer or
     mempalace_kg_add with Cyrillic/CJK in content or KG object.
  2. Server returns: MCP error -32000: Internal tool error.
  3. Calling the handler directly from Python works fine -- the bug is
     purely in the stdio transport.

Fix:
  Reconfigure stdin/stdout to UTF-8 at the start of main(), after
  _restore_stdout(). Uses errors="replace" defensively so a lone bad
  byte cannot take down the server. Guarded by hasattr(reconfigure)
  for exotic stream replacements.

This matches the behaviour of PYTHONUTF8=1 / python -X utf8 without
requiring users to set an env var.
2026-04-21 12:33:58 +05:00
Igor Lins e Silva 1b00f93b37 Merge pull request #833 from MemPalace/fix/hooks-python-resolution
fix(hooks): real python-resolution for .sh hooks, with MEMPAL_PYTHON override
2026-04-21 01:47:50 -03:00
Igor Lins e SilvaandMSL 48eb6271a7 fix(hooks): MEMPAL_PYTHON override for .sh hooks' internal python3 calls
The legacy hook scripts `hooks/mempal_save_hook.sh` and
`hooks/mempal_precompact_hook.sh` shell out to `python3` for JSON
parsing and transcript-message counting. On macOS GUI launches of
Claude Code — `open -a`, Spotlight, the dock — the harness inherits
`PATH` from launchd (`/usr/bin:/bin:/usr/sbin:/sbin`), which may not
contain a `python3` at all, or may contain only a system Python that
lacks what the hook needs. The hook then fails silently in the
background log where users never look.

`mempalace` auto-ingest itself is unaffected — #340 switched that
path to the `mempalace` CLI entry point, which pipx/uv install on a
stable global PATH.

This PR adds a `MEMPAL_PYTHON` environment variable that users can
set to point the hook at any Python 3 interpreter. Resolution order
applied at each `python3` invocation site inside the two hooks:

  1. $MEMPAL_PYTHON (if set and executable)
  2. $(command -v python3) on PATH
  3. bare `python3` as a last resort

The interpreter does not need `mempalace` installed in it — only the
standard-library `json` and `sys` modules. The hook's `mempalace mine`
call runs via the CLI, independent of this override.

hooks/README.md documents the macOS GUI PATH issue and the
MEMPAL_PYTHON override. tests/test_hooks_shell.py adds 3 regression
tests (Linux/macOS only, POSIX bash):

  - MEMPAL_PYTHON override wins over PATH (proved via a
    marker-emitting shim that proxies to the real interpreter).
  - Non-executable MEMPAL_PYTHON falls back to PATH rather than
    crashing on permission denied.
  - Unset MEMPAL_PYTHON resolves via PATH.

`hooks_cli.py` (the Python implementation invoked via
`mempalace hook run ...`) already uses `sys.executable` and is
therefore trivially correct — no changes needed there.

Supersedes abandoned branch `fix/hook-bugs`.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-21 01:43:08 -03:00
Igor Lins e Silva 5522d348a5 Merge pull request #340 from messelink/fix/mcp-pipx-compat
fix: add mempalace-mcp entry point for pipx/uv compatibility
2026-04-21 01:36:51 -03:00
Pim MesselinkandClaude Opus 4.6 9e53228ea3 test: update test_cli assertions for mempalace-mcp entry point
Three assertions in test_mcp_command_* were still checking for the old
`python -m mempalace.mcp_server` output string. Update to match the new
`mempalace-mcp` command printed by cmd_mcp().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 01:26:47 -03:00
Pim MesselinkandClaude Opus 4.6 982d421510 fix: update mempalace mcp command to use mempalace-mcp entry point
cmd_mcp() in cli.py was still printing `python -m mempalace.mcp_server`
as the setup command. Update to use the mempalace-mcp console entry
point added in the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 01:26:47 -03:00
Pim MesselinkandClaude Opus 4.6 67a067701e fix: use mempalace CLI in top-level hook scripts
hooks/mempal_precompact_hook.sh and hooks/mempal_save_hook.sh used
python3 -m mempalace mine which fails when mempalace is installed via
pipx or uv. Switch to the mempalace CLI entry point which pipx/uv put
on PATH. Also removes the now-unused PYTHON variable from mempal_save_hook.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 01:26:47 -03:00
Pim MesselinkandClaude Opus 4.6 be89e49add fix: use mempalace CLI in hook scripts instead of python3 -m
Hook scripts used `python3 -m mempalace` which fails when mempalace is
installed via pipx or uv. Using the `mempalace` CLI command directly
works for all installation methods. Dev users running from source should
use `pip install -e .` as documented in CONTRIBUTING.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 01:26:35 -03:00
Pim MesselinkandClaude Opus 4.6 9f5b8f5fd6 fix: add mempalace-mcp console entry point for pipx/uv compatibility
The MCP server config used `python -m mempalace.mcp_server` which fails
when mempalace is installed via pipx or uv, since the system python
cannot find the module in the isolated venv. Adding a `mempalace-mcp`
console_scripts entry point ensures the MCP server works regardless of
installation method (pip, pipx, uv, conda).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 01:26:00 -03:00
Igor Lins e Silva 4fb0ee57e7 Merge pull request #942 from fatkobra/fix-hooks-resolve-claude-plugin
fix(hooks): resolve Claude plugin hook runner cross-platform
2026-04-21 01:21:36 -03:00
Igor Lins e Silva 1a180cd7cb Merge pull request #1051 from itfarrier/feat/i18n-belarusian
feat(i18n): add Belarusian
2026-04-21 01:09:54 -03:00
Igor Lins e Silva 6d42f61e64 Merge pull request #1001 from mvalentsev/feat/i18n-de-es-fr-entity
feat(i18n): add entity detection to German, Spanish, and French locales
2026-04-21 00:55:33 -03:00
Igor Lins e Silva 2a5914b630 Merge pull request #945 from lmanchu/feat/zh-entity-detection
feat(i18n): add Traditional + Simplified Chinese entity detection
2026-04-21 00:55:04 -03:00
Ben Sigman 87102fb188 Merge pull request #1041 from MemPalace/release/3.3.2 2026-04-20 11:46:51 -07:00
Dzmitry Padabed 54c314d8d9 feat(i18n): add Belarusian 2026-04-20 21:00:39 +03:00
jp d657626736 style: ruff format — collapse AttributeError log call to single line 2026-04-19 08:34:43 -07:00
jpandClaude Opus 4.7 2629ae5b71 fix(hooks): default silent_guard=True — config-read failure must not suppress saves
Addresses bensig's review on PR #1021.

silent_guard was initialized to False, so when both MempalaceConfig
import and .hook_silent_save attribute access failed, silent_guard
stayed False. Then `if not silent_guard:` fired and returned empty —
silently dropping the save. In silent mode (the default since v3.3.0),
saves should ALWAYS proceed on config-read failure. Changing the
initial value to True makes that the safe default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:23:02 -07:00
fatkobra 0b316d4053 test: normalize wrapper script path for bash on Windows 2026-04-19 10:34:11 +02:00
eldar702 4949aab68b fix: guard None metadata/doc in tool_check_duplicate and Layer1/Layer2
Chroma 1.5.x can return ``None`` inside the ``metadatas`` / ``documents``
lists of a query/get result for partially-flushed rows. The codebase
already has a systemic None-guard pattern (merged #999, #1013, #1019)
but three call sites were still unguarded:

* ``mcp_server.tool_check_duplicate`` (``mcp_server.py:487-488``) —
  ``meta = results["metadatas"][0][i]`` followed by ``meta.get(...)``
  raises ``AttributeError: 'NoneType' object has no attribute 'get'``.
  The broad ``except Exception`` wrapper (line 504) swallows it and
  returns an uninformative ``"Duplicate check failed"``.

* ``layers.Layer1.generate`` (``layers.py:126``) — iterates
  ``zip(docs, metas)`` and calls ``meta.get(key)`` in the importance
  loop. A single None metadata blows up the entire wake-up render.

* ``layers.Layer2.retrieve`` (``layers.py:224``) — same pattern, same
  crash path for the on-demand render.

Apply the same ``meta = meta or {}`` / ``doc = doc or ""`` idiom used
by the merged guards in the search path. Three-line additions, no
behaviour change on well-formed results.

Tests added:

* ``test_check_duplicate_handles_none_metadata`` — mocks the collection
  query to return ``None`` for one metadata and document, asserts the
  call does not crash and the sentinel-rendered entry has wing/room "?"
  and empty content.
* ``test_layer1_handles_none_metadata`` / ``_handles_none_document``
* ``test_layer2_handles_none_metadata``

Relationship to other open PRs:

* **#1019** guarded ``searcher.py`` loops. This PR extends the same
  guard to the three call sites #1019 did not touch.
* **#979** fixed ``tool_check_duplicate`` negative similarity but left
  the None-metadata path unguarded.
* Does not overlap **#1013** (``Layer3.search_raw``) or **#999**.
2026-04-19 11:13:50 +03:00
Vu Nguyen 5d2da04bcd Merge remote-tracking branch 'upstream/develop' into fix/status-paginate-large-palaces
# Conflicts:
#	mempalace/miner.py
2026-04-19 02:02:28 -05:00
Vu Nguyen 3004ac4ee4 fix(miner): port None-metadata guard into paginated status loop
Upstream develop commit feba7e8 (2026-04-18) added `m = m or {}` to the
single-shot `for m in metas:` loop after this branch already rewrote
status() to paginate. Without porting the guard forward, merging this PR
would silently drop jp's fix and crash again on palaces with null-metadata
drawers.

Addresses bensig's review on #851.
2026-04-19 01:35:24 -05:00
jpandClaude Sonnet 4.6 2183d866f3 style(hooks): ruff format hooks_cli.py and test_hooks_cli.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 18:09:29 -07:00
jpandClaude Sonnet 4.6 1531a253be test: add missing import os in test_hooks_cli
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 18:05:18 -07:00
jpandClaude Sonnet 4.6 5deb815f0b fix(hooks): address Copilot review feedback on #1021
- _output(): use sys.modules.get() instead of unconditional import to
  avoid triggering mcp_server's stdout redirect as a side effect
- _output(): write-all loop for os.write() to handle partial writes and
  EINTR; fall back to sys.stdout.buffer on OSError
- _output() docstring: remove inaccurate _save_diary_direct reference
- stop_hook_active guard: narrow except to ImportError/AttributeError,
  default silent_guard=False (safe: preserves block-mode loop prevention
  when config load fails) and log a warning instead of silently changing
  behavior
- tests: two new regression tests covering the real-stdout-fd path and
  the fd-1 fallback path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 18:05:18 -07:00
jpandClaude Opus 4.7 6a3a5c7a3d fix(hooks): write hook JSON to real stdout, bypassing mcp_server redirect
mempalace.mcp_server redirects stdout → stderr at module-level import
(both Python-level and fd-level via os.dup2) to protect the MCP stdio
protocol from ChromaDB's C-level noise. Silent-save imports mcp_server
transitively via _save_diary_direct, so by the time _output() calls
print(), sys.stdout is actually stderr.

Claude Code reads hook output from fd 1. With the redirect in effect,
fd 1 points to fd 2, so our {"systemMessage": "✦ N memories woven..."}
JSON lands on stderr and Claude Code never renders it. The save still
happens, the marker still advances — the user just never sees the
beautiful checkpoint notification in their terminal.

Fix: _output() now writes to _REAL_STDOUT_FD (saved by mcp_server before
the redirect) via os.write(), falling back to sys.stdout only when the
saved fd is unavailable (e.g., hooks_cli imported without mcp_server).

Test: bash hook script 2>/dev/null now shows only the JSON;
2>&1 >/dev/null shows only the Diary entry log line — clean separation
restored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:05:18 -07:00
jpandClaude Opus 4.7 914945637c fix(hooks): honor silent_save when stop_hook_active is set
Claude Code 2.1.114 passes stop_hook_active:true on every Stop fire
after the first in a session (plugin-dispatched hooks in particular).
The legacy guard at line 426 was written for block-mode, where a
re-fire with the flag set meant "you already blocked, don't block
again" — correct loop prevention when the hook returns
{"decision":"block"}.

Silent-save mode (default since #673) never blocks — it saves
directly and returns. The flag is meaningless there, so the old
guard was suppressing every auto-save after the first one in a
Claude Code session. Symptom: terminal never shows the "✦ N
memories woven" notification again, hook.log stays silent, save
marker stuck.

Fix: only skip on stop_hook_active when block mode is configured.
Silent mode runs through as normal — the save is deterministic and
idempotent, no loop risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:05:18 -07:00
mvalentsev 5189e0d652 test(i18n): add entity section smoke tests and schema invariants 2026-04-18 21:58:11 +05:00
mvalentsev 118cbe40bd feat(i18n): add entity detection to French locale 2026-04-18 21:56:45 +05:00
mvalentsev e17f219be8 feat(i18n): add entity detection to Spanish locale 2026-04-18 21:54:39 +05:00
mvalentsev 7006a6b42d feat(i18n): add entity detection to German locale 2026-04-18 21:53:11 +05:00
dependabot[bot] 0e632df85d chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [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/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 07:56:09 +00:00
dependabot[bot] 04d80eb363 chore(deps): bump actions/upload-pages-artifact from 3 to 5
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 07:56:05 +00:00
dependabot[bot] c942f5866c chore(deps): bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 07:56:02 +00:00
Lman ChuandClaude c88b8a2e17 style: fix ruff format for test_entity_detector.py
Collapse implicit string concatenation to single-line strings
to satisfy ruff format --check in CI.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-17 06:40:41 +08:00
jpandClaude Opus 4.6 8adf35a13c fix: add threading lock to graph cache, expand docstring
Address review feedback from @bensig:
1. Wrap cache reads/writes in threading.Lock for thread safety
2. Promote the col-arg caveat from inline comment to docstring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 09:00:36 -07:00
jpandClaude Opus 4.6 1657a79649 fix: clarify cache docs, skip caching empty graphs
Addresses Copilot review feedback on #661.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 09:00:27 -07:00
jp 84e2aa16e4 perf: graph cache with write-invalidation in build_graph()
build_graph() scans every drawer's metadata in 1000-item batches on
every call — O(n) per graph build with no caching. At 50K+ drawers
this costs several seconds per MCP tool call (traverse, find_tunnels,
graph_stats all call build_graph on every invocation).

Add a module-level cache (nodes + edges + timestamp) with a 60-second
TTL. Cache is invalidated via invalidate_graph_cache(), exported for
write operations to call. Tests updated with setup_method cache resets
and two new tests verifying cache hit and invalidation behaviour.
2026-04-16 09:00:27 -07:00
Lman Chu 683e940f70 feat(i18n): add Traditional + Simplified Chinese entity detection
zh-TW and zh-CN previously had no `entity` section. Calling
`detect_entities(..., languages=("zh-TW",))` silently fell back to
English patterns (i18n/__init__.py:231-233), so no Chinese names
were ever extracted — Chinese-speaking users got zero people or
projects detected from their own notes.

This adds entity sections for both locales:

- `candidate_pattern`: common-surname-prefixed CJK n-grams (~100
  surnames covering >95% of Taiwanese / PRC names), length capped
  at {1,2} trailing chars so greedy matches don't swallow the
  trailing verb character (e.g. 朱宜振說).
- `boundary_chars`: `\u4E00-\u9FFF` so the i18n loader's
  script-aware wrap (introduced in #932) fires `\b` at CJK↔non-CJK
  transitions. This is the same mechanism used for Devanagari,
  applied to the CJK range.
- `person_verb_patterns`: Chinese verbs attach directly to the
  name with no whitespace, so patterns are written as `{name}說`,
  `{name}問`, `{name}決定` — no `\b` or `\s+` separators.
- `dialogue_patterns`: full-width colon `:`, Chinese quotes
  「」『』, plus the standard Latin forms.
- `pronoun_patterns`: 他 / 她 / 它 / 他們 / 她們 / 您 / 咱.
- `stopwords`: ~140 common particles, pronouns, time expressions,
  question words, conjunctions, UI nouns, and politeness forms.

**Known limitation** (explicitly covered by a test): CJK scripts
have no word delimiters, so a name flanked by CJK on both sides
with no punctuation or whitespace break is not extracted. This
is a fundamental limit of regex-based CJK entity detection —
resolving it would require a dictionary tokeniser. Realistic
Chinese technical writing contains enough non-CJK neighbours
(bullet lines, inline English, full-width punctuation, newlines)
that 3+ occurrences normally produce matches. Verified against a
realistic zh-TW PKM note: 朱宜振 extracted 11x from 8 sentences
with 0.99 person-classification confidence.

**Follow-ups** (separate PRs): same pattern for `ja` and `ko`,
both of which currently share the silent fallback-to-English bug.

Tests: 7 new tests in `tests/test_entity_detector.py`:
- `test_zh_tw_candidate_extraction_at_boundaries`
- `test_zh_tw_person_classification`
- `test_zh_tw_stopwords_filter_common_particles`
- `test_zh_tw_falls_back_to_english_for_non_cjk_names`
- `test_zh_cn_candidate_extraction`
- `test_zh_cn_and_zh_tw_union_covers_both_variants`
- `test_zh_tw_known_limitation_inline_name_no_boundary`

Full suite: 957 passed, 0 failed.
2026-04-16 17:43:09 +08:00
fatkobra 1dc55a791d test: make Claude plugin wrapper tests portable on Windows 2026-04-16 11:41:53 +02:00
fatkobra be9214a190 Update mempal-precompact-hook.sh 2026-04-16 10:42:20 +02:00
fatkobra 5fe0c1c2ac Update mempal-stop-hook.sh 2026-04-16 10:33:34 +02:00
fatkobra e083cd6c84 Create test_claude_plugin_hook_wrappers.py 2026-04-16 10:32:17 +02:00
JunghwanNA fb1cf53919 fix: harden repair backup scope and migrate swap rollback
- repair.py: define backup_path before the conditional block so it is
  always in scope when the except handler references it
- migrate.py: restore old palace from .old if both os.rename and
  shutil.move fail during the swap step
2026-04-16 14:04:26 +09:00
JunghwanNA 5bf826046c fix: sanitize topic parameter in tool_diary_write
agent_name and entry are validated via sanitize_name/sanitize_content,
but topic is stored raw into ChromaDB metadata. Apply the same
sanitize_name guard to reject null bytes, path traversal, and
oversized payloads.
2026-04-16 12:12:17 +09:00
JunghwanNA 5dfe853154 fix: guard against data loss in repair, migrate, and CLI rebuild
- repair.py: wrap upsert loop in try/except; restore from backup on
  failure instead of leaving a partially rebuilt collection
- migrate.py: replace non-atomic rmtree+move with rename-aside swap
  so a crash between the two calls does not destroy both copies
- cli.py: use offset += len(batch["ids"]) with empty-batch guard
  instead of fixed offset += batch_size to prevent skipping drawers
2026-04-16 12:11:18 +09:00
Vu Nguyen c924d07b3e fix(status): paginate metadata fetch to support large palaces
`col.get(limit=total)` causes SQLite "too many SQL variables"
on palaces with >10k drawers (#802) and on older versions the
hardcoded limit=10000 silently truncated the count (#850).

Paginate in 5k batches using offset and aggregate wing/room
counts incrementally. Also use `col.count()` for the header
instead of `len(metas)` so the displayed total is always correct.

Tested on a 122,686-drawer palace.

Fixes #850
Related: #802, #723
2026-04-13 22:30:56 -05:00
134 changed files with 27020 additions and 1030 deletions
+1 -5
View File
@@ -1,9 +1,5 @@
{
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
"command": "mempalace-mcp"
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ claude plugin add /path/to/mempalace
## Post-Install Setup
After installing the plugin, run the init command to complete setup (pip install, MCP configuration, etc.):
After installing the plugin, run the init command to complete setup (installs the `mempalace` package via `uv tool` or `pip`, configures MCP, etc.):
```
/mempalace:init
+2 -2
View File
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-stop-hook.sh"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-stop-hook.sh\""
}
]
}
@@ -16,7 +16,7 @@
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh"
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/mempal-precompact-hook.sh\""
}
]
}
+21 -2
View File
@@ -1,5 +1,24 @@
#!/bin/bash
# MemPalace PreCompact Hook — thin wrapper calling Python CLI
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
INPUT=$(cat)
echo "$INPUT" | python3 -m mempalace hook run --hook precompact --harness claude-code
run_mempalace_hook() {
if command -v mempalace >/dev/null 2>&1; then
mempalace hook run "$@"
return $?
fi
if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then
python3 -m mempalace hook run "$@"
return $?
fi
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
python -m mempalace hook run "$@"
return $?
fi
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
return 1
}
run_mempalace_hook --hook precompact --harness claude-code
+21 -2
View File
@@ -1,5 +1,24 @@
#!/bin/bash
# MemPalace Stop Hook — thin wrapper calling Python CLI
# All logic lives in mempalace.hooks_cli for cross-harness extensibility
INPUT=$(cat)
echo "$INPUT" | python3 -m mempalace hook run --hook stop --harness claude-code
run_mempalace_hook() {
if command -v mempalace >/dev/null 2>&1; then
mempalace hook run "$@"
return $?
fi
if command -v python3 >/dev/null 2>&1 && python3 -c "import mempalace" >/dev/null 2>&1; then
python3 -m mempalace hook run "$@"
return $?
fi
if command -v python >/dev/null 2>&1 && python -c "import mempalace" >/dev/null 2>&1; then
python -m mempalace hook run "$@"
return $?
fi
echo "MemPalace hook error: could not find a runnable mempalace command or module" >&2
return 1
}
run_mempalace_hook --hook stop --harness claude-code
+1 -1
View File
@@ -9,7 +9,7 @@
"name": "mempalace",
"source": "./.claude-plugin",
"description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.",
"version": "3.3.2",
"version": "3.3.5",
"author": {
"name": "milla-jovovich"
}
+2 -6
View File
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"version": "3.3.2",
"version": "3.3.5",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
@@ -9,11 +9,7 @@
"commands": [],
"mcpServers": {
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
"command": "mempalace-mcp"
}
},
"keywords": [
+2 -2
View File
@@ -16,10 +16,10 @@ Ensure `mempalace` is installed:
mempalace --version
```
If not installed:
If not installed (uv recommended):
```bash
pip install mempalace
uv tool install mempalace # or: pip install mempalace
```
## Usage
+8 -3
View File
@@ -6,7 +6,7 @@ Give your AI a persistent memory -- mine projects and conversations into a searc
- Python 3.9+
- Codex CLI installed and configured
- `pip install mempalace`
- `uv tool install mempalace` (recommended) or `pip install mempalace`
## Installation
@@ -39,12 +39,17 @@ git clone https://github.com/MemPalace/mempalace.git
cd mempalace
```
2. Install the Python package:
2. Install the Python package so the `mempalace-mcp` script lands on
your PATH (the bundled `plugin.json` invokes it by bare name):
```bash
pip install -e .
uv tool install --editable . # or: pip install -e .
```
Plain `uv sync` is **not** enough here — it installs the scripts into
`.venv/bin/`, which Codex will not find unless you activate the venv
before launching Codex.
3. The `.codex-plugin` directory is already in the repo root. Codex CLI will detect it automatically when you run Codex from inside the repository.
4. Initialize your palace:
+3 -3
View File
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh session-start"
"command": "\"${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh\" session-start"
}
]
}
@@ -17,7 +17,7 @@
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh stop"
"command": "\"${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh\" stop"
}
]
}
@@ -28,7 +28,7 @@
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh precompact"
"command": "\"${CODEX_PLUGIN_ROOT}/hooks/mempal-hook.sh\" precompact"
}
]
}
+1 -1
View File
@@ -3,7 +3,7 @@ set -euo pipefail
HOOK_NAME="${1:?Usage: mempal-hook.sh <hook-name>}"
INPUT_FILE=$(mktemp) || { echo "Failed to create temp file" >&2; exit 1; }
cat > "$INPUT_FILE"
cat "$INPUT_FILE" | python3 -m mempalace hook run --hook "$HOOK_NAME" --harness codex
cat "$INPUT_FILE" | mempalace hook run --hook "$HOOK_NAME" --harness codex
EXIT_CODE=$?
rm -f "$INPUT_FILE" 2>/dev/null
exit $EXIT_CODE
+2 -6
View File
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"version": "3.3.2",
"version": "3.3.5",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
@@ -21,11 +21,7 @@
"hooks": "./hooks.json",
"mcpServers": {
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
"command": "mempalace-mcp"
}
},
"interface": {
+6 -2
View File
@@ -17,6 +17,7 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
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
@@ -26,7 +27,8 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.9"
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
@@ -36,7 +38,8 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.9"
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
lint:
@@ -46,6 +49,7 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: 'pip'
- run: pip install "ruff>=0.4.0,<0.5"
- run: ruff check .
- run: ruff format --check .
+4 -4
View File
@@ -23,13 +23,13 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure GitHub Pages
id: pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- uses: oven-sh/setup-bun@v2
with:
@@ -46,7 +46,7 @@ jobs:
DOCS_EDIT_BRANCH: ${{ github.ref_name }}
run: bun run docs:build
- uses: actions/upload-pages-artifact@v3
- uses: actions/upload-pages-artifact@v5
with:
path: website/.vitepress/dist
@@ -63,4 +63,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
check-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Extract versions from all sources
id: versions
+1
View File
@@ -36,3 +36,4 @@ venv/
# ChromaDB local data
*.sqlite3-journal
.envrc
+1
View File
@@ -0,0 +1 @@
3.12
+89
View File
@@ -6,6 +6,94 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [3.3.5] — 2026-05-09
### Bug Fixes
- **MCP `tool_search` now retries once on transient `Error finding id` from chromadb's HNSW flush window.** After a bulk CLI mine, ChromaDB's HNSW segment metadata can be unflushed for ~30-60s; wing-scoped MCP search hits `Internal error: Error finding id` during that window. `tool_search` now detects this transient via response-shape sniffing, drops both the MCP-local client cache and `_DEFAULT_BACKEND._clients` / `_freshness` for the palace, sleeps 2s, and retries once. Successful retries are tagged with `index_recovered: true` so callers can observe when it fired; non-transient errors bypass the retry path entirely. Partial fix for the broader #1315 cluster — `tool_check_duplicate` and other index-touching tools still need the same wrapper. (#1396, refs #1082, #1315)
- **`mempalace_diary_read` silently dropped entries on agent-name case mismatch.** `tool_diary_write` stored the `agent` metadata verbatim after `sanitize_name`, which preserves case, while `tool_diary_read` filtered by exact match. Writing as `"Claude"` and reading as `"claude"` (or vice-versa) returned zero rows. Both endpoints now lowercase `agent_name` immediately after sanitization, so reads are case-insensitive and the default per-agent wing slug is stable across casings. **Behavior change:** entries written prior to this fix under mixed-case agent names will not match the new lowercase filter; run `mempalace repair` if you need to migrate legacy diary metadata. (#1243)
- **Knowledge-graph triples with `valid_to < valid_from` were silently invisible.** `KnowledgeGraph.query_entity()` filters with `valid_from <= as_of AND valid_to >= as_of`, so an inverted interval matches no `as_of` and the row is durably stored but unreachable — a P0 data-integrity foot-gun any caller that mixes up the two date params can hit. `add_triple()` now rejects inverted intervals at write time with a clear `ValueError` naming both bounds. Open intervals (one bound only) and point-in-time facts (`valid_from == valid_to`) remain accepted unchanged. (#1214)
- **`ChromaBackend.close_palace()` / `close()` did not release the SQLite file lock.** Evicted clients sat in `_clients` without `close()`, and chromadb 1.5.x retains the rust-side SQLite lock until GC. Reopening the same palace path after `shutil.rmtree` + recreate within one process failed with `SQLITE_READONLY_DBMOVED` (code 1032). New `_close_client()` helper now calls `PersistentClient.close()` (with a try/except fallback for older chromadb) on `close_palace()`, on whole-backend `close()`, and on the `_client()` invalidation path that detects a missing `chroma.sqlite3`. The mtime/inode auto-invalidation branch is intentionally left alone — callers there may still hold a live `ChromaCollection`. (#1067, #1105)
- **`EntityRegistry.save()` could leave a corrupt or empty `entity_registry.json` on crash.** `Path.write_text()` is not atomic — kernel sees `open('w')` (truncate), `write`, `close`, and any failure between truncate and full-flush (power loss, OOM, FS-full, kill -9) wipes the months-of-mining people/projects map silently (the registry's `load()` swallows `JSONDecodeError`). Save now writes to a sibling `.tmp` in the same directory, `fsync`s, `chmod 0o600`s, then `os.replace()`s into place — atomic on POSIX and Windows. The previous registry stays intact on any crash before the rename returns. (#1215)
- **`miner.detect_room` bidirectional substring matching caused systemic misrouting.** The priority-1 (path parts) and priority-2 (filename) checks used `c in part or part in c` against room names + keywords, so any token that was an unbounded substring of a room name (or vice versa) matched. Priority-1 iterates left-to-right and returns on first match, so `views/billing-page/src/Foo.test.tsx` routed to an `interviews` room because `"views" in "interviews"` matched before reaching `billing-page`. Both call sites now use a `_name_matches` helper that compares names as equal or as separator-bounded tokens of each other (split on `-`, `_`, `.`, `/`). (#1004, closes #1002)
- **`mempalace compress` crashed on large palaces.** `regenerate_closets` fetched all closet_llm drawers in a single `col.get()`, which trips `SQLITE_MAX_VARIABLE_NUMBER` on palaces above ~32k drawers. Mirrors the #851 fix in `miner.py`: drawer fetch is now paginated at `batch_size=5000`. Per-source aggregation works across batches, so the LLM regeneration call still groups chunks correctly. (#1073, #1107)
- **CLI and `fact_checker --stdin` mojibaked non-ASCII content on Windows.** Python defaults `sys.stdin`/`stdout`/`stderr` to the system ANSI codepage (cp1252/cp1251/cp950), so `mempalace search > out.txt` and piped fact_checker invocations corrupted Cyrillic / CJK drawer text at the process boundary. New `mempalace/_stdio.py` helper reconfigures all three streams to UTF-8 on `sys.platform == "win32"`, with per-stream `errors` policy: `surrogateescape` on stdin (preserves bad bytes from redirected files for the consumer's parser), `replace` on stdout/stderr (substitutes U+FFFD instead of `UnicodeEncodeError`-ing mid-print). With this, all three user-facing console_scripts (`mcp_server`, `hooks_cli`, `cli`/`fact_checker`) now reconfigure identically on Windows. (#1282)
- **MCP knowledge-graph tools forwarded malformed date strings to SQLite.** `tool_kg_query` (`as_of`), `tool_kg_add` (`valid_from`), and `tool_kg_invalidate` (`ended`) accepted any string and produced empty result sets on natural-language inputs like `"March 2026"` or `"yesterday"` — callers (especially LLM agents) could not distinguish "no fact at this time" from "your date format was unrecognized." New `sanitize_iso_temporal()` validator in `config.py` (with `sanitize_iso_date()` retained as a backwards-compat wrapper) accepts `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM:SSZ`, and `YYYY-MM-DDTHH:MM:SS+00:00` (normalized to the `Z` form), and passes `None`/`""` through unchanged; all three KG tools call it before values reach the storage layer. Partial dates (`YYYY`, `YYYY-MM`), naive datetimes, and non-UTC timezone offsets are rejected because KG queries compare TEXT temporal values where mixed formats silently return wrong results. **Behavior change:** previously-silent date typos now raise a clear `ValueError` naming the offending field; partial-date inputs that worked in 3.3.4 (`"2026"`, `"2026-05"`) no longer parse — pass a full `YYYY-MM-DD` or a canonical UTC datetime instead. (#1164, #1167, #1374, #1417)
- **MCP server's `_kg` was a module-level singleton.** Multi-tenant hosts that rotate `MEMPALACE_PALACE_PATH` between tool calls hit the wrong sqlite file, because the KG was constructed once at import time while the ChromaDB side was already per-call via `_get_client()`. The KG is now resolved per-call through a lazy per-path cache (`_kg_by_path` keyed by `os.path.abspath`, with a double-checked-locking init under `_kg_cache_lock`). `tool_reconnect` drains and `close()`s cached KGs alongside the existing chroma reconnect. A `_call_kg` retry guard catches `sqlite3.ProgrammingError` once after a reconnect race. (#1136, #1160)
- **`mempalace repair` can now recover palaces whose HNSW segment writer is stuck on `apply_logs`.** Both the existing `--mode legacy` rebuild and the inline `cli.cmd_repair` path call `Collection.count()` as their first read — exactly the call that raises `chromadb.errors.InternalError: Failed to apply logs to the hnsw segment writer` on the corruption class introduced upstream and reported in #1308. Repair would print `Cannot recover — palace may need to be re-mined from source files` even though the underlying SQLite tables were fully intact (the corruption lives in the on-disk index files, not the data layer). New `--mode from-sqlite` reads `(id, document, metadata)` rows directly from `chroma.sqlite3` via a `segments` → `embeddings` → `embedding_metadata` join, never opens a chromadb client against the corrupt palace, and re-upserts everything into a fresh palace at `--palace`. `--source PATH` extracts from a corrupt palace already moved aside; `--archive-existing` handles the in-place case by renaming the existing palace to `<palace>.pre-rebuild-<timestamp>` before reading from it. Documents are re-embedded under the user's configured embedding function (the original HNSW vectors live in the corrupt `data_level0.bin` and cannot be recovered, but the embedding model is deterministic so search results remain semantically equivalent). Verified end-to-end on a 52,300-row real-world corrupt palace. (#1308)
### Documentation
- **`CONTRIBUTING.md` git-identity guidance.** New section asks contributors to verify `git config user.name` and `git config user.email` before pushing, with an explicit warning for agentic coding tools that may not inherit the user's normal Git config. Avoids placeholder/template author values in commit history. (#1385, closes #1317)
### Internal
- **Test reliability: `multiprocessing` start method.** `tests/test_palace_locks.py` and `tests/test_chroma_collection_lock.py` switched from `fork` to `spawn` for child processes. Under Python 3.13 the pytest parent is multi-threaded by the time these tests run (chromadb + onnxruntime each spawn background threads on import); `fork` snapshotting that state into the child without the threads themselves deadlocked Linux 3.13 and macOS CI jobs indefinitely while Linux 3.9 / 3.11 / Windows finished normally. macOS additionally forbids fork-without-exec via CoreFoundation. `spawn` re-imports modules in the child (~0.5s per Process — bounded by the 10 subprocesses these tests fork) but is safe under threading. (#1431)
- **Test cleanup: SQLite connection lifecycle.** Wrapped naked `conn = sqlite3.connect(...)` blocks in `tests/test_backends.py`, `tests/test_sources.py`, and `tests/test_repair.py` with `contextlib.closing(...)`. The flat `conn.close()` pattern at the end of each test leaked the connection on any exception or assertion failure between connect and close, producing `ResourceWarning: unclosed database` noise in CI logs and creating a secondary risk of advisory-lock starvation on Python 3.13 / macOS. Mirrors the `try/finally` pattern already used in production code. (#1430)
---
## [3.3.4] — 2026-04-30
### Added
- **`mempalace init` now prompts to mine the same directory.** After entity confirmation, room detection, and gitignore guard, `init` shows a one-line scope estimate (e.g. `~423 files (~12 MB) would be mined into this palace.`) computed from its existing corpus walk, then asks `Mine this directory now? [Y/n]` (default yes) and runs `mine()` in-process if accepted. The estimate fires before the prompt so users on a real corpus aren't surprised by a minutes-long ChromaDB write. Declining prints the exact `mempalace mine <dir>` command for later. (#1181)
- **New `--auto-mine` flag on `mempalace init`** for the non-interactive path (`mempalace init --auto-mine <dir>` skips the mine prompt and runs mine directly). `--yes` retains its existing scope of entity auto-accept only and still prompts for the mine step, so existing scripted callers see no behaviour change; combining `--yes --auto-mine` gives a fully non-interactive setup. (#1181)
- **Cross-wing topic tunnels.** When two wings have confirmed `TOPIC` labels in common (the LLM-refine bucket from `mempalace init --llm`), the miner now drops a symmetric tunnel between them at mine time so the palace graph reflects shared themes (frameworks, vendors, recurring concepts). Tunnels are routed through the existing `create_tunnel` storage so they share dedup and persistence with explicit tunnels. Topic tunnels are stored under a synthetic `topic:<name>` room and tagged with `kind: "topic"` on the stored dict — this keeps them distinct from literal folder-derived rooms of the same name (a wing with both an `Angular` folder room and an `Angular` topic tunnel no longer collides at `follow_tunnels` read time) and gives LLMs scanning `list_tunnels` a visible discriminator. Threshold is configurable via `MEMPALACE_TOPIC_TUNNEL_MIN_COUNT` env var or `topic_tunnel_min_count` in `~/.mempalace/config.json` (default `1`). Manifest-dependency overlap and per-topic allow/deny lists remain out of scope. (#1180)
- **Context-aware corpus detection at `mempalace init`.** A new Pass 0 runs at the start of `init` — before entity detection — and answers one question: *is this corpus an AI-dialogue record, and if so, which platform and what persona names has the user assigned to the agents?* Tier 1 is a free regex heuristic (well-known AI brand terms + turn-marker patterns, with a co-occurrence rule that suppresses ambiguous terms like `Claude`/`Gemini`/`Haiku` when no unambiguous AI signal is present, so French novels and astrology forums don't false-positive). Tier 2 is an LLM call (~$0.01 with Anthropic Haiku, free with local Ollama/LM Studio/llama.cpp/vLLM) that extracts `user_name` and `agent_persona_names` from dialogue structure. Result is persisted to `<palace>/.mempalace/origin.json` with a `schema_version: 1` envelope so downstream tools can read it. Entity classification then routes names matching `agent_persona_names` (case-insensitive) into a new `agent_personas` bucket instead of `people`, so a Claude Code transcript no longer misclassifies the user's `Echo`/`Sparrow`/`Cipher` agents as biological people. `llm_refine` receives the same context as a system-prompt preamble so it can disambiguate other ambiguous candidates with corpus-level knowledge too. Backwards compatible: callers that don't pass `corpus_origin` see the v3.3.3 return shape unchanged. (#TBD)
- **`mempalace init` runs LLM-assisted refinement by default.** v3.3.3 made `--llm` opt-in; the LLM-assisted path is qualitatively better (extracts persona names, refines ambiguous classifications) so it now runs by default. Provider precedence is unchanged — Ollama at `http://localhost:11434` first, then openai-compat, then anthropic with API key. **Never blocks init on a missing LLM**: if no provider is reachable (Ollama not running, no API key set), init prints a one-line message pointing at `--no-llm` and falls through to the heuristic-only path. `--no-llm` is the new explicit opt-out. The legacy `--llm` flag is preserved as a deprecated alias of the default so scripted callers see no behaviour change. Cost story: zero for users with a local LLM (the majority on this repo), ~$0.01 per init for users with `ANTHROPIC_API_KEY` set who explicitly choose `--llm-provider anthropic`, zero for users with no LLM (graceful fallback). (#TBD)
- **`mempalace mine --redetect-origin` flag.** Re-runs corpus-origin detection on the current corpus state and overwrites `<palace>/.mempalace/origin.json`. Useful when the corpus has grown since `mempalace init` and the stored origin may be stale. Heuristic-only by design (the flag is meant to be cheap); re-run `mempalace init` for full Tier 2 LLM refinement. Default `mempalace mine` does not touch `origin.json` — the flag is opt-in. (#TBD)
### Bug Fixes
- **MCP server `tool_diary_write` SIGSEGV when default EF provider differs.** `mcp_server._get_collection` bypassed `ChromaBackend.get_collection` and called `client.get_collection` / `client.create_collection` without `embedding_function=`. ChromaDB 1.x persists the EF *identity* (its `name()`) with the collection but not the EF *instance/configuration*, so the MCP server's reopen silently bound chromadb's built-in `DefaultEmbeddingFunction` — its `name()` matches `mempalace.embedding`'s spoofed `"default"` so the identity check passes, but its provider list is chromadb's default rather than the user's resolved device. The miner / Stop hook ingest path routes through the backend helper and binds the configured EF instead. On bleeding-edge interpreters (python 3.14 + chromadb 1.5.x on Apple Silicon) the default provider selection could SIGSEGV the host process on first `col.add()`, killing the MCP stdio server and leaving every subsequent tool call returning `Connection closed` until Claude Code was relaunched. `_get_collection` now reuses `ChromaBackend._resolve_embedding_function()` on the reopen branches that actually open a collection (warm-cache reads stay zero-cost), matching the miner/backend path. (#1299, follow-up to #1262 / #1289)
- **Hooks no longer recreate `~/.mempalace/` after the user removes it.** When `~/.mempalace/` is deleted (a strong "do not auto-capture" signal), the next `Stop`, `PreCompact`, or `SessionStart` hook would silently rebuild the dir hierarchy and ingest existing transcripts: `_log()` called `STATE_DIR.mkdir(parents=True, exist_ok=True)` unconditionally, so the very act of writing `[HH:MM] SESSION START …` recreated `~/.mempalace/hook_state/`; subsequent calls in the save path then materialized `palace/`, `wal/`, `knowledge_graph.sqlite3`, and N drawers from `~/.claude/projects/*.jsonl`. All four entry points (`hook_stop`, `hook_precompact`, `hook_session_start`, and `_log` itself) now check a new module-level `PALACE_ROOT = Path.home() / ".mempalace"` constant first and short-circuit (returning `{}` on stdout, never logging) when the directory is absent. The user-removable directory becomes a kill-switch — `rm -rf ~/.mempalace` is now a stable state. Net: 23 lines added in `mempalace/hooks_cli.py`, 5 unit tests in `tests/test_hooks_cli.py`. (#1305)
- **Cross-wing topic tunnels for hyphenated dir names.** `mempalace init` recorded the `topics_by_wing` registry key under the raw directory name (e.g. `mempalace-public`), while `mempalace.yaml`'s `wing` field used the lower-cased + separator-collapsed slug (`mempalace_public`). At mine time the miner read the slug from the yaml and missed the registry, so `_compute_topic_tunnels_for_wing` returned `0` silently. Real-world: any project whose folder contained a hyphen or space lost every topic tunnel. Now both call sites route through a shared `normalize_wing_name()` in `config.py`. (#1194, follow-up to #1180)
- **CLI `mempalace search` retrieval quality.** The CLI was using pure ChromaDB cosine distance with no BM25 rerank, so drawers containing every query term but embedding as noise (directory listings, diff output, shell logs) scored `Match: 0.0` alongside genuinely irrelevant results with no way to tell them apart. Wired the CLI through the same `_hybrid_rank` the `mempalace_search` MCP tool already used, and surfaced both `cosine=` and `bm25=` scores in the output so users see which component of the match is firing. MCP search was unaffected; this fixes the human-facing CLI parity gap.
- **Legacy-palace distance-metric warning.** CLI search now detects palaces created before `hnsw:space=cosine` was consistently set and prints a one-line notice pointing at `mempalace repair`. Without the warning such palaces silently used L2 distance, under which the similarity display floored every result to `Match: 0.0`. New palaces mined today already set cosine correctly and now have invariant tests pinning that behavior so future refactors can't silently regress it. (#1179)
- **Graceful Ctrl-C during `mempalace mine`.** Interrupting a long mine no longer dumps a multi-frame `KeyboardInterrupt` traceback. The main file-processing loop now catches the signal, prints `files_processed: N/M`, `drawers_filed: K`, and `last_file:` so the user knows what landed, then exits with code 130 (standard SIGINT). Already-filed drawers are upserted idempotently on re-mine via deterministic IDs, so resuming is safe. The hooks PID lock at `~/.mempalace/hook_state/mine.pid` is now also actively cleaned up in a `finally` when its entry points at us — clean exit, error, or interrupt — preventing the next hook fire from briefly waiting on a stale PID. (#1182)
- **`mempalace init` is now idempotent across re-runs.** Running `init` twice on the same project produced different `origin.json` results because the first run wrote `entities.json` into the project directory, and the second run's corpus-origin sampling included that file as corpus content — shifting Tier 1's character-density math. Sampling now skips the per-project artifacts (`entities.json`, `mempalace.yaml`), so re-running `init` produces the same classification it did the first time. Pinned by an integration test in `tests/test_corpus_origin_integration.py`. (#TBD)
---
## [3.3.3] — 2026-04-23
### Bug Fixes
- **Install regression** — `mempalace-mcp` console script is now declared in `pyproject.toml` alongside `.claude-plugin/plugin.json`'s reference to it. In v3.3.2 the two drifted apart (plugin.json shipped the new `"command": "mempalace-mcp"` form before the matching entry point landed), so every fresh `pip install mempalace==3.3.2` produced a Claude Code plugin config pointing at a binary that wasn't installed. (#1093, #340)
- Restore silent-save visibility after the Claude Code 2.1.114 client regression — production transcript saves were failing silently until this PR. (#1021)
- Paginate `status`-path metadata fetches so large palaces don't trip SQLite variable limits. (#851)
- Resolve the Claude plugin hook runner across platform / plugin-dir variations; previously broke on Windows and some macOS layouts. (#942)
- Real `python3` resolution for `.sh` hooks with a `MEMPAL_PYTHON` override path. (#833)
- Add optional `wing` parameter to `tool_diary_write` / `tool_diary_read` and derive per-project wing from the Claude Code transcript path when writing from the stop hook — diary entries from different projects no longer collapse into a shared default wing. (#659)
- Treat empty string as "no filter" in `mempalace_search` `wing`/`room`; LLM agents that default to filling every optional parameter with `""` no longer get bounced with `must be a non-empty string`. (#1097, #1084)
- Broaden `_wing_from_transcript_path` to handle Claude Code project folders without a `-Projects-` segment (e.g. `~/dev/<parent>/<project>`, `~/code/<project>`). The project name is now derived from the final dash-separated token of the encoded folder, so Linux users with code outside `~/Projects/` get per-project diary scoping instead of falling through to `wing_sessions`. (#1145, follow-up to #659)
- `mempalace_diary_read(wing="")` now returns diary entries from every wing this agent has written to, matching the #1097 "empty-string as no filter" pattern. Previously defaulted to `wing_<agent>`, siloing entries that hooks wrote to project-derived wings. (#1145)
- `mempalace mine` now skips the generated `entities.json` file so its contents aren't re-ingested as project content. (#1175)
### Improvements
- **Deterministic hook saves.** Save hook now uses a silent Python API path, so successive hook invocations produce reproducible results and zero data loss on the hot path. (#673)
- **Graph cache with write-invalidation** inside `build_graph()` — warm-path calls no longer rebuild the palace-graph per request. (#661)
- **`mempalace init` entity detection overhaul.** Canonical project names now come from package manifests (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`) and real people come from git commit authors, rather than being inferred from prose. Includes union-find dedup across name/email aliases, bot filtering that keeps `@users.noreply.github.com` humans, and automatic "mine" flagging by contribution share. (#1148)
- **Regex detector accuracy.** CamelCase extraction so `MemPalace`, `ChromaDB`, `OpenAI` aren't fragmented; tighter versioned/hyphenated pattern kills `context-manager` / `multi-word` false positives; dialogue `^NAME:\s` requires ≥2 hits so `Created: <date>` metadata stops classifying field names as people; expanded stopwords for common English participles and descriptors; high-pronoun signal classifies as person rather than dumping to uncertain. (#1148)
- **Init → miner wire-up.** Confirmed entities merge into `~/.mempalace/known_entities.json` on init, which the miner reads to tag drawer metadata for entity-filtered search. Previously init's output was not consumed by the miner; the per-project `entities.json` is kept as an audit trail. (#1157)
- **Case-insensitive project dedup** across manifest, git, and convo sources so casing variants of the same project name collapse into one review entry. (#1175)
### Added
- i18n: Belarusian translation. (#1051)
- i18n: entity detection for German, Spanish, and French locales. (#1001)
- i18n: Traditional + Simplified Chinese entity detection. (#945)
- **`mempalace init --llm`**: optional LLM-assisted entity classification. Defaults to local Ollama (zero-API); also supports any OpenAI-compatible endpoint (LM Studio, llama.cpp server, vLLM, OpenRouter, etc.) and the Anthropic Messages API. Runs interactively with a progress indicator; Ctrl-C cancels cleanly and returns partial results. Useful for prose-heavy folders where the regex detector struggles (diaries, transcripts, research notes). Opt-in only — default init path remains zero-API. (#1150)
- **Claude Code conversation scanner.** `~/.claude/projects/<slug>/` directories now contribute project entities using each session's authoritative `cwd` metadata, avoiding slug-decoding ambiguity. (#1150)
### Known — deferred to v3.3.4
- HNSW parallel-insert SIGSEGV when `hnsw:num_threads` is unset on collection creation (#974) — fix in-flight as #976, awaiting rebase against develop.
---
## [3.3.2] — 2026-04-19
### Bug Fixes
@@ -119,6 +207,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- Hall detection — routes drawer content to `emotions` / `technical` / `family` / `memory` / `identity` / `consciousness` / `creative` halls, enabling hall-based graph connectivity within wings (#835)
### Bug Fixes
- Repair `max_seq_id` corruption caused by `_fix_blob_seq_ids` misinterpreting chromadb 1.5.x's sysdb-10 BLOB format (`b'\x11\x11'` + ASCII digits) as legacy 0.6.x big-endian BLOBs. The shim now skips the `max_seq_id` table entirely and guards the `embeddings` branch with a prefix check. New subcommand `mempalace repair --mode max-seq-id [--from-sidecar <path>]` restores affected palaces. Fixes silent drawer-write drops that began after chromadb 1.5.x upgrades on palaces that still had BLOB-typed `max_seq_id` rows at migration time.
- Set `hnsw:space=cosine` metadata on all collection creation sites — fixes broken similarity scoring under ChromaDB's default L2 distance (#807, #218)
- File-level locking prevents duplicate drawers when agents mine the same file concurrently (#784, #826)
- Hybrid closet+drawer retrieval — closets boost ranking, never gate results (#795)
+7 -7
View File
@@ -22,7 +22,7 @@ These are non-negotiable. Every PR, every feature, every refactor must honor the
- **Verbatim always** — Never summarize, paraphrase, or lossy-compress user data. The system searches the index and returns the original words. If a user said it, we store exactly what they said. This is the foundational promise.
- **Incremental only** — Append-only ingest after initial build. Never destroy existing data to rebuild. A crash mid-operation must leave the existing palace untouched.
- **Entity-first** — Everything is keyed by real names with disambiguation by DOB, ID, or context. People matter more than topics.
- **Local-first, zero API** — All extraction, chunking, and embedding happens on the user's machine. No cloud dependency for memory operations. No API keys required.
- **Local-first, zero external API by default** — All extraction, chunking, embedding, and LLM-assisted refinement happens on the user's machine by default, using locally-hosted runtimes (Ollama, LM Studio, llama.cpp, vLLM, unsloth studio, etc.). External providers (Anthropic, OpenAI, Google) are supported via BYOK but are never required and never enabled silently. The system never sends user content to a service the user has not explicitly configured. "Local LLM" is not an external API — Ollama and equivalents running on localhost are part of the user's machine. External BYOK is always a deliberate user choice, never a default and never a silent fallback.
- **Performance budgets** — Hooks under 500ms. Startup injection under 100ms. Memory should feel instant.
- **Privacy by architecture** — The system physically cannot send your data because it never leaves your machine. No telemetry, no phone-home, no external service dependencies for core operations.
- **Background everything** — Filing, indexing, timestamps, and pipeline work happen via hooks in the background. Nothing interrupts the user's conversation. Zero tokens spent on bookkeeping in the chat window.
@@ -36,26 +36,26 @@ We do not accept summarization of user content, cloud storage/sync features, tel
## Setup
```bash
pip install -e ".[dev]"
uv sync --extra dev # recommended; or: pip install -e ".[dev]"
```
## Commands
```bash
# Run tests
python -m pytest tests/ -v --ignore=tests/benchmarks
uv run pytest tests/ -v --ignore=tests/benchmarks
# Run tests with coverage
python -m pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
uv run pytest tests/ -v --ignore=tests/benchmarks --cov=mempalace --cov-report=term-missing
# Lint
ruff check .
uv run ruff check .
# Format
ruff format .
uv run ruff format .
# Format check (CI mode)
ruff format --check .
uv run ruff format --check .
```
## Project Structure
+21 -5
View File
@@ -10,13 +10,17 @@ git clone https://github.com/<your-username>/mempalace.git
cd mempalace
git remote add upstream https://github.com/MemPalace/mempalace.git
pip install -e ".[dev]" # installs with dev dependencies (pytest, build, twine)
# Recommended: uv (https://docs.astral.sh/uv/) handles the venv for you
uv sync --extra dev
# Or with pip in your own venv:
# pip install -e ".[dev]"
```
## Running Tests
```bash
pytest tests/ -v
uv run pytest tests/ -v
```
All tests must pass before submitting a PR. Tests should run without API keys or network access.
@@ -25,10 +29,10 @@ All tests must pass before submitting a PR. Tests should run without API keys or
```bash
# Quick test (20 questions, ~30 seconds)
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json --limit 20
uv run python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json --limit 20
# Full benchmark (500 questions, ~5 minutes)
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
uv run python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
```
See [benchmarks/README.md](benchmarks/README.md) for data download instructions and reproduction guide.
@@ -49,7 +53,7 @@ assets/ ← logo + brand
1. Fork the repo and create a feature branch: `git checkout -b feat/my-thing`
2. Write your code
3. Add or update tests if applicable
4. Run `pytest tests/ -v` — everything must pass
4. Run `uv run pytest tests/ -v` — everything must pass
5. Commit with a clear message following [conventional commits](https://www.conventionalcommits.org/):
- `feat: add Notion export format`
- `fix: handle empty transcript files`
@@ -93,3 +97,15 @@ If you're planning a significant change, open an issue first to discuss the appr
## License
MIT — your contributions will be released under the same license.
## Git identity for contributions
Before pushing commits, verify that Git is configured with an email address that GitHub can associate with your account:
```bash
git config user.name
git config user.email
```
This is especially important when commits are created through agentic coding tools or automation, because those tools may not inherit your normal shell Git configuration. Avoid placeholder values such as `your@email.com` or localized template text; unresolved author emails can create avoidable provenance and SBOM review friction for downstream users.
+17 -4
View File
@@ -6,6 +6,10 @@
> domain — including `mempalace.tech` — is an impostor and may distribute
> malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
> [!IMPORTANT]
> **🚨 Claude Code sessions expire in 30 days w/out auto-save hooks wired!** **[Read this →](https://github.com/MemPalace/mempalace/discussions/1388)**
<div align="center">
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
@@ -45,11 +49,16 @@ Architecture, concepts, and mining flows:
## Install
We recommend [`uv`](https://docs.astral.sh/uv/) — `uv tool install` puts
the `mempalace` CLI in an isolated environment on your PATH:
```bash
pip install mempalace
uv tool install mempalace
mempalace init ~/projects/myapp
```
If you prefer pip, `pip install mempalace` still works.
## Quickstart
```bash
@@ -116,9 +125,9 @@ own research page for their published numbers.
```bash
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
uv sync --extra dev # or: pip install -e ".[dev]"
# see benchmarks/README.md for dataset download commands
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
uv run python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
```
---
@@ -149,6 +158,10 @@ system prompt:
Two Claude Code hooks save periodically and before context compression:
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
For per-message recall on top of the file-level chunks the hooks produce,
run `mempalace sweep <transcript-dir>` periodically — it stores one
verbatim drawer per user/assistant message, idempotent and resume-safe.
---
## Requirements
@@ -177,7 +190,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
MIT — see [LICENSE](LICENSE).
<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.3.2-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.3.5-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/
+3 -3
View File
@@ -344,7 +344,7 @@ The palace classifies each question into one of 5 halls. Pass 1 searches only wi
```bash
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
uv sync --extra dev # or: pip install -e ".[dev]"
mkdir -p /tmp/longmemeval-data
curl -fsSL -o /tmp/longmemeval-data/longmemeval_s_cleaned.json \
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
@@ -724,8 +724,8 @@ python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_clean
The question: how much of the 96.6% → 99.4% improvement is the heuristics, and how much would come from just using a better embedding model?
```bash
pip install fastembed
python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_cleaned.json \
uv pip install fastembed # or: pip install fastembed
uv run python benchmarks/longmemeval_bench.py /tmp/longmemeval-data/longmemeval_s_cleaned.json \
--mode raw --embed-model bge-large
```
+1 -1
View File
@@ -198,7 +198,7 @@ python benchmarks/longmemeval_bench.py data/longmemeval_s_cleaned.json --mode hy
# Setup
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
uv sync --extra dev # or: pip install -e ".[dev]"
# Download data
mkdir -p /tmp/longmemeval-data
+1 -1
View File
@@ -7,7 +7,7 @@ Run the exact same benchmarks we report. Clone, install, run.
```bash
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
uv sync --extra dev # or: pip install -e ".[dev]"
```
## Benchmark 1: LongMemEval (500 questions)
+301
View File
@@ -0,0 +1,301 @@
"""Mining throughput benchmark: per-chunk vs batched upsert, CPU vs GPU.
Compares the legacy per-chunk ``add_drawer`` loop against the batched
``collection.upsert`` path introduced in the "batched upsert + GPU" PR.
Runs both paths on an identical seeded synthetic corpus, reports
wall-clock time + drawers/sec, and prints a markdown table suitable
for pasting into a PR description.
Usage
-----
# CPU (whatever onnxruntime is installed — CPU if you don't have
# onnxruntime-gpu):
uv run python benchmarks/mine_bench.py
# GPU (NVIDIA):
uv venv /tmp/gpu && source /tmp/gpu/bin/activate
uv pip install -e '.[gpu]' 'nvidia-cudnn-cu12>=9,<10' \\
'nvidia-cuda-runtime-cu12' 'nvidia-cublas-cu12'
export LD_LIBRARY_PATH=$(python -c "import nvidia.cudnn, os; \\
print(os.path.dirname(nvidia.cudnn.__file__)+'/lib')"):$LD_LIBRARY_PATH
MEMPALACE_EMBEDDING_DEVICE=cuda python benchmarks/mine_bench.py
Flags
-----
--device cpu|cuda|coreml|dml|auto Override MEMPALACE_EMBEDDING_DEVICE
--scenarios small,medium,large Which scenarios to run
--seed 42 RNG seed for reproducibility
"""
from __future__ import annotations
import argparse
import hashlib
import os
import random
import shutil
import string
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
def build_corpus(dest: Path, n_files: int, paragraphs_per_file: int, seed: int) -> None:
"""Generate ``n_files`` markdown files of random words under ``dest``."""
rng = random.Random(seed)
dest.mkdir(parents=True, exist_ok=True)
for i in range(n_files):
paragraphs = []
for _ in range(paragraphs_per_file):
words = [
"".join(rng.choices(string.ascii_lowercase, k=rng.randint(3, 10)))
for _ in range(12)
]
paragraphs.append(" ".join(words))
(dest / f"doc_{i:03d}.md").write_text("\n\n".join(paragraphs))
(dest / "mempalace.yaml").write_text(
"wing: bench\n"
"rooms:\n"
" - name: general\n"
" description: all\n"
" keywords: [general]\n"
)
def _process_file_unbatched(filepath, project_path, collection, wing, rooms, agent, closets_col):
"""Legacy per-chunk upsert path (pre-batching).
Reproduces the exact loop shape the miner used before this PR so the
comparison is apples-to-apples; only the upsert granularity differs.
"""
from mempalace import miner
from mempalace.palace import (
build_closet_lines,
file_already_mined,
mine_lock,
purge_file_closets,
upsert_closet_lines,
)
source_file = str(filepath)
if file_already_mined(collection, source_file, check_mtime=True):
return 0, "general"
try:
content = filepath.read_text(encoding="utf-8", errors="replace")
except OSError:
return 0, "general"
content = content.strip()
if len(content) < miner.MIN_CHUNK_SIZE:
return 0, "general"
room = miner.detect_room(filepath, content, rooms, project_path)
chunks = miner.chunk_text(content, source_file)
with mine_lock(source_file):
if file_already_mined(collection, source_file, check_mtime=True):
return 0, room
try:
collection.delete(where={"source_file": source_file})
except Exception:
pass
drawers_added = 0
for chunk in chunks:
miner.add_drawer(
collection=collection,
wing=wing,
room=room,
content=chunk["content"],
source_file=source_file,
chunk_index=chunk["chunk_index"],
agent=agent,
)
drawers_added += 1
if closets_col and drawers_added > 0:
drawer_ids = [
f"drawer_{wing}_{room}_"
f"{hashlib.sha256((source_file + str(c['chunk_index'])).encode()).hexdigest()[:24]}"
for c in chunks
]
closet_lines = build_closet_lines(source_file, drawer_ids, content, wing, room)
closet_id_base = (
f"closet_{wing}_{room}_"
f"{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
)
closet_meta = {
"wing": wing,
"room": room,
"source_file": source_file,
"drawer_count": drawers_added,
"filed_at": datetime.now().isoformat(),
"normalize_version": miner.NORMALIZE_VERSION,
}
purge_file_closets(closets_col, source_file)
upsert_closet_lines(closets_col, closet_id_base, closet_lines, closet_meta)
return drawers_added, room
def mine_once(project_dir: str, palace_path: str, batched: bool) -> tuple[int, float]:
"""Mine a project dir with either the batched (new) or per-chunk (old) path."""
from mempalace import miner
from mempalace.miner import load_config, scan_project
from mempalace.palace import get_closets_collection, get_collection
project_path = Path(project_dir).resolve()
config = load_config(project_dir)
wing = config["wing"]
rooms = config.get("rooms", [])
files = scan_project(project_dir)
collection = get_collection(palace_path)
closets = get_closets_collection(palace_path)
total = 0
t0 = time.perf_counter()
for filepath in files:
if batched:
drawers, _ = miner.process_file(
filepath=filepath,
project_path=project_path,
collection=collection,
wing=wing,
rooms=rooms,
agent="bench",
dry_run=False,
closets_col=closets,
)
else:
drawers, _ = _process_file_unbatched(
filepath, project_path, collection, wing, rooms, "bench", closets
)
total += drawers
return total, time.perf_counter() - t0
def _reset_backend_caches() -> None:
"""Drop the in-process client cache so each run pays cold-open cost equally."""
from mempalace.palace import _DEFAULT_BACKEND
_DEFAULT_BACKEND._clients.clear()
_DEFAULT_BACKEND._freshness.clear()
def run_scenario(label: str, n_files: int, paragraphs_per_file: int, seed: int) -> dict:
"""Run one scenario under both code paths and return a result dict."""
print(f"\n=== {label}: {n_files} files × {paragraphs_per_file} paragraphs ===")
results = {}
for mode in ("unbatched", "batched"):
tmp = Path(tempfile.mkdtemp(prefix=f"mp_{mode}_"))
try:
proj = tmp / "proj"
palace = tmp / "palace"
build_corpus(proj, n_files, paragraphs_per_file, seed=seed)
_reset_backend_caches()
drawers, dt = mine_once(str(proj), str(palace), batched=(mode == "batched"))
rate = drawers / dt if dt > 0 else 0.0
results[mode] = (drawers, dt, rate)
print(f" {mode:10} {drawers:5} drawers in {dt:6.2f}s → {rate:7.1f} drawers/sec")
finally:
shutil.rmtree(tmp, ignore_errors=True)
_, t_u, r_u = results["unbatched"]
d_b, t_b, r_b = results["batched"]
speedup = t_u / t_b if t_b > 0 else 0.0
print(f" speedup: {speedup:.2f}× ({t_u:.2f}s → {t_b:.2f}s)")
return {
"label": label,
"n_files": n_files,
"paragraphs": paragraphs_per_file,
"drawers": d_b,
"unbatched_time": t_u,
"unbatched_rate": r_u,
"batched_time": t_b,
"batched_rate": r_b,
"speedup": speedup,
}
SCENARIOS = {
"small": ("Small files (~50 paragraphs)", 10, 50),
"medium": ("Medium files (~200 paragraphs)", 20, 200),
"large": ("Large files (~500 paragraphs)", 10, 500),
}
def _env_summary(device_label: str) -> list[str]:
"""Short hardware + version lines included with the printed table."""
import platform
try:
import chromadb
chromadb_v = chromadb.__version__
except Exception:
chromadb_v = "?"
try:
import onnxruntime as ort
ort_v = ort.__version__
providers = ",".join(p.replace("ExecutionProvider", "") for p in ort.get_available_providers())
except Exception:
ort_v = "?"
providers = "?"
return [
f"device: **{device_label}** (onnxruntime {ort_v}, providers={providers})",
f"chromadb {chromadb_v} · python {sys.version.split()[0]} · {platform.platform()}",
]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
parser.add_argument(
"--device",
default=None,
help="Override MEMPALACE_EMBEDDING_DEVICE (cpu|cuda|coreml|dml|auto)",
)
parser.add_argument(
"--scenarios",
default="small,medium,large",
help="Comma-separated scenario names (default: all)",
)
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
if args.device:
os.environ["MEMPALACE_EMBEDDING_DEVICE"] = args.device
from mempalace.embedding import describe_device, get_embedding_function
device_label = describe_device()
print(f"Warming up ONNX model on device={device_label}...")
ef = get_embedding_function()
ef(["warmup sentence one", "warmup sentence two"])
picked = [s.strip() for s in args.scenarios.split(",") if s.strip()]
results = []
for key in picked:
if key not in SCENARIOS:
print(f"Unknown scenario {key!r}; choices: {sorted(SCENARIOS)}", file=sys.stderr)
sys.exit(2)
label, n_files, paras = SCENARIOS[key]
results.append(run_scenario(label, n_files, paras, args.seed))
print("\n\n## Mining benchmark\n")
for line in _env_summary(device_label):
print(line + " ")
print()
print("| Scenario | Files | Drawers | Per-chunk (old) | Batched (new) | Speedup |")
print("| --- | ---: | ---: | ---: | ---: | ---: |")
for r in results:
print(
f"| {r['label']} | {r['n_files']} | {r['drawers']} | "
f"{r['unbatched_time']:.2f}s · {r['unbatched_rate']:.0f} drw/s | "
f"{r['batched_time']:.2f}s · {r['batched_rate']:.0f} drw/s | "
f"**{r['speedup']:.2f}×** |"
)
if __name__ == "__main__":
main()
+44 -6
View File
@@ -7,22 +7,60 @@ MemPalace hooks act as an "Auto-Save" feature. They help your AI keep a permanen
* **PreCompact Hook** (`mempal_precompact_hook.sh`): Saves your context right before the AI's memory window fills up.
### 2. Setup for Claude Code
Add this to your configuration file to enable automatic background saving:
Add this to `~/.claude/settings.local.json` (global) or `.claude/settings.local.json` (project-scoped) to enable automatic background saving:
```json
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [{"type": "command", "command": "./hooks/mempal_save_hook.sh"}]
"matcher": "*",
"hooks": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_save_hook.sh",
"timeout": 30
}]
}
],
"PreCompact": [
{
"matcher": "",
"hooks": [{"type": "command", "command": "./hooks/mempal_precompact_hook.sh"}]
"hooks": [{
"type": "command",
"command": "/absolute/path/to/hooks/mempal_precompact_hook.sh",
"timeout": 30
}]
}
]
}
}
}
```
Make the hooks executable:
```bash
chmod +x /absolute/path/to/hooks/mempal_save_hook.sh
chmod +x /absolute/path/to/hooks/mempal_precompact_hook.sh
```
**Note:** Replace `/absolute/path/to/hooks/` with the actual path where you cloned the MemPalace repository (e.g., `~/projects/mempalace/hooks/`).
### 3. What changed (v3.1.0+)
Both hooks now have **two-layer capture**:
1. **Auto-mine**: Before blocking the AI, the hook runs the normalizer on the JSONL transcript and upserts chunks directly into the palace. This captures raw tool output (Bash results, search findings, build errors) that the AI would otherwise summarize away.
2. **Updated reason messages**: The block reason now explicitly tells the AI to save tool output verbatim — not just topics and decisions.
### 4. Backfill past conversations (one-time)
The hooks capture conversations going forward, but you probably have months of past sessions. Run this once to mine them all:
```bash
mempalace mine ~/.claude/projects/ --mode convos
```
### 5. Configuration
- **`SAVE_INTERVAL=15`** — How many human messages between saves
- **`MEMPALACE_PYTHON`** — Python interpreter with mempalace + chromadb. Auto-detects: env var → repo venv → system python3
- **`MEMPAL_DIR`** — Optional directory for auto-ingest via `mempalace mine`
+13 -6
View File
@@ -9,17 +9,24 @@ This guide explains how to set up MemPalace as a permanent memory for the [Gemin
## 1. Installation
On many Linux systems, installing Python packages globally is restricted. We recommend using a local virtual environment within the MemPalace directory.
On many Linux systems, installing Python packages globally is restricted. We
recommend [`uv`](https://docs.astral.sh/uv/), which creates and manages a
local virtual environment for you.
```bash
# Clone the repository (if you haven't already)
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
# Create a virtual environment
python3 -m venv .venv
# Create the venv and install MemPalace + dependencies in editable mode
uv sync
```
# Install dependencies and MemPalace in editable mode
This produces a `.venv/` directory inside the repo with everything installed.
If you prefer plain pip:
```bash
python3 -m venv .venv
.venv/bin/pip install -e .
```
@@ -29,7 +36,7 @@ Set up your "Palace" (the database) and configure your identity.
```bash
# Initialize the palace in the current directory
.venv/bin/python3 -m mempalace init .
uv run python -m mempalace init .
```
### Identity and Wings (Optional but Recommended)
@@ -86,7 +93,7 @@ Once connected, Gemini CLI will automatically:
### Manual Mining
If you want the AI to learn from your existing code or docs immediately, run the "mine" command:
```bash
.venv/bin/python3 -m mempalace mine /path/to/your/project
uv run python -m mempalace mine /path/to/your/project
```
### Verification
+2 -2
View File
@@ -5,13 +5,13 @@
Run the MCP server:
```bash
python -m mempalace.mcp_server
mempalace-mcp
```
Or add it to Claude Code:
```bash
claude mcp add mempalace -- python -m mempalace.mcp_server
claude mcp add mempalace -- mempalace-mcp
```
## Available Tools
+54 -15
View File
@@ -6,10 +6,10 @@ These hook scripts make MemPalace save automatically. No manual "save" commands
| Hook | When It Fires | What Happens |
|------|--------------|-------------|
| **Save Hook** | Every 15 human messages | Blocks the AI, tells it to save key topics/decisions/quotes to the palace |
| **PreCompact Hook** | Right before context compaction | Emergency save — forces the AI to save EVERYTHING before losing context |
| **Save Hook** | Every 15 human messages | Auto-mines transcript (tool output included), then blocks the AI to save topics/decisions/quotes |
| **PreCompact Hook** | Right before context compaction | Auto-mines transcript, then emergency save — forces the AI to save EVERYTHING before losing context |
The AI does the actual filing — it knows the conversation context, so it classifies memories into the right wings/halls/closets. The hooks just tell it WHEN to save.
**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.
## Install — Claude Code
@@ -67,7 +67,8 @@ Edit `mempal_save_hook.sh` to change:
- **`SAVE_INTERVAL=15`** — How many human messages between saves. Lower = more frequent saves, higher = less interruption.
- **`STATE_DIR`** — Where hook state is stored (defaults to `~/.mempalace/hook_state/`)
- **`MEMPAL_DIR`** — Optional. Set to a conversations directory to auto-run `mempalace mine <dir>` on each save trigger. Leave blank (default) to let the AI handle saving via the block reason message.
- **`MEMPAL_DIR`** — Optional **project directory** (code, notes, docs) to also mine on each save trigger, with `--mode projects`. The hook ALWAYS mines the active conversation transcript automatically with `--mode convos` — `MEMPAL_DIR` is purely additive, never an override. Leave blank if you don't want to ingest project files.
- **`MEMPALACE_PYTHON`** — Optional env var. Python interpreter with mempalace + chromadb installed. Auto-detects: `MEMPALACE_PYTHON` env var → repo `venv/bin/python3` → system `python3`. Set this if your venv is in a non-standard location.
### mempalace CLI
@@ -91,15 +92,19 @@ User sends message → AI responds → Claude Code fires Stop hook
↓
┌─── < 15 since last save ──→ echo "{}" (let AI stop)
│
└─── ≥ 15 since last save ──→ {"decision": "block", "reason": "save..."}
↓
AI saves to palace
↓
AI tries to stop again
↓
stop_hook_active = true
↓
Hook sees flag → echo "{}" (let it through)
└─── ≥ 15 since last save
↓
Auto-mine transcript → palace (tool output captured)
↓
{"decision": "block", "reason": "save tool output verbatim..."}
↓
AI saves to palace (topics, decisions, quotes)
↓
AI tries to stop again
↓
stop_hook_active = true
↓
Hook sees flag → echo "{}" (let it through)
```
The `stop_hook_active` flag prevents infinite loops: block once → AI saves → tries to stop → flag is true → we let it through.
@@ -109,14 +114,18 @@ The `stop_hook_active` flag prevents infinite loops: block once → AI saves →
```
Context window getting full → Claude Code fires PreCompact
↓
Hook ALWAYS blocks
Find transcript (from input or session_id lookup)
↓
Auto-mine transcript → palace (tool output captured)
↓
{"decision": "block", "reason": "save tool output verbatim..."}
↓
AI saves everything
↓
Compaction proceeds
```
No counting needed — compaction always warrants a save.
No counting needed — compaction always warrants a save. The auto-mine captures raw tool output before the AI gets a chance to summarize it away.
## Debugging
@@ -137,6 +146,36 @@ Example output:
**Hooks require session restart after install.** Claude Code loads hooks from `settings.json` at session start only. If you run `mempalace init` or manually edit hook config mid-session, the hooks won't fire until you restart Claude Code. This is a Claude Code limitation.
**`MEMPAL_PYTHON` override for the hook's internal Python calls.** The save hook parses its JSON input and counts transcript messages with `python3`. When the harness is launched from a GUI on macOS — `open -a`, Spotlight, the dock — its `PATH` is the minimal `/usr/bin:/bin:/usr/sbin:/sbin` inherited from `launchd`, not your shell PATH. If `python3` isn't on that PATH, those internal calls fail and the hook can't count exchanges.
Point the hook at any Python 3 interpreter to fix it:
```bash
export MEMPAL_PYTHON="/usr/bin/python3" # system Python is fine
export MEMPAL_PYTHON="$HOME/.venvs/mempalace/bin/python" # or your venv
```
Resolution priority: `$MEMPAL_PYTHON` (if set and executable) → `$(command -v python3)` → bare `python3`. The interpreter only needs `json` and `sys` from the standard library — `mempalace` itself does not need to be installed in it.
Note: the `mempalace mine` auto-ingest runs via the `mempalace` CLI, so that command also needs to be on the hook's `PATH`. Installing with `pipx install mempalace` or `uv tool install mempalace` puts it on a stable global location; otherwise extend the hook environment's `PATH` to include your venv's `bin/`.
## Backfill Past Conversations
The hooks only capture conversations going forward. To mine **past** Claude Code sessions into your palace, run a one-time backfill:
```bash
mempalace mine ~/.claude/projects/ --mode convos
```
This scans all JSONL transcripts from previous sessions and files them into the `conversations` wing. On a typical developer machine with months of history, this can yield 50K–200K drawers.
For Codex CLI sessions:
```bash
mempalace mine ~/.codex/sessions/ --mode convos
```
This only needs to be done once — after that, the hooks auto-mine each session as you go.
## Cost
**Zero extra tokens.** The hooks notify the AI that saves happened in the background — the AI doesn't need to write anything in the chat. All filing is handled automatically. Previous versions asked the AI to write diary entries and drawer content in the chat window, which cost ~$1/session in retransmitted tokens.
+62 -12
View File
@@ -41,31 +41,81 @@
# to save everything. After the AI saves, compaction proceeds normally.
#
# === MEMPALACE CLI ===
# This repo uses: mempalace mine <dir>
# or: mempalace mine <dir> --mode convos
# Set MEMPAL_DIR below if you want the hook to auto-ingest before compaction.
# Leave blank to rely on the AI's own save instructions.
# The hook ALWAYS mines the active conversation transcript synchronously
# before compaction (via `mempalace mine <transcript-dir> --mode convos`).
# MEMPAL_DIR is an *additional*, optional target for project files — it
# does not replace the conversation mine.
STATE_DIR="$HOME/.mempalace/hook_state"
mkdir -p "$STATE_DIR"
# Optional: set to the directory you want auto-ingested before compaction.
# Example: MEMPAL_DIR="$HOME/conversations"
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
# Optional: project directory (code / notes / docs) to also mine before
# compaction. Mined with `--mode projects`. The conversation transcript
# is always mined regardless — this is purely additive.
# Example: MEMPAL_DIR="$HOME/projects/my_app"
MEMPAL_DIR=""
# Resolve the Python interpreter. Same contract as mempal_save_hook.sh:
# MEMPAL_PYTHON (explicit override) → $(command -v python3) → bare python3.
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
# Read JSON input from stdin
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id','unknown'))" 2>/dev/null)
# Parse session_id and transcript_path in one call. Sanitize both, then
# read sanitized values from one-per-line stdout into shell variables —
# avoids ``eval`` on generated code (#1231 review). Same contract as
# mempal_save_hook.sh.
mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
import sys, json, re
data = json.load(sys.stdin)
sid = data.get('session_id', 'unknown')
tp = data.get('transcript_path', '')
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
print(safe(sid))
print(safe(tp))
" 2>/dev/null)
SESSION_ID="${_mempal_parsed[0]:-unknown}"
TRANSCRIPT_PATH="${_mempal_parsed[1]:-}"
# Expand ~ in path
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
# Validate that TRANSCRIPT_PATH looks like a transcript file. Mirrors
# mempalace.hooks_cli._validate_transcript_path so the shell hook
# rejects the same shapes the Python hook rejects (#1231 review).
is_valid_transcript_path() {
local path="$1"
[ -n "$path" ] || return 1
case "$path" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$path/" in
*/../*) return 1 ;;
esac
return 0
}
echo "[$(date '+%H:%M:%S')] PRE-COMPACT triggered for session $SESSION_ID" >> "$STATE_DIR/hook.log"
# Optional: run mempalace ingest synchronously so memories land before compaction
# Run ingest synchronously so memories land before compaction. Two
# independent targets — both run if both are set:
# 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 \
>> "$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
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$STATE_DIR/hook.log" 2>&1
fi
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
+71 -26
View File
@@ -45,10 +45,10 @@
# stop_hook_active=true so we let it through. No infinite loop.
#
# === MEMPALACE CLI ===
# This repo uses: mempalace mine <dir>
# or: mempalace mine <dir> --mode convos
# Set MEMPAL_DIR below if you want the hook to auto-ingest after blocking.
# Leave blank to rely on the AI's own save instructions.
# The hook ALWAYS mines the active conversation transcript automatically
# (via `mempalace mine <transcript-dir> --mode convos`). MEMPAL_DIR is an
# *additional*, optional target for project files — it does not replace
# the conversation mine.
#
# === CONFIGURATION ===
@@ -56,18 +56,38 @@ SAVE_INTERVAL=15 # Save every N human messages (adjust to taste)
STATE_DIR="$HOME/.mempalace/hook_state"
mkdir -p "$STATE_DIR"
# Optional: set to the directory you want auto-ingested on each save trigger.
# Example: MEMPAL_DIR="$HOME/conversations"
# Leave empty to skip auto-ingest (AI handles saving via the block reason).
# Optional: project directory (code / notes / docs) to also mine each
# save trigger. Mined with `--mode projects`. The conversation transcript
# is always mined regardless — this is purely additive.
# Example: MEMPAL_DIR="$HOME/projects/my_app"
MEMPAL_DIR=""
# Resolve the Python interpreter the hook should use.
#
# Why this is nontrivial: GUI-launched Claude Code on macOS (or any harness
# that doesn't inherit the user's shell PATH) may find a `python3` on PATH
# that lacks mempalace — e.g. /usr/bin/python3 while the user installed
# mempalace into a venv or pyenv. Users in that situation can point the
# hook at the right interpreter by exporting MEMPAL_PYTHON.
#
# Resolution order (first hit wins):
# 1. $MEMPAL_PYTHON — explicit user override (absolute path)
# 2. $(command -v python3) — first python3 on the hook's PATH
# 3. bare "python3" — last-resort fallback (hope the PATH has it)
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
# Read JSON input from stdin
INPUT=$(cat)
# Parse all fields in a single Python call (3x faster than separate invocations)
# SECURITY: All values are sanitized before being interpolated into shell assignments.
# stop_hook_active is coerced to a strict True/False to prevent command injection via eval.
eval $(echo "$INPUT" | python3 -c "
# without invoking ``eval`` on generated code: Python prints one sanitized
# value per line, the shell reads them via ``mapfile`` and does plain
# variable assignment — same data, smaller blast radius if the sanitizer
# is ever bypassed (#1231 review).
mapfile -t _mempal_parsed < <(echo "$INPUT" | "$MEMPAL_PYTHON_BIN" -c "
import sys, json, re
data = json.load(sys.stdin)
sid = data.get('session_id', 'unknown')
@@ -77,14 +97,36 @@ tp = data.get('transcript_path', '')
safe = lambda s: re.sub(r'[^a-zA-Z0-9_/.\-~]', '', str(s))
# Coerce stop_hook_active to strict boolean string
sha = 'True' if sha_raw is True or str(sha_raw).lower() in ('true', '1', 'yes') else 'False'
print(f'SESSION_ID=\"{safe(sid)}\"')
print(f'STOP_HOOK_ACTIVE=\"{sha}\"')
print(f'TRANSCRIPT_PATH=\"{safe(tp)}\"')
print(safe(sid))
print(sha)
print(safe(tp))
" 2>/dev/null)
SESSION_ID="${_mempal_parsed[0]:-unknown}"
STOP_HOOK_ACTIVE="${_mempal_parsed[1]:-False}"
TRANSCRIPT_PATH="${_mempal_parsed[2]:-}"
# Expand ~ in path
TRANSCRIPT_PATH="${TRANSCRIPT_PATH/#\~/$HOME}"
# Validate that TRANSCRIPT_PATH looks like a transcript file:
# - non-empty
# - .jsonl or .json suffix
# - no traversal segments (.. components)
# Mirrors mempalace.hooks_cli._validate_transcript_path so the shell hook
# rejects the same shapes the Python hook rejects (#1231 review).
is_valid_transcript_path() {
local path="$1"
[ -n "$path" ] || return 1
case "$path" in
*.json|*.jsonl) ;;
*) return 1 ;;
esac
case "/$path/" in
*/../*) return 1 ;;
esac
return 0
}
# If we're already in a save cycle, let the AI stop normally
# This is the infinite-loop prevention: block once → AI saves → tries to stop again → we let it through
if [ "$STOP_HOOK_ACTIVE" = "True" ] || [ "$STOP_HOOK_ACTIVE" = "true" ]; then
@@ -95,7 +137,7 @@ fi
# Count human messages in the JSONL transcript
# SECURITY: Pass transcript path as sys.argv to avoid shell injection via crafted paths
if [ -f "$TRANSCRIPT_PATH" ]; then
EXCHANGE_COUNT=$(python3 - "$TRANSCRIPT_PATH" <<'PYEOF'
EXCHANGE_COUNT=$("$MEMPAL_PYTHON_BIN" - "$TRANSCRIPT_PATH" <<'PYEOF'
import json, sys
count = 0
with open(sys.argv[1]) as f:
@@ -140,20 +182,23 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then
echo "[$(date '+%H:%M:%S')] TRIGGERING SAVE at exchange $EXCHANGE_COUNT" >> "$STATE_DIR/hook.log"
# Auto-mine the transcript. Two paths:
# 1. TRANSCRIPT_PATH (from Claude Code) — mine the directory it lives in
# 2. MEMPAL_DIR (user-configured) — mine that directory
# At least one should work. If neither is set, nothing mines.
PYTHON="$(command -v python3)"
MINE_DIR=""
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
MINE_DIR="$(dirname "$TRANSCRIPT_PATH")"
# Auto-mine. Two independent targets — both run if both are set:
# 1. TRANSCRIPT_PATH (from Claude Code) → parent dir, --mode convos
# (Claude Code session JSONL — must use the convo miner)
# 2. MEMPAL_DIR (user-configured project) → --mode projects
# (code, notes, docs)
# 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 \
>> "$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
MINE_DIR="$MEMPAL_DIR"
fi
if [ -n "$MINE_DIR" ]; then
"$PYTHON" -m mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
mempalace mine "$MEMPAL_DIR" --mode projects \
>> "$STATE_DIR/hook.log" 2>&1 &
fi
# MEMPAL_VERBOSE toggle:
+2 -2
View File
@@ -102,10 +102,10 @@ You have access to a local memory palace via MCP tools. The palace stores verbat
## Setup
Install MemPalace and populate the palace:
Install MemPalace and populate the palace (uv recommended):
```bash
pip install mempalace
uv tool install mempalace # or: pip install mempalace
mempalace init ~/my-convos
mempalace mine ~/my-convos
```
+71
View File
@@ -0,0 +1,71 @@
"""Stdio UTF-8 reconfiguration helper for Windows entry points.
Python on Windows defaults stdio to the system ANSI codepage
(cp1252/cp1251/cp950 depending on locale), which mojibakes UTF-8 input
or output the moment a non-Latin character shows up. Every console
entry point that touches stdio needs to fix this on Windows -- the MCP
server, the CLI, the fact_checker `--stdin` mode -- so the
reconfigure code lives here in one place to keep the per-stream
errors policies aligned across them.
Per-stream errors policy is caller-chosen:
* MCP server uses ``strict`` on stdout/stderr because everything written
there is server-controlled JSON-RPC; any encode failure is a real bug
the operator wants loud.
* CLI / fact_checker use ``replace`` on stdout/stderr because they print
verbatim drawer text that may contain surrogate halves round-tripped
from filenames -- ``strict`` would crash mid-print.
* All callers use ``surrogateescape`` on stdin so a malformed byte from
a redirected file or a misbehaving client survives as a lone surrogate
the consumer's parser surfaces, instead of ``UnicodeDecodeError``
killing the read loop on the first bad byte.
"""
from __future__ import annotations
import sys
from typing import Callable, Optional
def reconfigure_stdio_utf8_on_windows(
*,
stdin_errors: str = "surrogateescape",
stdout_errors: str = "strict",
stderr_errors: str = "strict",
on_failure: Optional[Callable[[str, BaseException], None]] = None,
) -> None:
"""Reconfigure stdio to UTF-8 on Windows. No-op elsewhere.
Args:
stdin_errors: errors= policy for stdin.reconfigure().
stdout_errors: errors= policy for stdout.reconfigure().
stderr_errors: errors= policy for stderr.reconfigure().
on_failure: optional ``(stream_name, exc) -> None`` callback for
streams whose ``reconfigure`` raises (e.g. Jupyter-replaced
streams that lack the method-shape we expect). Defaults to a
``WARNING:`` line on the original sys.stderr.
"""
if sys.platform != "win32":
return
policies = (
("stdin", stdin_errors),
("stdout", stdout_errors),
("stderr", stderr_errors),
)
for name, errors in policies:
stream = getattr(sys, name, None)
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8", errors=errors)
except Exception as exc: # noqa: BLE001 -- last-resort guard
if on_failure is not None:
on_failure(name, exc)
else:
print(
f"WARNING: Could not reconfigure {name} to UTF-8: {exc}",
file=sys.stderr,
)
File diff suppressed because it is too large Load Diff
+876 -75
View File
File diff suppressed because it is too large Load Diff
+34 -11
View File
@@ -40,6 +40,7 @@ import json
import os
import re
import time
import urllib.parse
import urllib.request
import urllib.error
from datetime import datetime
@@ -101,6 +102,14 @@ class LLMConfig:
self.endpoint = (endpoint or os.environ.get("LLM_ENDPOINT", "")).rstrip("/")
self.key = key or os.environ.get("LLM_KEY", "")
self.model = model or os.environ.get("LLM_MODEL", "")
if self.endpoint:
# Privacy-by-architecture: reject file:// and other non-HTTP schemes
# so a misconfigured endpoint cannot exfiltrate local files.
scheme = urllib.parse.urlparse(self.endpoint).scheme.lower()
if scheme not in ("http", "https"):
raise ValueError(
f"LLM_ENDPOINT must use http:// or https:// (got scheme {scheme!r})"
)
def missing(self) -> list:
missing = []
@@ -160,6 +169,9 @@ def _call_llm(cfg: LLMConfig, source_file: str, wing: str, room: str, content: s
parsed = json.loads(text)
return parsed, payload.get("usage")
except json.JSONDecodeError:
if attempt < 2:
time.sleep(2**attempt)
continue
return None, None
except urllib.error.HTTPError as e:
# 429 / 503 = retry with backoff
@@ -221,17 +233,28 @@ def regenerate_closets(
print("No drawers in palace.")
return {"processed": 0}
all_data = drawers_col.get(limit=total, include=["documents", "metadatas"])
by_source = {}
for doc_id, doc, meta in zip(all_data["ids"], all_data["documents"], all_data["metadatas"]):
source = meta.get("source_file", "unknown")
w = meta.get("wing", "")
if wing and w != wing:
continue
if source not in by_source:
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
by_source[source]["drawer_ids"].append(doc_id)
by_source[source]["content"].append(doc)
# Paginate the fetch — a single get(limit=total, ...) blows through
# SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) on large palaces and
# crashes inside chromadb (see #802, #850, #1073).
by_source: dict = {}
batch_size = 5000
offset = 0
while offset < total:
batch = drawers_col.get(limit=batch_size, offset=offset, include=["documents", "metadatas"])
ids = batch["ids"]
if not ids:
break
for doc_id, doc, meta in zip(ids, batch["documents"], batch["metadatas"]):
meta = meta or {}
source = meta.get("source_file", "unknown")
w = meta.get("wing", "")
if wing and w != wing:
continue
if source not in by_source:
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
by_source[source]["drawer_ids"].append(doc_id)
by_source[source]["content"].append(doc)
offset += len(ids)
sources = list(by_source.keys())
if sample > 0:
+151 -1
View File
@@ -7,6 +7,8 @@ Priority: env vars > config file (~/.mempalace/config.json) > defaults
import json
import os
import re
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
@@ -19,6 +21,16 @@ MAX_NAME_LENGTH = 128
_SAFE_NAME_RE = re.compile(r"^(?:[^\W_]|[^\W_][\w .'-]{0,126}[^\W_])$")
def normalize_wing_name(name: str) -> str:
"""Lower-case + collapse separators (`-`, ` `) to `_` for wing slugs.
The same rule is applied by ``init`` when persisting `topics_by_wing`
and when writing `mempalace.yaml`, so the miner's lookup matches at
mine time regardless of the source dirname.
"""
return name.lower().replace(" ", "_").replace("-", "_")
def sanitize_name(value: str, field_name: str = "name") -> str:
"""Validate and sanitize a wing/room/entity name.
@@ -71,6 +83,91 @@ def sanitize_kg_value(value: str, field_name: str = "value") -> str:
return value
# ISO-8601 temporal validator for knowledge-graph temporal parameters
# (as_of, valid_from, valid_to, ended).
#
# The KG stores temporal values as TEXT. Lexicographic comparisons are only
# safe when datetime values use one canonical shape. Accept full dates for
# legacy compatibility and exact UTC datetimes for sub-day precision.
#
# Accepted:
# YYYY-MM-DD
# YYYY-MM-DDTHH:MM:SSZ
# YYYY-MM-DDTHH:MM:SS+00:00 (normalized to ...Z)
#
# Rejected:
# partial dates, naive datetimes, non-UTC timezone offsets, fractional
# seconds, and SQLite-style space-separated datetimes.
_ISO_DATE_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$")
_ISO_UTC_DATETIME_RE = re.compile(
r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])"
r"T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|\+00:00)$"
)
def _validate_iso_temporal_calendar(value: str) -> None:
"""Reject impossible calendar values after regex shape validation."""
if _ISO_DATE_RE.match(value):
date.fromisoformat(value)
return
if _ISO_UTC_DATETIME_RE.match(value):
datetime.fromisoformat(value.replace("Z", "+00:00"))
return
raise ValueError
def sanitize_iso_temporal(value, field_name: str = "date"):
"""Validate an ISO-8601 date or canonical UTC datetime string.
Accepts ``None`` and ``""`` as pass-through values.
Accepted non-empty string forms:
- ``YYYY-MM-DD``
- ``YYYY-MM-DDTHH:MM:SSZ``
- ``YYYY-MM-DDTHH:MM:SS+00:00`` normalized to ``...Z``
Partial dates are rejected because KG queries compare TEXT temporal values.
Non-canonical datetime forms are rejected because mixed temporal string
formats can silently return wrong KG query results.
"""
if value is None or value == "":
return value
if not isinstance(value, str):
raise ValueError(f"{field_name} must be a string")
value = value.strip()
try:
_validate_iso_temporal_calendar(value)
except ValueError:
raise ValueError(
f"{field_name}={value!r} is not a valid ISO-8601 date or UTC datetime "
"(expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)"
) from None
if value.endswith("+00:00"):
value = f"{value[:-6]}Z"
return value
def sanitize_iso_date(value, field_name: str = "date"):
"""Backward-compatible wrapper for ISO temporal validation.
Historically this accepted only full dates. It now also accepts canonical
UTC datetimes, but the old name is kept so existing imports continue to
work.
"""
return sanitize_iso_temporal(value, field_name)
def sanitize_content(value: str, max_length: int = 100_000) -> str:
"""Validate drawer/diary content length."""
if not isinstance(value, str) or not value.strip():
@@ -85,6 +182,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"
@lru_cache(maxsize=1)
def get_configured_collection_name() -> str:
"""Return the configured drawer collection name without repeated config-file reads."""
return MempalaceConfig().collection_name
DEFAULT_TOPIC_WINGS = [
"emotions",
"consciousness",
@@ -168,7 +272,10 @@ class MempalaceConfig:
"""Path to the memory palace data directory."""
env_val = os.environ.get("MEMPALACE_PALACE_PATH") or os.environ.get("MEMPAL_PALACE_PATH")
if env_val:
return 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)
@property
@@ -233,6 +340,49 @@ class MempalaceConfig:
pass
return normalized
@property
def embedding_device(self):
"""Hardware device for the ONNX embedding model.
Values: ``"auto"`` (default), ``"cpu"``, ``"cuda"``, ``"coreml"``,
``"dml"``. Read from env ``MEMPALACE_EMBEDDING_DEVICE`` first, then
``embedding_device`` in ``config.json``, then ``"auto"``.
``auto`` resolves to the first available accelerator at runtime via
:mod:`mempalace.embedding`; requesting an unavailable accelerator
logs a warning and falls back to CPU.
"""
env_val = os.environ.get("MEMPALACE_EMBEDDING_DEVICE")
if env_val:
return env_val.strip().lower()
return str(self._file_config.get("embedding_device", "auto")).strip().lower()
@property
def topic_tunnel_min_count(self):
"""Minimum number of overlapping confirmed topics required to create
a cross-wing tunnel between two wings.
Default is ``1`` — any single shared topic produces a tunnel. Bump
to ``2+`` if your projects share lots of common-tech labels (Python,
Docker, Git) and you want only meaningfully overlapping wings to
link. Reads ``MEMPALACE_TOPIC_TUNNEL_MIN_COUNT`` env first, then the
config-file value, then ``1``.
"""
env_val = os.environ.get("MEMPALACE_TOPIC_TUNNEL_MIN_COUNT")
if env_val:
try:
parsed = int(env_val)
if parsed >= 1:
return parsed
except ValueError:
pass
cfg_val = self._file_config.get("topic_tunnel_min_count")
try:
parsed = int(cfg_val) if cfg_val is not None else 1
except (TypeError, ValueError):
parsed = 1
return max(1, parsed)
@property
def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
+42 -24
View File
@@ -11,6 +11,7 @@ Same palace as project mining. Different ingest strategy.
import os
import sys
import hashlib
import logging
from pathlib import Path
from datetime import datetime
from collections import defaultdict
@@ -24,6 +25,8 @@ from .palace import (
mine_lock,
)
logger = logging.getLogger("mempalace_mcp")
# Cached hall keywords — avoids re-reading config per drawer
_HALL_KEYWORDS_CACHE = None
@@ -55,6 +58,7 @@ CONVO_EXTENSIONS = {
MIN_CHUNK_SIZE = 30
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
DRAWER_UPSERT_BATCH_SIZE = 1000
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
# Matches miner.py at 500 MB. Long Claude Code sessions, multi-year
# ChatGPT exports, and lifetime Slack dumps routinely exceed 10 MB; the
@@ -330,33 +334,45 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
try:
collection.delete(where={"source_file": source_file})
except Exception:
pass
logger.debug("Stale-drawer purge failed for %s", source_file, exc_info=True)
for chunk in chunks:
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
if extract_mode == "general":
room_counts_delta[chunk_room] += 1
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
# Batch chunks into bounded upserts so large transcripts keep most of
# the embedding speedup without one huge Chroma/SQLite request. Keep
# one filed_at per source file so all transcript drawers share an
# ingest timestamp.
filed_at = datetime.now().isoformat()
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
batch_docs: list = []
batch_ids: list = []
batch_metas: list = []
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
if extract_mode == "general":
room_counts_delta[chunk_room] += 1
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
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,
}
)
try:
collection.upsert(
documents=[chunk["content"]],
ids=[drawer_id],
metadatas=[
{
"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": datetime.now().isoformat(),
"ingest_mode": "convos",
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
}
],
documents=batch_docs,
ids=batch_ids,
metadatas=batch_metas,
)
drawers_added += 1
drawers_added += len(batch_docs)
except Exception as e:
if "already exists" not in str(e).lower():
raise
@@ -381,7 +397,9 @@ def mine_convos(
convo_path = Path(convo_dir).expanduser().resolve()
if not wing:
wing = convo_path.name.lower().replace(" ", "_").replace("-", "_")
from .config import normalize_wing_name
wing = normalize_wing_name(convo_path.name)
files = scan_convos(convo_dir)
if limit > 0:
+160
View File
@@ -0,0 +1,160 @@
"""
convo_scanner.py — Parse Claude Code conversation directories into ProjectInfo.
Claude Code stores sessions under ``~/.claude/projects/<slug>/<id>.jsonl``,
where the ``<slug>`` is the original CWD with ``/`` replaced by ``-``. That
encoding is lossy: we can't tell whether ``foo-bar`` in a slug is the
literal project name ``foo-bar`` or two path segments ``foo/bar``.
Fortunately, every message record in the JSONL carries a ``cwd`` field with
the true path. This scanner reads one record per session to recover the
accurate project name, falling back to slug-decoding only if the JSONL
is malformed or empty.
Output is the same ``ProjectInfo`` shape used by ``project_scanner``, so the
``discover_entities`` orchestrator can mix-and-match sources.
Public:
is_claude_projects_root(path) -> bool
scan_claude_projects(path) -> list[ProjectInfo]
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from mempalace.project_scanner import ProjectInfo
MAX_HEADER_LINES = 20 # lines to read per session looking for `cwd`
def is_claude_projects_root(path: Path) -> bool:
"""Return True if path looks like `.claude/projects/`.
Heuristic: at least one child dir whose name starts with ``-`` and which
contains at least one ``.jsonl`` file.
"""
if not path.is_dir():
return False
try:
children = list(path.iterdir())
except OSError:
return False
for child in children:
if not (child.is_dir() and child.name.startswith("-")):
continue
try:
if any(p.suffix == ".jsonl" for p in child.iterdir() if p.is_file()):
return True
except OSError:
continue
return False
def _extract_cwd_from_session(session_file: Path) -> Optional[str]:
"""Return the ``cwd`` from the first message record that carries one.
Returns None if the file can't be read, has no JSON, or no record has cwd.
"""
try:
with open(session_file, encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f):
if i >= MAX_HEADER_LINES:
break
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
cwd = obj.get("cwd")
if isinstance(cwd, str) and cwd:
return cwd
except OSError:
return None
return None
def _decode_slug_fallback(slug: str) -> str:
"""Best-effort project name from slug when cwd is unavailable.
The slug is lossy (`/` and `-` both become `-`). Last non-empty segment
is the closest guess at the project name, preserving kebab-case is
impossible without cwd.
"""
stripped = slug.lstrip("-")
parts = [p for p in stripped.split("-") if p]
return parts[-1] if parts else slug
def _safe_mtime(path: Path) -> float:
"""Return file mtime, defaulting old on permission or filesystem errors."""
try:
return path.stat().st_mtime
except OSError:
return 0.0
def _resolve_project_name(project_dir: Path) -> str:
"""Read one session's cwd to recover the original project name.
Falls back to slug-decoding if no session has a readable cwd.
"""
sessions = sorted(
(p for p in project_dir.iterdir() if p.is_file() and p.suffix == ".jsonl"),
key=_safe_mtime,
reverse=True, # newest first — most likely to be well-formed
)
for session in sessions:
cwd = _extract_cwd_from_session(session)
if cwd:
return Path(cwd).name or cwd
return _decode_slug_fallback(project_dir.name)
def scan_claude_projects(path: str | Path) -> list[ProjectInfo]:
"""Scan a ``.claude/projects/`` directory for Claude Code conversations.
One ProjectInfo per subdir. ``has_git`` is False (the directory isn't a
repo itself) but ``total_commits`` is repurposed here as session count so
the UX surfaces a density signal for ranking.
"""
root = Path(path).expanduser().resolve()
if not is_claude_projects_root(root):
return []
projects: dict[str, ProjectInfo] = {}
for sub in sorted(root.iterdir()):
if not (sub.is_dir() and sub.name.startswith("-")):
continue
try:
sessions = [p for p in sub.iterdir() if p.is_file() and p.suffix == ".jsonl"]
except OSError:
continue
if not sessions:
continue
name = _resolve_project_name(sub)
session_count = len(sessions)
proj = ProjectInfo(
name=name,
repo_root=sub,
manifest=None,
has_git=False,
total_commits=session_count,
user_commits=session_count,
is_mine=True, # Claude Code sessions are authored by the user
)
existing = projects.get(name)
if existing is None or session_count > existing.user_commits:
projects[name] = proj
return sorted(
projects.values(),
key=lambda p: (-p.user_commits, p.name),
)
+422
View File
@@ -0,0 +1,422 @@
"""
corpus_origin.py — Detect whether a corpus is an AI-dialogue record and,
if so, what platform and what persona names the user has assigned to the
agent.
This is the first question any downstream Pass 2 classification needs
answered. Without it, a drawer like "my three sons" in a Claude Code
dialogue corpus can't be correctly resolved to "three AI instances"
rather than "three biological children."
Two-tier detection:
Tier 1 — detect_origin_heuristic(samples)
Cheap, no API. Grep for well-known AI brand terms + turn
markers. Always runs. Outputs a hypothesis.
Tier 2 — detect_origin_llm(samples, provider)
Uses an LLMProvider (typically Haiku via mempalace.llm_client)
with the model's pre-trained knowledge of Claude/ChatGPT/Gemini
etc. Confirms platform, extracts agent persona-names the user
has assigned. One call, ~$0.01 cost.
Design principle:
Don't make the classifier re-discover what Claude, ChatGPT, Gemini, MCP,
or other well-known entities ARE — the LLM already knows them from its
training. Only corpus-specific entities (e.g. the user's persona-name
for their Claude instance) need discovery.
Default stance (when evidence is thin):
"This IS an AI-dialogue corpus" — false-negative is catastrophic for
downstream classification; false-positive is recoverable via per-drawer
voice-profile detection in later passes.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field, asdict
from typing import Optional
# ── Well-known AI brand terms (expand as new platforms emerge) ────────────
# Detection is by PATTERN + CONTEXT, not by capitalization or English-language
# rules. Two categories:
#
# UNAMBIGUOUS — terms that have essentially no meaning outside of AI context.
# Always counted toward AI-dialogue evidence.
#
# AMBIGUOUS — terms that share a string with common English words, names,
# poetry forms, zodiac signs, animals, etc. Counted toward AI-dialogue
# evidence ONLY when at least one unambiguous AI signal also appears in
# the corpus (turn marker, unambiguous brand term, or AI infrastructure
# term). This avoids false-positives on French novels with characters
# named "Claude", astrology corpora discussing "Gemini", poetry corpora
# full of "haiku" / "sonnet", etc.
#
# All matching is CASE-INSENSITIVE — users type lowercase constantly.
_AI_UNAMBIGUOUS_TERMS = [
# Anthropic-specific
"Anthropic",
"Claude Code",
"Claude 3",
"Claude 4",
"claude mcp",
"CLAUDE.md",
".claude/",
# OpenAI-specific
"ChatGPT",
"GPT-4",
"GPT-3",
"GPT-5",
"OpenAI",
"gpt-4o",
"gpt-4-turbo",
"o1-preview",
"o3",
# Google-specific
"gemini-pro",
"gemini-1.5",
"Google AI",
# Meta / others (specific model identifiers, not bare common words)
"Mixtral",
"Cohere",
# AI-infrastructure terms with no common-English collision
"MCP",
"LLM",
"RAG",
"fine-tune",
"context window",
"embedding",
]
_AI_AMBIGUOUS_TERMS = [
# Anthropic — bare brand/model names that collide with names + poetry
"Claude", # also a common French masculine name
"Opus", # also a musical work, comic strip, magazine
"Sonnet", # also a 14-line poem form
"Haiku", # also a 17-syllable poem form
# Google — bare brand that collides with zodiac sign
"Gemini", # also the zodiac sign
"Bard", # also a poet / Shakespeare
# Meta / others
"Llama", # also the South American animal
"Mistral", # also a Mediterranean wind
# Note: 'prompt', 'completion', 'tokens' previously lived here but were
# removed: they're suppressed without an unambiguous co-signal anyway,
# and by the time a co-signal is present the corpus is already flagged.
# Keeping them just produced noisier evidence strings.
]
# Turn-marker patterns commonly seen in AI-dialogue transcripts
_TURN_MARKERS = [
r"\buser\s*:\s*",
r"\bassistant\s*:\s*",
r"\bhuman\s*:\s*",
r"\bai\s*:\s*",
r"\b>>>\s*User\b",
r"\b>>>\s*Assistant\b",
]
def _brand_pattern(term: str) -> str:
"""Build a regex for a brand term that uses word boundaries
only on edges where the term itself starts/ends with a word
character. Without this nuance:
- 'Claude' would falsely match inside 'Claudette' (no \\b)
- '.claude/' would fail to match at start of string (\\b
before non-word char requires preceding word char)
So we only attach \\b where it actually makes sense."""
escaped = re.escape(term)
prefix = r"\b" if term[0].isalnum() or term[0] == "_" else ""
suffix = r"\b" if term[-1].isalnum() or term[-1] == "_" else ""
return prefix + escaped + suffix
@dataclass
class CorpusOriginResult:
"""Structured output from corpus-origin detection.
Fields:
likely_ai_dialogue — best hypothesis about whether this is AI-dialogue
confidence — 0.0 to 1.0
primary_platform — e.g. "Claude Code (Anthropic CLI)" or None
user_name — the corpus author's name if identifiable from context, else None
agent_persona_names — names the user has assigned to the AI agent(s)
(e.g. ["Echo", "Sparrow"]). Does NOT include the user's own name.
evidence — human-readable reasons for the classification
"""
likely_ai_dialogue: bool
confidence: float
primary_platform: Optional[str]
user_name: Optional[str] = None
agent_persona_names: list[str] = field(default_factory=list)
evidence: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return asdict(self)
# ── Tier 1: cheap heuristic ───────────────────────────────────────────────
def detect_origin_heuristic(samples: list[str]) -> CorpusOriginResult:
"""Fast grep-based detection. No API calls.
Scores AI-dialogue likelihood by counting:
- occurrences of well-known AI brand terms
- turn-marker patterns (user:, assistant:, etc.)
Returns a CorpusOriginResult with confidence derived from signal density.
"""
combined = "\n\n".join(samples)
total_chars = max(1, len(combined))
# Count UNAMBIGUOUS brand-term hits (case-insensitive — users type
# lowercase constantly, so 'chatgpt' must trip the same as 'ChatGPT').
# Word boundaries prevent false in-word matches (see _brand_pattern).
unambiguous_hits: dict[str, int] = {}
total_unambiguous = 0
for term in _AI_UNAMBIGUOUS_TERMS:
matches = re.findall(_brand_pattern(term), combined, re.IGNORECASE)
if matches:
unambiguous_hits[term] = len(matches)
total_unambiguous += len(matches)
# Count AMBIGUOUS brand-term hits separately. These will only be
# counted toward AI-dialogue evidence if the corpus also contains
# at least one unambiguous AI signal — see co-occurrence rule below.
ambiguous_hits: dict[str, int] = {}
total_ambiguous = 0
for term in _AI_AMBIGUOUS_TERMS:
matches = re.findall(_brand_pattern(term), combined, re.IGNORECASE)
if matches:
ambiguous_hits[term] = len(matches)
total_ambiguous += len(matches)
# Count turn-marker hits (case-insensitive — transcripts vary).
turn_hits = 0
turn_types_found = set()
for pattern in _TURN_MARKERS:
matches = re.findall(pattern, combined, re.IGNORECASE)
if matches:
turn_hits += len(matches)
turn_types_found.add(pattern)
# Co-occurrence rule for ambiguous terms.
# Ambiguous terms (e.g. 'Claude' as a French name, 'Gemini' as a zodiac
# sign, 'Haiku' as a poem form) only count toward brand evidence if
# the corpus also contains at least one unambiguous AI signal. Otherwise
# we'd false-positive on French novels, astrology forums, poetry corpora,
# llama-rancher journals, etc.
has_ai_context = total_unambiguous > 0 or turn_hits > 0
counted_brand_hits = total_unambiguous + (total_ambiguous if has_ai_context else 0)
# Brand-term density per 1000 chars; turn-marker density likewise.
# Tuned on a small set of examples; these aren't magic numbers and
# can be revisited as we see more corpora.
brand_density = counted_brand_hits / (total_chars / 1000)
turn_density = turn_hits / (total_chars / 1000)
# Build evidence list
evidence: list[str] = []
shown_hits = dict(unambiguous_hits)
if has_ai_context:
shown_hits.update(ambiguous_hits)
if shown_hits:
top_terms = sorted(shown_hits.items(), key=lambda x: -x[1])[:5]
evidence.append("AI brand terms: " + ", ".join(f"'{k}' ({v}x)" for k, v in top_terms))
elif ambiguous_hits and not has_ai_context:
# Be transparent that we saw ambiguous matches but suppressed them
# for lack of co-occurring AI context.
suppressed = sorted(ambiguous_hits.items(), key=lambda x: -x[1])[:3]
evidence.append(
"Ambiguous terms present but suppressed (no co-occurring AI signal): "
+ ", ".join(f"'{k}' ({v}x)" for k, v in suppressed)
)
if turn_hits:
evidence.append(
f"Turn markers detected: {turn_hits} occurrences across {len(turn_types_found)} pattern types"
)
# Decision logic:
# strong signal (brand OR turn hits both >= threshold) → confident AI-dialogue
# MEANINGFUL absence (enough text, zero brand, zero turn) → confident narrative
# ambiguous or insufficient text → default stance: AI-dialogue with low confidence
#
# Threshold for "meaningful absence": the samples collectively have to
# be long enough that the absence of AI signals would be expected to
# surface if the corpus really is narrative. 150 chars is the working
# floor — below that, we cannot confidently say "this is narrative."
MEANINGFUL_TEXT_FLOOR = 150
if brand_density >= 0.5 or turn_density >= 2.0:
return CorpusOriginResult(
likely_ai_dialogue=True,
confidence=min(0.95, 0.6 + 0.1 * (brand_density + turn_density)),
primary_platform=None, # tier 2 will refine
evidence=evidence,
)
if counted_brand_hits == 0 and turn_hits == 0 and total_chars >= MEANINGFUL_TEXT_FLOOR:
# Note: ambiguous-only matches (e.g. a French novel with 'Claude' as
# a character name) flow through here because counted_brand_hits == 0
# when no unambiguous AI signal co-occurs. The 'evidence' list still
# records that the ambiguous matches were seen and suppressed.
narrative_evidence = list(evidence) + [
f"no unambiguous AI signal across {total_chars} chars of text — pure narrative"
]
return CorpusOriginResult(
likely_ai_dialogue=False,
confidence=0.9,
primary_platform=None,
evidence=narrative_evidence,
)
# Ambiguous or too-short-to-tell case: default stance is AI-dialogue
# with explicit low confidence. Tier 2 (LLM) should be called to confirm.
reason = "weak signal" if (counted_brand_hits or turn_hits) else "insufficient text"
return CorpusOriginResult(
likely_ai_dialogue=True,
confidence=0.4,
primary_platform=None,
evidence=evidence
+ [
f"{reason} — applying default-stance (ai_dialogue=True, low confidence). "
"Tier 2 LLM check recommended to confirm or override."
],
)
# ── Tier 2: LLM-assisted confirmation + persona extraction ────────────────
_SYSTEM_PROMPT = """You are analyzing a corpus of text to determine whether it is a \
record of conversations with an AI agent (e.g. Claude, ChatGPT, Gemini, custom LLM \
apps), or some other kind of text (personal narrative, story, research notes, \
journal, code, etc.).
Use your pre-existing knowledge of well-known AI platforms. You don't need the \
corpus to explain what Claude or ChatGPT is — you already know. Your job is to \
detect evidence of their presence and identify what persona-names the user has \
assigned to the agent(s) they converse with.
CRITICAL distinction:
- agent_persona_names are names the USER has assigned to the AI AGENT(S)
they converse with. Example: "Echo", "Sparrow", "Henry" might be names
the user calls a Claude instance they're building a relationship with.
- Do NOT include the USER's own name in agent_persona_names. The user
is the human author of the corpus, not a persona of the agent. Even
if the user's name appears frequently in the text (writing about
themselves), that is NOT an agent persona.
- If you can identify the user's name from context, put it in user_name
(separate field). If unclear, leave user_name null.
Respond with JSON only (no prose before or after):
{
"is_ai_dialogue_corpus": <true|false>,
"confidence": <0.0 to 1.0>,
"primary_platform": <"Claude (Anthropic)" | "ChatGPT (OpenAI)" | "Gemini (Google)" | other platform name | null>,
"user_name": <user's name if clearly identifiable from context, else null>,
"agent_persona_names": [<names the user has assigned to the AI AGENT(S), NOT the user's own name>],
"evidence": [<short bullet strings explaining the decision>]
}
Default stance: if evidence is thin or mixed, return is_ai_dialogue_corpus=true \
with low confidence. False-negatives on AI-dialogue detection break downstream \
classification; false-positives are recoverable later.
"""
def _extract_json(text: str) -> Optional[dict]:
"""Pull the first JSON object out of a possibly-messy LLM response."""
text = text.strip()
if not text:
return None
# Try straight parse first
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Try to find a {...} block
start = text.find("{")
if start < 0:
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
candidate = text[start : i + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
return None
return None
def detect_origin_llm(samples: list[str], provider) -> CorpusOriginResult:
"""LLM-assisted detection. Takes samples (list of drawer-text excerpts)
and an LLMProvider (mempalace.llm_client.LLMProvider). Returns the
same CorpusOriginResult shape as the heuristic.
Falls back conservatively (default-stance ai=True, low confidence)
on any LLM error or malformed response — never raises.
"""
# Build the user prompt: concise excerpts, capped so we stay cheap
max_excerpt_chars = 800
excerpts = "\n\n---\n\n".join(
f"[sample {i + 1}]\n{s[:max_excerpt_chars]}" for i, s in enumerate(samples[:20])
)
user_prompt = f"CORPUS EXCERPTS:\n\n{excerpts}\n\nAnalyze and respond with JSON."
try:
resp = provider.classify(system=_SYSTEM_PROMPT, user=user_prompt, json_mode=True)
raw = getattr(resp, "text", "") or ""
except Exception as e:
return CorpusOriginResult(
likely_ai_dialogue=True,
confidence=0.3,
primary_platform=None,
evidence=[f"LLM provider error (fallback to default stance): {e}"],
)
parsed = _extract_json(raw)
if not parsed or not isinstance(parsed, dict):
return CorpusOriginResult(
likely_ai_dialogue=True,
confidence=0.3,
primary_platform=None,
evidence=["LLM response was not valid JSON (fallback to default stance)"],
)
# Pull fields defensively. If the LLM leaked the user_name into
# agent_persona_names despite the prompt telling it not to, filter it out.
user_name = parsed.get("user_name") or None
personas = list(parsed.get("agent_persona_names") or [])
if user_name:
personas = [p for p in personas if p.lower() != user_name.lower()]
return CorpusOriginResult(
likely_ai_dialogue=bool(parsed.get("is_ai_dialogue_corpus", True)),
confidence=float(parsed.get("confidence", 0.5)),
primary_platform=parsed.get("primary_platform") or None,
user_name=user_name,
agent_persona_names=personas,
evidence=list(parsed.get("evidence") or []),
)
+1 -1
View File
@@ -89,7 +89,7 @@ def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=Tru
kept = []
to_delete = []
for did, doc, meta in items:
for did, doc, _meta in items:
if not doc or len(doc) < 20:
to_delete.append(did)
continue
+1 -1
View File
@@ -873,7 +873,7 @@ class Dialect:
for date_key in sorted(by_date.keys()):
lines.append(f"=MOMENTS[{date_key}]=")
for z, fnum in by_date[date_key]:
for z, _fnum in by_date[date_key]:
entities = []
for p in z.get("people", []):
code = self.encode_entity(p)
+26 -8
View File
@@ -120,12 +120,28 @@ def ingest_diaries(
continue
date_str = date_match.group(1)
# Skip if content hasn't changed
# Skip if content hasn't changed. Hash-based — size alone false-negatives
# on same-length edits (e.g. "teh" → "the"), silently dropping real edits.
state_key = f"{wing}|{diary_path.name}"
prev_size = state.get(state_key, {}).get("size", 0)
prev_entry = state.get(state_key, {})
prev_hash = prev_entry.get("content_hash")
prev_size = prev_entry.get("size", 0)
curr_size = len(text)
if curr_size == prev_size and not force:
continue
curr_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
if not force:
if prev_hash is not None:
if curr_hash == prev_hash:
continue
elif curr_size == prev_size and prev_size > 0:
# Legacy state without content_hash: keep size-based skip but
# backfill the hash so future runs use the strict check.
state[state_key] = {**prev_entry, "content_hash": curr_hash}
continue
# An in-place edit (same entry count, different content) means existing
# closets are stale. Force a full rebuild whenever the hash changes,
# not only on entry-count growth.
content_changed = prev_hash is not None and curr_hash != prev_hash
now_iso = datetime.now(timezone.utc).isoformat()
drawer_id = _diary_drawer_id(wing, date_str)
@@ -153,7 +169,8 @@ def ingest_diaries(
entries = _split_entries(text)
prev_entry_count = state.get(state_key, {}).get("entry_count", 0)
new_entries = entries if force else entries[prev_entry_count:]
full_rebuild = force or content_changed
new_entries = entries if full_rebuild else entries[prev_entry_count:]
if new_entries:
all_lines = []
@@ -175,15 +192,16 @@ def ingest_diaries(
}
if entities:
closet_meta["entities"] = entities
# On a force rebuild, wipe any leftover numbered closets
# from a longer prior run before re-writing.
if force:
# On any full rebuild (force or detected content edit),
# wipe leftover closets from a prior run before re-writing.
if full_rebuild:
purge_file_closets(closets_col, source_file)
n = upsert_closet_lines(closets_col, closet_id_base, all_lines, closet_meta)
closets_created += n
state[state_key] = {
"size": curr_size,
"content_hash": curr_hash,
"entry_count": len(entries),
"ingested_at": now_iso,
}
+155
View File
@@ -0,0 +1,155 @@
"""Embedding function factory with hardware acceleration.
Returns a ChromaDB-compatible embedding function bound to a user-selected
ONNX Runtime execution provider. The same ``all-MiniLM-L6-v2`` model and
384-dim vectors ChromaDB ships by default are reused, so switching device
does not invalidate existing palaces.
Supported devices (env ``MEMPALACE_EMBEDDING_DEVICE`` or ``embedding_device``
in ``~/.mempalace/config.json``):
* ``auto`` — prefer CUDA ▸ CoreML ▸ DirectML, fall back to CPU
* ``cpu`` — force CPU (the historical default)
* ``cuda`` — NVIDIA GPU via ``onnxruntime-gpu`` (``pip install mempalace[gpu]``)
* ``coreml`` — Apple Neural Engine (macOS)
* ``dml`` — DirectML (Windows / AMD / Intel GPUs)
Requesting an unavailable accelerator emits a warning and falls back to CPU
rather than hard-failing — mining must still work on a laptop without CUDA.
"""
from __future__ import annotations
import logging
from typing import Optional
logger = logging.getLogger(__name__)
_PROVIDER_MAP = {
"cpu": ["CPUExecutionProvider"],
"cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
"coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
"dml": ["DmlExecutionProvider", "CPUExecutionProvider"],
}
_DEVICE_EXTRA = {
"cuda": "mempalace[gpu]",
"coreml": "mempalace[coreml]",
"dml": "mempalace[dml]",
}
_AUTO_ORDER = [
("CUDAExecutionProvider", "cuda"),
("CoreMLExecutionProvider", "coreml"),
("DmlExecutionProvider", "dml"),
]
_EF_CACHE: dict = {}
_WARNED: set = set()
def _resolve_providers(device: str) -> tuple[list, str]:
"""Return ``(provider_list, effective_device)`` for ``device``.
Falls back to CPU (with a one-shot warning) when the requested
accelerator is not compiled into the installed ``onnxruntime``.
"""
device = (device or "auto").strip().lower()
try:
import onnxruntime as ort
available = set(ort.get_available_providers())
except ImportError:
return (["CPUExecutionProvider"], "cpu")
if device == "auto":
for provider, name in _AUTO_ORDER:
if provider in available:
return ([provider, "CPUExecutionProvider"], name)
return (["CPUExecutionProvider"], "cpu")
requested = _PROVIDER_MAP.get(device)
if requested is None:
if device not in _WARNED:
logger.warning("Unknown embedding_device %r — falling back to cpu", device)
_WARNED.add(device)
return (["CPUExecutionProvider"], "cpu")
preferred = requested[0]
if preferred == "CPUExecutionProvider":
return (requested, "cpu")
if preferred not in available:
if device not in _WARNED:
extra = _DEVICE_EXTRA.get(device, "the matching mempalace extra for your device")
logger.warning(
"embedding_device=%r requested but %s is not installed — "
"falling back to CPU. Install %s.",
device,
preferred,
extra,
)
_WARNED.add(device)
return (["CPUExecutionProvider"], "cpu")
return (requested, device)
def _build_ef_class():
"""Subclass ``ONNXMiniLM_L6_V2`` with name ``"default"``.
Why the rename: ChromaDB 1.5 persists the EF identity on the collection
and rejects reads that pass a differently-named EF (``onnx_mini_lm_l6_v2``
vs ``default``). The vectors and model are identical — only the
``name()`` tag differs — so spoofing the name lets one EF class serve
palaces created with ``DefaultEmbeddingFunction`` *and* palaces we
create ourselves, with the same GPU-capable ``preferred_providers``.
"""
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2
class _MempalaceONNX(ONNXMiniLM_L6_V2):
@staticmethod
def name() -> str:
return "default"
return _MempalaceONNX
def get_embedding_function(device: Optional[str] = None):
"""Return a cached embedding function bound to the requested device.
``device=None`` reads from :class:`MempalaceConfig.embedding_device`.
The returned function is shared across calls with the same resolved
provider list so we only pay model-load cost once per process.
"""
if device is None:
from .config import MempalaceConfig
device = MempalaceConfig().embedding_device
providers, effective = _resolve_providers(device)
cache_key = tuple(providers)
cached = _EF_CACHE.get(cache_key)
if cached is not None:
return cached
ef_cls = _build_ef_class()
ef = ef_cls(preferred_providers=providers)
_EF_CACHE[cache_key] = ef
logger.info("Embedding function initialized (device=%s providers=%s)", effective, providers)
return ef
def describe_device(device: Optional[str] = None) -> str:
"""Return a short human-readable label for the resolved device.
Used by the miner CLI header so users can see at a glance whether GPU
acceleration actually engaged.
"""
if device is None:
from .config import MempalaceConfig
device = MempalaceConfig().embedding_device
_, effective = _resolve_providers(device)
return effective
+161 -17
View File
@@ -2,6 +2,9 @@
"""
entity_detector.py — Auto-detect people and projects from file content.
Uses ``from __future__ import annotations`` so PEP 604 union syntax
(``dict | None``) works on the Python 3.9 baseline.
Two-pass approach:
Pass 1: scan files, extract entity candidates with signal counts
Pass 2: score and classify each candidate as person, project, or uncertain
@@ -27,6 +30,8 @@ Usage:
confirmed = confirm_entities(candidates) # interactive review
"""
from __future__ import annotations
import re
import os
import functools
@@ -113,6 +118,23 @@ SKIP_DIRS = {
".next",
"coverage",
".mempalace",
".terraform",
"vendor",
"target",
}
# Files whose content is boilerplate prose — poisons entity detection.
# Matched by stem (case-insensitive), with or without an extension.
SKIP_FILENAMES = {
"license",
"licence",
"copying",
"copyright",
"notice",
"authors",
"patents",
"third_party_notices",
"third-party-notices",
}
@@ -193,7 +215,7 @@ def _build_patterns(name: str, languages: tuple = ("en",)) -> dict:
"person_verbs": _compile_each(sources["person_verb_patterns"]),
"project_verbs": _compile_each(sources["project_verb_patterns"]),
"direct": direct_compiled,
"versioned": re.compile(rf"\b{n}[-v]\w+", re.IGNORECASE),
"versioned": re.compile(rf"\b{n}[-_]v?\d+(?:\.\d+)*\b", re.IGNORECASE),
"code_ref": re.compile(rf"\b{n}\.(py|js|ts|yaml|yml|json|sh)\b", re.IGNORECASE),
}
@@ -227,12 +249,19 @@ def score_entity(name: str, text: str, lines: list, languages=("en",)) -> dict:
# --- Person signals ---
# Dialogue markers (strong signal)
# Dialogue markers (strong signal).
# The bare `^NAME:\s` colon-prefix pattern matches metadata lines like
# `Created: 2026-04-21`, so we require >= 2 hits for it to count as dialogue
# (real speaker markers repeat; single-line metadata doesn't).
for rx in patterns["dialogue"]:
matches = len(rx.findall(text))
if matches > 0:
person_score += matches * 3
person_signals.append(f"dialogue marker ({matches}x)")
if matches == 0:
continue
is_bare_colon = rx.pattern.endswith(r":\s") and not rx.pattern.endswith(r"[:\s]")
if is_bare_colon and matches < 2:
continue
person_score += matches * 3
person_signals.append(f"dialogue marker ({matches}x)")
# Person verbs
for rx in patterns["person_verbs"]:
@@ -328,17 +357,28 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
signal_categories.add("addressed")
has_two_signal_types = len(signal_categories) >= 2
_ = signal_categories - {"pronoun"} # reserved for future thresholds
# Single-category pronoun signal still classifies as person when the
# evidence is overwhelming — a diary's main character is referenced
# with pronouns, not dialogue markers. Require both: many pronoun hits
# AND a high pronoun-to-frequency ratio so common sentence-start words
# (Never, Before, etc.) with incidental pronoun proximity don't qualify.
pronoun_hits = 0
for s in scores["person_signals"]:
m = re.search(r"pronoun nearby \((\d+)x\)", s)
if m:
pronoun_hits = int(m.group(1))
break
strong_pronoun_signal = pronoun_hits >= 5 and frequency > 0 and pronoun_hits / frequency >= 0.2
if person_ratio >= 0.7 and has_two_signal_types and ps >= 5:
if person_ratio >= 0.7 and (has_two_signal_types and ps >= 5 or strong_pronoun_signal):
entity_type = "person"
confidence = min(0.99, 0.5 + person_ratio * 0.5)
signals = scores["person_signals"] or [f"appears {frequency}x"]
elif person_ratio >= 0.7 and (not has_two_signal_types or ps < 5):
# Pronoun-only match — downgrade to uncertain
elif person_ratio >= 0.7:
# Weak single-category person signal — downgrade to uncertain
entity_type = "uncertain"
confidence = 0.4
signals = scores["person_signals"] + [f"appears {frequency}x — pronoun-only match"]
signals = scores["person_signals"] + [f"appears {frequency}x — weak person signal"]
elif person_ratio <= 0.3:
entity_type = "project"
confidence = min(0.99, 0.5 + (1 - person_ratio) * 0.5)
@@ -361,7 +401,12 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
# ==================== MAIN DETECT ====================
def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) -> dict:
def detect_entities(
file_paths: list,
max_files: int = 10,
languages=("en",),
corpus_origin: dict | None = None,
) -> dict:
"""
Scan files and detect entity candidates.
@@ -370,12 +415,24 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
max_files: Max files to read (for speed)
languages: Tuple of language codes whose entity patterns should be
applied (union). Defaults to ``("en",)``.
corpus_origin: Optional corpus-origin context (the dict produced
by ``mempalace.corpus_origin`` and persisted to
``<palace>/.mempalace/origin.json`` by ``mempalace init``).
When supplied and the corpus is identified as AI-dialogue with
known agent persona names, candidates whose name matches an
agent persona are moved out of ``people``/``uncertain`` and
into a new ``agent_personas`` bucket. Shape:
``{"schema_version": 1, "result": {"agent_persona_names": [...], ...}}``.
Returns:
{
"people": [...entity dicts...],
"projects": [...entity dicts...],
"topics": [...entity dicts...],
"uncertain":[...entity dicts...],
# Only present when corpus_origin reclassifies at least one
# candidate as an agent persona:
"agent_personas": [...entity dicts...],
}
"""
langs = _normalize_langs(languages)
@@ -405,7 +462,10 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
candidates = extract_candidates(combined_text, languages=langs)
if not candidates:
return {"people": [], "projects": [], "uncertain": []}
return _apply_corpus_origin(
{"people": [], "projects": [], "topics": [], "uncertain": []},
corpus_origin,
)
# Score and classify each candidate
people = []
@@ -428,13 +488,76 @@ def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) ->
projects.sort(key=lambda x: x["confidence"], reverse=True)
uncertain.sort(key=lambda x: x["frequency"], reverse=True)
# Cap results to most relevant
return {
detected = {
"people": people[:15],
"projects": projects[:10],
"topics": [],
"uncertain": uncertain[:8],
}
return _apply_corpus_origin(detected, corpus_origin)
def _apply_corpus_origin(detected: dict, corpus_origin: dict | None) -> dict:
"""Reclassify per-candidate buckets using corpus-origin context.
When the corpus is identified as AI-dialogue with known agent persona
names, a candidate whose name case-insensitively matches one of those
personas is moved from ``people``/``uncertain`` into an
``agent_personas`` bucket. The candidate's per-entity ``type`` is also
rewritten to ``"agent_persona"``.
No-op when ``corpus_origin`` is ``None`` or contains no usable persona
names. Pure: returns a new dict, does not mutate the input.
"""
if not corpus_origin:
return detected
origin_result = corpus_origin.get("result") or {}
raw_personas = origin_result.get("agent_persona_names") or []
persona_lower = {n.lower() for n in raw_personas if isinstance(n, str)}
if not persona_lower:
return detected
agent_personas: list = []
new_people: list = []
new_uncertain: list = []
for entity in detected.get("people", []):
if entity["name"].lower() in persona_lower:
agent_personas.append(_tag_as_persona(entity))
else:
new_people.append(entity)
for entity in detected.get("uncertain", []):
if entity["name"].lower() in persona_lower:
agent_personas.append(_tag_as_persona(entity))
else:
new_uncertain.append(entity)
if not agent_personas:
return detected
agent_personas.sort(key=lambda x: x.get("confidence", 0), reverse=True)
return {
**detected,
"people": new_people,
"uncertain": new_uncertain,
"agent_personas": agent_personas,
}
def _tag_as_persona(entity: dict) -> dict:
"""Return a new entity dict tagged as agent_persona with provenance signal."""
existing_signals = entity.get("signals", [])
return {
**entity,
"type": "agent_persona",
"confidence": max(0.95, entity.get("confidence", 0.0)),
"signals": ["matched corpus_origin agent_persona_names"] + existing_signals[:2],
}
# ==================== INTERACTIVE CONFIRM ====================
@@ -454,7 +577,13 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
"""
Interactive confirmation step.
User reviews detected entities, removes wrong ones, adds missing ones.
Returns confirmed {people: [names], projects: [names]}
Returns confirmed {people: [names], projects: [names], topics: [names]}.
Topics are not surfaced for interactive review — they come from the
LLM-refined ``TOPIC`` bucket and are passed through verbatim. They
feed cross-wing tunnel computation at mine time (see
``palace_graph.compute_topic_tunnels``); a wrong topic at worst adds
a low-traffic tunnel and never alters drawer storage.
Pass yes=True to auto-accept all detected entities without prompting.
"""
@@ -466,18 +595,28 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
_print_entity_list(detected["people"], "PEOPLE")
_print_entity_list(detected["projects"], "PROJECTS")
if detected.get("topics"):
_print_entity_list(detected["topics"], "TOPICS (cross-wing tunnel signal)")
if detected["uncertain"]:
_print_entity_list(detected["uncertain"], "UNCERTAIN (need your call)")
confirmed_people = [e["name"] for e in detected["people"]]
confirmed_projects = [e["name"] for e in detected["projects"]]
confirmed_topics = [e["name"] for e in detected.get("topics", [])]
if yes:
# Auto-accept: include all detected (skip uncertain — ambiguous without user input)
print(
f"\n Auto-accepting {len(confirmed_people)} people, {len(confirmed_projects)} projects."
f"\n Auto-accepting {len(confirmed_people)} people, "
f"{len(confirmed_projects)} projects, "
f"{len(confirmed_topics)} topics."
)
return {"people": confirmed_people, "projects": confirmed_projects}
return {
"people": confirmed_people,
"projects": confirmed_projects,
"topics": confirmed_topics,
}
print(f"\n{'─' * 58}")
print(" Options:")
@@ -535,11 +674,14 @@ def confirm_entities(detected: dict, yes: bool = False) -> dict:
print(" Confirmed:")
print(f" People: {', '.join(confirmed_people) or '(none)'}")
print(f" Projects: {', '.join(confirmed_projects) or '(none)'}")
if confirmed_topics:
print(f" Topics: {', '.join(confirmed_topics)}")
print(f"{'=' * 58}\n")
return {
"people": confirmed_people,
"projects": confirmed_projects,
"topics": confirmed_topics,
}
@@ -560,6 +702,8 @@ def scan_for_detection(project_dir: str, max_files: int = 10) -> list:
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for filename in filenames:
filepath = Path(root) / filename
if filepath.stem.lower() in SKIP_FILENAMES:
continue
ext = filepath.suffix.lower()
if ext in PROSE_EXTENSIONS:
prose_files.append(filepath)
+27 -2
View File
@@ -16,6 +16,7 @@ Usage:
"""
import json
import os
import re
import urllib.request
import urllib.parse
@@ -320,11 +321,35 @@ class EntityRegistry:
self._path.parent.chmod(0o700)
except (OSError, NotImplementedError):
pass
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
# Atomic write: serialize to a sibling temp file in the same dir
# (so os.replace stays on one filesystem), fsync, then rename over
# the target. A crash mid-write leaves the previous registry intact
# instead of a half-written file or an empty file from the truncate.
payload = json.dumps(self._data, indent=2)
tmp_path = self._path.with_name(self._path.name + ".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(payload)
f.flush()
os.fsync(f.fileno())
try:
self._path.chmod(0o600)
tmp_path.chmod(0o600)
except (OSError, NotImplementedError):
pass
os.replace(tmp_path, self._path)
# On ext4 (and similar) the rename's durability across power loss
# requires an additional fsync on the parent directory. Without it,
# the kernel can ack the rename and a crash reverts to the state
# where the temp file is present and the target is at the old version.
try:
dir_fd = os.open(str(self._path.parent), os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
# Windows and some special filesystems reject directory fds — they
# have different durability semantics on rename anyway.
pass
@staticmethod
def _empty() -> dict:
+43 -2
View File
@@ -11,6 +11,7 @@ Streams drawers in paginated batches so memory usage stays bounded
regardless of palace size.
"""
import errno
import os
import re
from collections import defaultdict
@@ -26,6 +27,44 @@ def _safe_path_component(name: str) -> str:
return name or "unknown"
def _reject_symlink(path: str, label: str) -> None:
"""Refuse to write into a path that is itself a symlink.
Defense-in-depth: a pre-placed symlink at the export target would
redirect writes to wherever it points (e.g., system directories).
Mirrors the miner's input-side caution.
"""
if os.path.islink(path):
raise ValueError(
f"refusing to export: {label} is a symbolic link ({path!r}). "
f"Remove the symlink or choose a different output path."
)
def _safe_open_for_write(path: str, mode: str, encoding: str = "utf-8"):
"""Open a file for writing, refusing to follow a symlink at the target path.
On POSIX (O_NOFOLLOW available) the open itself fails with ELOOP if path is
a symlink — closing the TOCTOU window between an islink check and the open.
On platforms without O_NOFOLLOW (Windows), pre-checks ``os.path.islink``,
which is narrower than no check at all.
"""
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
if o_nofollow:
flags = os.O_WRONLY | os.O_CREAT | o_nofollow
flags |= os.O_APPEND if "a" in mode else os.O_TRUNC
try:
fd = os.open(path, flags, 0o600)
except OSError as e:
if e.errno == errno.ELOOP:
raise ValueError(f"refusing to write: {path!r} is a symbolic link.") from None
raise
return os.fdopen(fd, mode, encoding=encoding)
if os.path.islink(path):
raise ValueError(f"refusing to write: {path!r} is a symbolic link.")
return open(path, mode, encoding=encoding)
def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -> dict:
"""Export all palace drawers as markdown files organized by wing/room.
@@ -48,6 +87,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
print(" Palace is empty — nothing to export.")
return {"wings": 0, "rooms": 0, "drawers": 0}
_reject_symlink(output_dir, "output_dir")
os.makedirs(output_dir, exist_ok=True)
try:
os.chmod(output_dir, 0o700)
@@ -89,6 +129,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
safe_wing = _safe_path_component(wing)
wing_dir = os.path.join(output_dir, safe_wing)
if wing_dir not in created_wing_dirs:
_reject_symlink(wing_dir, f"wing directory {safe_wing!r}")
os.makedirs(wing_dir, exist_ok=True)
try:
os.chmod(wing_dir, 0o700)
@@ -102,7 +143,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
key = (wing, room)
is_new = key not in opened_rooms
with open(room_path, "a" if not is_new else "w", encoding="utf-8") as f:
with _safe_open_for_write(room_path, "a" if not is_new else "w") as f:
if is_new:
f.write(f"# {wing} / {room}\n\n")
opened_rooms.add(key)
@@ -152,7 +193,7 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
index_lines.append("")
index_path = os.path.join(output_dir, "index.md")
with open(index_path, "w", encoding="utf-8") as f:
with _safe_open_for_write(index_path, "w") as f:
f.write("\n".join(index_lines))
stats = {
+20
View File
@@ -27,6 +27,7 @@ Usage:
from __future__ import annotations
import logging
import os
import re
from datetime import datetime, timezone
@@ -35,6 +36,8 @@ from datetime import datetime, timezone
# ~/.mempalace/known_entities.json on every check_text call.
from .miner import _load_known_entities_raw
logger = logging.getLogger("mempalace_mcp")
# Narrow detection patterns — parse "X is Y's Z" and "X's Z is Y".
# Names are captured greedily as word sequences (letters + optional
@@ -214,6 +217,7 @@ def _check_kg_contradictions(text: str, palace_path: str) -> list:
try:
facts = kg.query_entity(subject, direction="outgoing")
except Exception:
logger.debug("KG lookup failed for subject %r", subject, exc_info=True)
continue
if not facts:
continue
@@ -303,11 +307,27 @@ def _edit_distance(s1: str, s2: str) -> int:
return prev[-1]
def _reconfigure_stdio_utf8_on_windows():
"""Decode --stdin payload as UTF-8 on Windows.
Thin wrapper around the shared helper in ``mempalace._stdio``. Mirrors
the primary CLI policy: stdout/stderr use ``replace`` because
extracted fact text can include surrogate halves round-tripped from
filenames -- ``strict`` would raise UnicodeEncodeError mid-print.
stdin keeps the default ``surrogateescape``.
"""
from ._stdio import reconfigure_stdio_utf8_on_windows
reconfigure_stdio_utf8_on_windows(stdout_errors="replace", stderr_errors="replace")
if __name__ == "__main__":
import argparse
import json
import sys
_reconfigure_stdio_utf8_on_windows()
parser = argparse.ArgumentParser(
description="Check text against known facts in the MemPalace palace.",
epilog="Exits 0 when no issues found, 1 when one or more issues detected.",
+596 -67
View File
@@ -6,6 +6,7 @@ Supported hooks: session-start, stop, precompact
Supported harnesses: claude-code, codex (extensible to cursor, gemini, etc.)
"""
import hashlib
import json
import os
import re
@@ -13,26 +14,97 @@ import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
SAVE_INTERVAL = 15
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
PALACE_ROOT = Path.home() / ".mempalace"
def _detached_popen_kwargs() -> dict:
"""Kwargs that fully detach a Popen child so the hook process can exit.
Without these, Windows holds the parent open until the child closes the
inherited stdout/stderr handles — manifesting as "Stop hook hangs" at
session end (#1268). On POSIX the parent can already exit (orphan
reparents to init), but ``start_new_session`` makes the boundary
explicit so signals to the hook don't propagate to the background mine.
"""
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"):
flags |= getattr(subprocess, name, 0)
if flags:
kwargs["creationflags"] = flags
else:
kwargs["start_new_session"] = True
return kwargs
def _palace_root_exists() -> bool:
"""User-removable kill-switch.
If ~/.mempalace/ does not exist, the user has explicitly cleared it.
All hook side effects (logging, state dir creation, mining, ingestion)
must respect this and short-circuit BEFORE touching disk — including
before logging the short-circuit itself.
Uses ``is_dir()`` rather than ``exists()`` so a stray regular file at
``~/.mempalace`` (or a broken symlink) is treated as absent — otherwise
the kill-switch would be bypassed and ``STATE_DIR.mkdir()`` would later
crash on ``NotADirectoryError``.
"""
return PALACE_ROOT.is_dir()
def _mempalace_python() -> str:
"""Return the python interpreter that has mempalace installed.
When hooks are invoked by Claude Code, sys.executable may be the system
python which lacks chromadb and other deps. Resolution order:
1. MEMPALACE_PYTHON env var (explicit override)
2. Venv python from package install path
3. Editable install: venv/ sibling to mempalace/
4. sys.executable fallback
"""
# Honor explicit override (used by shell hook wrappers)
env_python = os.environ.get("MEMPALACE_PYTHON", "")
if env_python and os.path.isfile(env_python) and os.access(env_python, os.X_OK):
return env_python
# This file lives at <venv>/lib/pythonX.Y/site-packages/mempalace/hooks_cli.py
# or <project>/mempalace/hooks_cli.py (editable install).
venv_bin = Path(__file__).resolve().parents[3] / "bin" / "python"
if venv_bin.is_file():
return str(venv_bin)
# Editable install: assumes project root has a venv/ sibling to mempalace/
project_venv = Path(__file__).resolve().parents[1] / "venv" / "bin" / "python"
if project_venv.is_file():
return str(project_venv)
return sys.executable
_RECENT_MSG_COUNT = 30 # how many recent user messages to summarize
STOP_BLOCK_REASON = (
"AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n"
"1. mempalace_diary_write — AAAK-compressed session summary\n"
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n"
"1. mempalace_diary_write — session summary (what was discussed, "
"key decisions, current state of work)\n"
"2. mempalace_add_drawer — verbatim quotes, decisions, code snippets "
"(place in appropriate wing and room)\n"
"3. mempalace_kg_add — entity relationships (optional)\n"
"Do NOT write to Claude Code's native auto-memory (.md files). "
"Continue conversation after saving."
"For THIS save, use MemPalace MCP tools only (not auto-memory .md files). "
"Use verbatim quotes where possible. Continue conversation after saving."
)
PRECOMPACT_BLOCK_REASON = (
"COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n"
"1. mempalace_diary_write — thorough AAAK-compressed session summary\n"
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n"
"1. mempalace_diary_write — thorough session summary\n"
"2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context "
"(place each in appropriate wing and room)\n"
"3. mempalace_kg_add — entity relationships (optional)\n"
"Be thorough \u2014 after compaction, detailed context will be lost. "
"Do NOT write to Claude Code's native auto-memory (.md files). "
"For THIS save, use MemPalace MCP tools only (not auto-memory .md files). "
"Be thorough — after compaction this is all that survives. "
"Save everything to MemPalace, then allow compaction to proceed."
)
@@ -110,6 +182,8 @@ _state_dir_initialized = False
def _log(message: str):
"""Append to hook state log file."""
if not _palace_root_exists():
return # User removed the palace; do not recreate by logging
global _state_dir_initialized
try:
if not _state_dir_initialized:
@@ -134,23 +208,95 @@ def _log(message: str):
def _output(data: dict):
"""Print JSON to stdout with consistent formatting (pretty-printed)."""
print(json.dumps(data, indent=2, ensure_ascii=False))
"""Print JSON to stdout without importing modules that may redirect streams.
If mempalace.mcp_server is already loaded, reuse its saved real stdout fd.
Otherwise, write directly to fd 1 so hook responses still go to stdout even
if sys.stdout has been redirected elsewhere.
"""
payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
real_stdout_fd: int | None = None
mcp_mod = sys.modules.get("mempalace.mcp_server") or sys.modules.get(
f"{__package__}.mcp_server" if __package__ else "mcp_server"
)
if mcp_mod is not None:
real_stdout_fd = getattr(mcp_mod, "_REAL_STDOUT_FD", None)
fd = real_stdout_fd if real_stdout_fd is not None else 1
offset = 0
try:
while offset < len(payload):
try:
offset += os.write(fd, payload[offset:])
except InterruptedError:
continue
return
except OSError:
pass
sys.stdout.buffer.write(payload)
sys.stdout.buffer.flush()
def _get_mine_dir(transcript_path: str = "") -> str:
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
def _get_mine_targets() -> list[tuple[str, str]]:
"""Return the list of ``(dir, mode)`` targets for auto-ingest.
MEMPAL_DIR (when set and resolvable) contributes a ``"projects"``
target. Transcript ingestion is handled separately by
``_ingest_transcript`` — emitting it here too would double-mine the
same JSONL into a different wing on every hook fire (#1231 review).
An empty list means no MEMPAL_DIR ingest should run.
"""
targets: list[tuple[str, str]] = []
mempal_dir = os.environ.get("MEMPAL_DIR", "")
if mempal_dir and os.path.isdir(mempal_dir):
return mempal_dir
if transcript_path:
path = Path(transcript_path).expanduser()
if path.is_file():
return str(path.parent)
return ""
if mempal_dir:
resolved = Path(mempal_dir).expanduser().resolve()
if resolved.is_dir():
targets.append((str(resolved), "projects"))
return targets
_MINE_PID_FILE = STATE_DIR / "mine.pid"
# Per-target PID guard.
#
# Hook fires ingest mines in the background. If a previous fire's child is
# still running for the *same* target (same source dir, mode, wing), the new
# fire should skip rather than pile up — multiple concurrent mines against the
# same source corrupt the HNSW index and exhaust disk via duplicate upserts
# (#1212, #1206). But mines targeting *different* sources / modes must remain
# independent so the user can have e.g. project-mining and transcript-ingest
# running in parallel.
#
# The single ``mine.pid`` global file used previously failed both ways: the
# guard was rebuilt every spawn (so two near-simultaneous fires both passed
# the check before either wrote), and the file was unconditionally overwritten
# (so the second spawn lost the first PID, orphaning it). The replacement is
# a directory of per-target slots, claimed via ``O_CREAT | O_EXCL`` so the
# claim is atomic and per-target.
_MINE_PID_DIR = STATE_DIR / "mine_pids"
# The per-process PID file path is communicated to the mine subprocess via
# this env var so the child's cleanup hook (in miner.py) can remove its
# own slot on exit without scanning the whole directory.
_MINE_PID_FILE_ENV = "MEMPALACE_MINE_PID_FILE"
def _pid_file_for_cmd(cmd: list[str]) -> Path:
"""Return the per-target PID file path for a mine subcommand.
The key is derived from the mine arguments (everything after ``mine``)
so different (dir, mode, wing) combinations get independent slots.
Two fires with the same arguments collapse to the same slot — which is
exactly the dedup we want.
"""
try:
idx = cmd.index("mine")
key = " ".join(cmd[idx:])
except ValueError:
key = " ".join(cmd)
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
return _MINE_PID_DIR / f"mine_{digest}.pid"
def _pid_alive(pid: int) -> bool:
@@ -186,54 +332,326 @@ def _pid_alive(pid: int) -> bool:
return False
def _mine_already_running() -> bool:
"""Return True if a background mine process from a previous hook fire is still alive."""
def _mine_already_running(cmd: list[str]) -> bool:
"""Return True if a previous mine for ``cmd``'s target is still alive."""
pid_file = _pid_file_for_cmd(cmd)
try:
pid = int(_MINE_PID_FILE.read_text().strip())
except (OSError, ValueError):
recorded = pid_file.read_text().strip()
except OSError:
return False
return _pid_alive(pid)
if not recorded.isdigit():
return False
return _pid_alive(int(recorded))
def _claim_mine_slot(cmd: list[str]) -> Optional[Path]:
"""Atomically reserve the per-target PID slot for ``cmd``.
Returns the slot path on success, or ``None`` if the target is
already being mined by a live process. The reservation is done via
``O_CREAT | O_EXCL`` so two simultaneous hook fires can never both
pass the check; one wins, the other returns None.
A stale slot (file exists but the recorded PID is dead) is reclaimed
transparently — orphan miners that crashed without cleanup do not
block future hook fires forever.
"""
pid_file = _pid_file_for_cmd(cmd)
pid_file.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(str(pid_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
os.close(fd)
return pid_file
except FileExistsError:
pass
# Slot exists. If the holder is alive, defer.
if _mine_already_running(cmd):
return None
# Stale entry; reclaim. The unlink+create is racy against another hook
# firing right now, but the second create's O_EXCL will fail and that
# caller will see the live PID via the next round.
try:
pid_file.unlink()
except FileNotFoundError:
pass
except OSError:
return None
try:
fd = os.open(str(pid_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
os.close(fd)
return pid_file
except FileExistsError:
return None
def _spawn_mine(cmd: list) -> None:
"""Spawn a mine subprocess, write its PID to the lock file, log to hook.log."""
"""Spawn a mine subprocess if no live mine is already targeting it.
The PID slot is claimed atomically *before* the spawn, so two near-
simultaneous hook fires can't both proceed — the second sees the
claimed slot and silently skips. The spawned process inherits a
``MEMPALACE_MINE_PID_FILE`` env var so its cleanup hook can remove
the slot on exit without scanning the directory.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
pid_file = _claim_mine_slot(cmd)
if pid_file is None:
_log(f"Skipping mine: target already running ({' '.join(cmd[-3:])})")
return
child_env = os.environ.copy()
child_env[_MINE_PID_FILE_ENV] = str(pid_file)
with open(log_path, "a") as log_f:
proc = subprocess.Popen(cmd, stdout=log_f, stderr=log_f)
_MINE_PID_FILE.write_text(str(proc.pid))
def _maybe_auto_ingest(transcript_path: str = ""):
"""Run mempalace mine in background if a mine directory is available."""
mine_dir = _get_mine_dir(transcript_path)
if not mine_dir:
return
if _mine_already_running():
_log("Skipping auto-ingest: mine already running")
return
try:
proc = subprocess.Popen(
cmd,
stdout=log_f,
stderr=log_f,
env=child_env,
**_detached_popen_kwargs(),
)
except OSError:
# Spawn failed; release the slot we just claimed so the next
# hook fire can try again rather than skipping forever.
try:
pid_file.unlink()
except OSError:
pass
raise
try:
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
pid_file.write_text(str(proc.pid))
except OSError:
pass
def _mine_sync(transcript_path: str = ""):
"""Run mempalace mine synchronously (for precompact -- data must land first)."""
mine_dir = _get_mine_dir(transcript_path)
if not mine_dir:
def _maybe_auto_ingest():
"""Background-mine MEMPAL_DIR (project files) if set.
Transcript convos are ingested separately via ``_ingest_transcript``
in the hook handlers — this function does not handle them, to avoid
asymmetric interpreter handling and PID-file overwrite when both
targets fire from a single hook call (#1231 review).
Per-target dedup is done by ``_spawn_mine`` itself: each (dir, mode)
target gets its own PID slot, so distinct targets never block each
other but a re-fire of the same target while the previous one is
still running is silently skipped.
"""
targets = _get_mine_targets()
if not targets:
return
for mine_dir, mode in targets:
try:
_spawn_mine([_mempalace_python(), "-m", "mempalace", "mine", mine_dir, "--mode", mode])
except OSError:
pass
def _mine_sync():
"""Synchronously mine MEMPAL_DIR (precompact path).
Transcript convos are ingested separately via ``_ingest_transcript``
in ``hook_precompact`` — keeping them out of this function avoids
timeout stacking against the harness 30s ceiling (#1231 review).
"""
targets = _get_mine_targets()
if not targets:
return
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
for mine_dir, mode in targets:
try:
with open(log_path, "a") as log_f:
subprocess.run(
[
_mempalace_python(),
"-m",
"mempalace",
"mine",
mine_dir,
"--mode",
mode,
],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
pass
def _desktop_toast(body: str, title: str = "MemPalace"):
"""Send a desktop notification via notify-send. Fails silently."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
subprocess.Popen(
["notify-send", "--app-name=MemPalace", "--icon=brain", title, body],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**_detached_popen_kwargs(),
)
except OSError:
pass
def _extract_recent_messages(transcript_path: str, count: int = _RECENT_MSG_COUNT) -> list[str]:
"""Extract the last N user messages from a JSONL transcript."""
path = Path(transcript_path).expanduser()
if not path.is_file():
return []
messages = []
try:
with open(path, encoding="utf-8", errors="replace") as f:
for line in f:
try:
entry = json.loads(line)
# Claude Code format
msg = entry.get("message") or entry.get("event_message") or {}
if isinstance(msg, dict) and msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, list):
content = " ".join(
b.get("text", "") for b in content if isinstance(b, dict)
)
if not isinstance(content, str) or not content.strip():
continue
if "<command-message>" in content or "<system-reminder>" in content:
continue
messages.append(content.strip()[:200])
# Codex CLI format
elif entry.get("type") == "event_msg":
payload = entry.get("payload", {})
if isinstance(payload, dict) and payload.get("type") == "user_message":
text = payload.get("message", "")
if isinstance(text, str) and text.strip():
if "<command-message>" not in text:
messages.append(text.strip()[:200])
except (json.JSONDecodeError, AttributeError):
pass
except OSError:
return []
return messages[-count:]
_THEME_STOPWORDS = frozenset(
"the a an and or but in on at to for of is it i me my you your we our "
"this that with from by was were be been are not no yes can do did dont "
"will would should could have has had lets let just also like so if then "
"ok okay sure yeah hey hi here there what when where how why which some "
"all any each every about into out up down over after before between "
"get got make made need want use used using check look see run try "
"know think right now still already really very much more most too "
"file files code one two new first last next thing things way well".split()
)
def _extract_themes(messages: list[str], max_themes: int = 3) -> list[str]:
"""Pull 2-3 distinctive topic words from recent messages.
Note: stopword list is English-only; non-English corpora will produce noisy themes.
"""
from collections import Counter
words: Counter[str] = Counter()
for msg in messages:
for word in msg.lower().split():
# Strip punctuation, keep words 4+ chars
clean = word.strip(".,;:!?\"'`()[]{}#<>/\\-_=+@$%^&*~")
if len(clean) >= 4 and clean not in _THEME_STOPWORDS and clean.isalpha():
words[clean] += 1
return [w for w, _ in words.most_common(max_themes)]
def _save_diary_direct(
transcript_path: str,
session_id: str,
wing: str = "",
toast: bool = False,
) -> dict:
"""Write a diary checkpoint by calling the tool function directly (no MCP roundtrip).
If `wing` is set, the entry lands in that wing (typically the project wing
derived from the transcript path). Otherwise falls back to `tool_diary_write`'s
default of `wing_session-hook`.
Returns {"count": N, "themes": [...]} on success, {"count": 0} on failure.
"""
messages = _extract_recent_messages(transcript_path)
if not messages:
_log("No recent messages to save")
return {"count": 0}
themes = _extract_themes(messages)
# Build a compressed diary entry from recent conversation
now = datetime.now()
topics = "|".join(m[:80] for m in messages[-10:])
entry = (
f"CHECKPOINT:{now.strftime('%Y-%m-%d')}|session:{session_id}"
f"|msgs:{len(messages)}|recent:{topics}"
)
try:
from .mcp_server import tool_diary_write
result = tool_diary_write(
agent_name="session-hook",
entry=entry,
topic="checkpoint",
wing=wing,
)
if result.get("success"):
_log(f"Diary checkpoint saved: {result.get('entry_id', '?')}")
# Write state for ack tool to read
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 \u2014 {len(messages)} messages archived")
return {"count": len(messages), "themes": themes}
else:
_log(f"Diary checkpoint failed: {result.get('error', 'unknown')}")
except Exception as e:
_log(f"Diary checkpoint error: {e}")
return {"count": 0}
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:
return
from .config import MempalaceConfig
try:
MempalaceConfig() # validate config loads
except Exception:
return
try:
# 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.
_spawn_mine(
[
_mempalace_python(),
"-m",
"mempalace",
"mine",
str(path.parent),
"--mode",
"convos",
"--wing",
"sessions",
]
)
_log(f"Transcript ingest started: {path.name}")
except OSError:
pass
@@ -252,17 +670,72 @@ def _parse_harness_input(data: dict, harness: str) -> dict:
}
def _wing_from_transcript_path(transcript_path: str) -> str:
"""Derive a project wing name from a Claude Code transcript path.
Claude Code encodes the project's source directory by replacing path
separators with dashes, producing folders like:
~/.claude/projects/-home-<user>-Projects-<project>/session.jsonl
~/.claude/projects/-home-<user>-dev-<parent>-<project>/session.jsonl
~/.claude/projects/-Users-<user>-<folder>-<project>/session.jsonl
The project directory name is the final dash-separated token of the
encoded folder. Returns ``wing_<project>`` (lowercased, spaces → ``_``).
Falls back to ``wing_sessions`` if the path does not match a Claude Code
project-folder layout.
"""
# Normalize path separators for cross-platform (Windows backslashes)
normalized = transcript_path.replace("\\", "/")
# Primary: pull the encoded project folder out of ``.claude/projects/``
# and take its last dash-separated token.
match = re.search(r"/\.claude/projects/-([^/]+)", normalized)
if match:
encoded = match.group(1)
project = encoded.rsplit("-", 1)[-1]
if project:
return f"wing_{project.lower().replace(' ', '_')}"
# Legacy fallback: explicit ``-Projects-<name>`` segment, useful for
# transcripts not under the standard Claude Code projects dir.
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
if match:
project = match.group(1).lower().replace(" ", "_")
return f"wing_{project}"
return "wing_sessions"
def hook_stop(data: dict, harness: str):
"""Stop hook: block every N messages for auto-save."""
if not _palace_root_exists():
_output({})
return
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
stop_hook_active = parsed["stop_hook_active"]
transcript_path = parsed["transcript_path"]
# If already in a save cycle, let through (infinite-loop prevention)
# If already in a block-mode save cycle, let through (infinite-loop prevention).
# Silent mode saves directly without returning {"decision":"block"}, so there's
# no loop to prevent — and Claude Code's plugin dispatch sets this flag on every
# fire after the first, which would otherwise suppress all subsequent auto-saves.
if str(stop_hook_active).lower() in ("true", "1", "yes"):
_output({})
return
# Safe default: assume silent mode on any config-read failure so saves
# proceed rather than being silently dropped. Silent mode is the default
# (v3.3.0+), so if we can't read config, behave as if it's still on.
silent_guard = True
try:
from .config import MempalaceConfig
except ImportError as exc:
_log(
f"WARNING: could not import MempalaceConfig for stop guard: {exc}; defaulting to silent mode"
)
else:
try:
silent_guard = MempalaceConfig().hook_silent_save
except AttributeError as exc:
_log(f"WARNING: could not read hook_silent_save: {exc}; defaulting to silent mode")
if not silent_guard:
_output({})
return
# Count human messages
exchange_count = _count_human_messages(transcript_path)
@@ -282,24 +755,71 @@ def hook_stop(data: dict, harness: str):
_log(f"Session {session_id}: {exchange_count} exchanges, {since_last} since last save")
if since_last >= SAVE_INTERVAL and exchange_count > 0:
# Update last save point
try:
last_save_file.write_text(str(exchange_count), encoding="utf-8")
except OSError:
pass
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
# Optional: auto-ingest if MEMPAL_DIR is set
_maybe_auto_ingest(transcript_path)
# Read hook settings from config
from .config import MempalaceConfig
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
try:
config = MempalaceConfig()
silent = config.hook_silent_save
toast = config.hook_desktop_toast
except Exception:
silent = True
toast = False
project_wing = _wing_from_transcript_path(transcript_path)
if silent:
# Save directly via Python API — systemMessage renders in terminal
result = {"count": 0}
if transcript_path:
result = _save_diary_direct(
transcript_path, session_id, wing=project_wing, toast=toast
)
_ingest_transcript(transcript_path)
_maybe_auto_ingest()
# Only advance save marker after successful save
count = result.get("count", 0)
if count > 0:
try:
last_save_file.write_text(str(exchange_count), encoding="utf-8")
except OSError:
pass
themes = result.get("themes", [])
if themes:
tag = " \u2014 " + ", ".join(themes)
else:
tag = ""
_output(
{
"systemMessage": f"\u2726 {count} memories woven into the palace{tag}",
}
)
else:
_output({})
else:
# Legacy: block and ask Claude to save via MCP tools.
# Marker advances before confirmed save — best-effort; if Claude
# fails to save, the checkpoint is lost but won't retry endlessly.
try:
last_save_file.write_text(str(exchange_count), encoding="utf-8")
except OSError:
pass
if transcript_path:
_ingest_transcript(transcript_path)
_maybe_auto_ingest()
reason = STOP_BLOCK_REASON + f" Write diary entry to wing={project_wing}."
_output({"decision": "block", "reason": reason})
else:
_output({})
def hook_session_start(data: dict, harness: str):
"""Session start hook: initialize session tracking state."""
if not _palace_root_exists():
_output({})
return
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
@@ -314,14 +834,23 @@ def hook_session_start(data: dict, harness: str):
def hook_precompact(data: dict, harness: str):
"""Precompact hook: mine transcript synchronously, then allow compaction."""
if not _palace_root_exists():
_output({})
return
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
transcript_path = parsed["transcript_path"]
_log(f"PRE-COMPACT triggered for session {session_id}")
# Mine synchronously so data lands before compaction proceeds
_mine_sync(transcript_path)
# Capture tool output via our normalize path before compaction loses it
if transcript_path:
_ingest_transcript(transcript_path)
# Mine MEMPAL_DIR synchronously so project data lands before
# compaction proceeds. Transcript convos were already kicked off
# above via _ingest_transcript.
_mine_sync()
_output({})
+166
View File
@@ -0,0 +1,166 @@
{
"lang": "be",
"label": "Беларуская",
"terms": {
"palace": "палац",
"wing": "крыло",
"hall": "зала",
"closet": "шафа",
"drawer": "шуфляда",
"mine": "майнінг",
"search": "пошук",
"status": "статус",
"init": "ініцыялізацыя",
"repair": "аднаўленне",
"migrate": "міграцыя",
"entity": "аб'ект",
"topic": "тэма"
},
"cli": {
"mine_start": "Майнінг {path}...",
"mine_complete": "Гатова. Створана шаф: {closets}, шуфляд: {drawers}.",
"mine_skip": "Майнінг скончаны. Выкарыстайце --force, каб перамайніць.",
"search_no_results": "Няма вынікаў для: {query}",
"search_results": "Знойдзена {count} вынікаў:",
"status_palace": "Палац: {path}",
"status_wings": "Крылаў: {count}",
"status_closets": "Шаф: {count}",
"status_drawers": "Шуфляд: {count}",
"init_complete": "Палац ініцыялізаваны ў {path}",
"init_exists": "Палац ужо існуе ў {path}",
"repair_complete": "Аднаўленне скончана. Выпраўлена праблем: {fixed}.",
"migrate_complete": "Міграцыя завершана.",
"no_palace": "Палац не знойдзены. Запусціце: mempalace init <дырэкторыя>"
},
"aaak": {
"instruction": "Сцісні да фармату індэкса. Злучкі паміж словамі, вертыкальныя рысы паміж паняткамі. Выдалі падставы і службовыя словы. Захавай дакладнасць імёнаў, нумароў, скланенняў і формаў."
},
"regex": {
"topic_pattern": "[А-ЯЁІЎ][а-яёіў]{2,}|[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "і а але бо каб калі калісьці хоць што хто дзе куды адкуль як чаму таму ж не ні так таксама яшчэ ўжо цяпер потым тут там адсюль туды ў на па з са без для над пад пры пра праз супраць замест акрамя сярод вакол уздоўж каля паміж пасля перад да ад у або альбо то ці быццам нібыта вось гэты гэта гэтыя той тая тыя некаторыя кожны кожная кожныя толькі вельмі можа трэба будзе быў была былі ёсць няма",
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
"action_pattern": "(?:зрабіў|зрабіла|зрабілі|стварыў|стварыла|стварылі|дадаў|дадала|дадалі|абнавіў|абнавіла|абнавілі|наладзіў|наладзіла|наладзілі|пратэставаў|пратэставала|пратэставалі)\\s+[\\wа-яёА-ЯЁІіЎў\\s]{3,30}"
},
"entity": {
"candidate_pattern": "[А-ЯЁІЎ][а-яёіў]{1,19}",
"multi_word_pattern": "[А-ЯЁІЎ][а-яёіў]+(?:\\s+[А-ЯЁІЎ][а-яёіў]+)+",
"person_verb_patterns": [
"\\b{name}\\s+сказа(?:ў|ла|лі)\\b",
"\\b{name}\\s+спыта(?:ў|ла|лі)\\b",
"\\b{name}\\s+адказа(?:ў|ла|лі)\\b",
"\\b{name}\\s+расказа(?:ў|ла|лі)\\b",
"\\b{name}\\s+засмея(?:ўся|лася|ліся)\\b",
"\\b{name}\\s+усміхну(?:ўся|лася|ліся)\\b",
"\\b{name}\\s+заплака(?:ў|ла|лі)\\b",
"\\b{name}\\s+адчу(?:ў|ла|лі)\\b",
"\\b{name}\\s+думае\\b",
"\\b{name}\\s+хоча\\b",
"\\b{name}\\s+кахае\\b",
"\\b{name}\\s+ненавідзіць\\b",
"\\b{name}\\s+ведае\\b",
"\\b{name}\\s+вырашы(?:ў|ла|лі)\\b",
"\\b{name}\\s+напіса(?:ў|ла|лі)\\b",
"\\b{name}\\s+каза(?:ў|ла|лі)\\b",
"\\b{name}\\s+зрабі(?:ў|ла|лі)\\b",
"\\b{name}\\s+спытваецца\\b",
"\\b{name}\\s+адказвае\\b"
],
"pronoun_patterns": [
"\\bён\\b",
"\\bяго\\b",
"\\bяму\\b",
"\\bім\\b",
"\\bяна\\b",
"\\bяе\\b",
"\\bёй\\b",
"\\bёю\\b",
"\\bяны\\b",
"\\bіх\\b",
"\\bімі\\b",
"\\bяно\\b",
"\\bвы\\b",
"\\bвас\\b",
"\\bвам\\b",
"\\bвамі\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+сказа(?:ў|ла|лі)"
],
"direct_address_pattern": "\\bпрывітанне[,:!.]?\\s+{name}\\b|\\bвітаю[,:!.]?\\s+{name}\\b|\\bдзякуй[,:!.]?\\s+{name}\\b|\\bдарагі[,:!.]?\\s+{name}\\b|\\bдарагая[,:!.]?\\s+{name}\\b|\\bпаважаны[,:!.]?\\s+{name}\\b|\\bпаважаная[,:!.]?\\s+{name}\\b",
"project_verb_patterns": [
"\\bзбіраю\\s+{name}\\b",
"\\bсабраў\\s+{name}\\b",
"\\bзапускаю\\s+{name}\\b",
"\\bзапусціў\\s+{name}\\b",
"\\bразгарнуў\\s+{name}\\b",
"\\bусталяваў\\s+{name}\\b",
"\\bсістэма\\s+{name}\\b",
"\\bпраект\\s+{name}\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"і",
"ў",
"з",
"са",
"на",
"па",
"да",
"ад",
"у",
"без",
"для",
"над",
"пад",
"пры",
"пра",
"праз",
"супраць",
"замест",
"акрамя",
"сярод",
"вакол",
"уздоўж",
"каля",
"паміж",
"пасля",
"перад",
"або",
"альбо",
"каб",
"калі",
"хоць",
"бо",
"ж",
"не",
"ні",
"так",
"яшчэ",
"ужо",
"цяпер",
"вось",
"гэта",
"тое",
"тут",
"там",
"вельмі",
"толькі",
"можа",
"трэба",
"ёсць",
"няма",
"як",
"што",
"хто",
"чаму",
"таму",
"прывітанне",
"дзякуй",
"ласка"
]
}
}
+82
View File
@@ -40,5 +40,87 @@
"stop_words": "der die das ein eine eines einer einem einen den dem des und oder aber denn weil wenn als ob auch noch schon sehr viel nur nicht mehr kann wird hat ist sind war waren sein haben wurde mit von zu für auf in an um über nach durch",
"quote_pattern": "\\u201E([^\\u201C]{10,200})\\u201C|\"([^\"]{10,200})\"",
"action_pattern": "(?:gebaut|behoben|geschrieben|hinzugefügt|gepusht|gemessen|getestet|überprüft|erstellt|gelöscht|aktualisiert|konfiguriert|bereitgestellt|migriert)\\s+[\\wÄÖÜäöüß\\s]{3,30}"
},
"entity": {
"candidate_pattern": "[A-ZÄÖÜ][a-zäöüß]{1,19}",
"multi_word_pattern": "[A-ZÄÖÜ][a-zäöüß]+(?:\\s+[A-ZÄÖÜ][a-zäöüß]+)+",
"person_verb_patterns": [
"\\b{name}\\s+sagte\\b",
"\\b{name}\\s+fragte\\b",
"\\b{name}\\s+antwortete\\b",
"\\b{name}\\s+erzählte\\b",
"\\b{name}\\s+lachte\\b",
"\\b{name}\\s+lächelte\\b",
"\\b{name}\\s+weinte\\b",
"\\b{name}\\s+fühlte\\b",
"\\b{name}\\s+denkt\\b",
"\\b{name}\\s+will\\b",
"\\b{name}\\s+liebt\\b",
"\\b{name}\\s+hasst\\b",
"\\b{name}\\s+weiß\\b",
"\\b{name}\\s+entschied\\b",
"\\b{name}\\s+schrieb\\b"
],
"pronoun_patterns": [
"\\ber\\b",
"\\bsie\\b",
"\\bes\\b",
"\\bihn\\b",
"\\bihm\\b",
"\\bihr\\b",
"\\bsein\\b",
"\\bihre\\b",
"\\bihnen\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+sagte"
],
"direct_address_pattern": "\\bhallo\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bdanke\\s+{name}\\b|\\bservus\\s+{name}\\b|\\blieber\\s+{name}\\b|\\bliebe\\s+{name}\\b|\\bsehr\\s+geehrter\\s+{name}\\b|\\bsehr\\s+geehrte\\s+{name}\\b",
"project_verb_patterns": [
"\\bbaue\\s+{name}\\b",
"\\bgebaut\\s+{name}\\b",
"\\bstarte\\s+{name}\\b",
"\\bgestartet\\s+{name}\\b",
"\\bdeploye\\s+{name}\\b",
"\\binstalliert\\s+{name}\\b",
"\\bdie\\s+{name}\\s+architektur\\b",
"\\bdie\\s+{name}\\s+pipeline\\b",
"\\bdas\\s+{name}\\s+system\\b",
"\\bdas\\s+{name}\\s+repository\\b",
"\\b{name}\\s+v\\d+\\b",
"\\b{name}\\.py\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"der", "die", "das", "ein", "eine", "eines", "einer", "einem", "einen",
"den", "dem", "des", "und", "oder", "aber", "denn", "weil", "wenn", "als",
"ob", "auch", "noch", "schon", "sehr", "viel", "nur", "nicht", "mehr",
"kann", "wird", "hat", "ist", "sind", "war", "waren", "sein", "haben",
"wurde", "worden", "werden", "mit", "von", "zu", "für", "auf", "in",
"an", "um", "über", "nach", "durch", "bei", "aus", "seit", "vor", "zwischen",
"ich", "du", "er", "sie", "es", "wir", "ihr", "mich", "dich", "mir", "dir",
"uns", "euch", "mein", "dein", "unser", "euer", "ihre", "seine",
"wer", "was", "wann", "wo", "wie", "warum", "welcher", "welche", "welches",
"so", "dann", "jetzt", "heute", "gestern", "morgen", "hier", "dort", "da",
"immer", "nie", "manchmal", "oft", "selten", "bald", "spät",
"ja", "nein", "vielleicht", "gut", "schlecht", "besser", "bitte", "danke",
"hallo", "hi", "hey", "tschüss",
"tag", "tage", "woche", "monat", "jahr", "jahre", "zeit", "welt", "leben",
"mensch", "menschen", "leute", "person", "ding", "dinge", "sache", "sachen",
"teil", "art", "weise", "stelle", "platz", "ort", "zimmer", "haus", "land",
"grund", "frage", "antwort", "fakt", "sinn", "idee", "punkt", "fall", "aspekt",
"beispiel", "version", "nummer", "zahl", "name", "namen", "system", "modell",
"sprache", "technologie", "gesellschaft", "kultur", "geschichte",
"wissenschaft", "zukunft", "erinnerung", "gedächtnis",
"datei", "ordner", "pfad", "schlüssel", "wert", "fehler", "warnung",
"ergebnis", "eingabe", "ausgabe", "quelle", "ziel", "daten", "elemente",
"montag", "dienstag", "mittwoch", "donnerstag", "freitag", "samstag", "sonntag",
"januar", "februar", "märz", "april", "mai", "juni", "juli", "august",
"september", "oktober", "november", "dezember"
]
}
}
+12 -2
View File
@@ -42,7 +42,7 @@
"action_pattern": "(?:built|fixed|wrote|added|pushed|measured|tested|reviewed|created|deleted|updated|configured|deployed|migrated)\\s+[\\w\\s]{3,30}"
},
"entity": {
"candidate_pattern": "[A-Z][a-z]{1,19}",
"candidate_pattern": "[A-Z][a-z]+(?:[A-Z][a-z]+|[A-Z]{2,})+|[A-Z][a-z]{1,19}",
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
"person_verb_patterns": [
"\\b{name}\\s+said\\b",
@@ -140,7 +140,17 @@
"agents", "tools", "others", "guards", "ethics", "regulation",
"learning", "thinking", "memory", "language", "intelligence",
"technology", "society", "culture", "future", "history", "science",
"model", "models", "network", "networks", "training", "inference"
"model", "models", "network", "networks", "training", "inference",
"created", "updated", "deleted", "added", "removed", "modified",
"extracted", "processed", "generated", "compiled", "launched", "installed",
"deployed", "executed", "loaded", "parsed", "validated", "configured",
"total", "summary", "covered", "included", "pending", "failed", "success",
"ready", "active", "disabled", "enabled", "available", "completed",
"auto", "multi", "mini", "micro", "meta", "super", "hybrid",
"context", "bridge", "batch", "local", "global", "native", "cloud",
"before", "after", "during", "often", "always", "never",
"project", "contributor", "software",
"backend", "frontend", "server", "client", "service", "app", "api"
]
}
}
+84
View File
@@ -40,5 +40,89 @@
"stop_words": "el la los las un una unos unas de del al en con por para su sus mi mis tu tus es son está están fue ser estar haber sido como pero más muy también todo todos toda todas este esta estos estas ese esa esos esas que quien cual donde cuando porque aunque sin",
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
"action_pattern": "(?:construido|corregido|escrito|añadido|enviado|medido|probado|revisado|creado|eliminado|actualizado|configurado|desplegado|migrado)\\s+[\\wá-ú\\s]{3,30}"
},
"entity": {
"candidate_pattern": "[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]{1,19}",
"multi_word_pattern": "[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+(?:\\s+[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+)+",
"person_verb_patterns": [
"\\b{name}\\s+dijo\\b",
"\\b{name}\\s+preguntó\\b",
"\\b{name}\\s+respondió\\b",
"\\b{name}\\s+contó\\b",
"\\b{name}\\s+rió\\b",
"\\b{name}\\s+sonrió\\b",
"\\b{name}\\s+lloró\\b",
"\\b{name}\\s+sintió\\b",
"\\b{name}\\s+piensa\\b",
"\\b{name}\\s+quiere\\b",
"\\b{name}\\s+ama\\b",
"\\b{name}\\s+odia\\b",
"\\b{name}\\s+sabe\\b",
"\\b{name}\\s+decidió\\b",
"\\b{name}\\s+escribió\\b"
],
"pronoun_patterns": [
"\\bél\\b",
"\\bella\\b",
"\\bellos\\b",
"\\bellas\\b",
"\\blo\\b",
"\\bla\\b",
"\\ble\\b",
"\\bles\\b",
"\\bse\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+dijo"
],
"direct_address_pattern": "\\bhola\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bgracias\\s+{name}\\b|\\bquerido\\s+{name}\\b|\\bquerida\\s+{name}\\b|\\bestimado\\s+{name}\\b|\\bestimada\\s+{name}\\b|\\bdon\\s+{name}\\b|\\bdoña\\s+{name}\\b|\\bseñor\\s+{name}\\b|\\bseñora\\s+{name}\\b",
"project_verb_patterns": [
"\\bconstruyo\\s+{name}\\b",
"\\bconstruí\\s+{name}\\b",
"\\barmé\\s+{name}\\b",
"\\blancé\\s+{name}\\b",
"\\bdesplegué\\s+{name}\\b",
"\\binstalé\\s+{name}\\b",
"\\bla\\s+arquitectura\\s+{name}\\b",
"\\bel\\s+pipeline\\s+{name}\\b",
"\\bel\\s+sistema\\s+{name}\\b",
"\\bel\\s+proyecto\\s+{name}\\b",
"\\bel\\s+repositorio\\s+{name}\\b",
"\\b{name}\\s+v\\d+\\b",
"\\b{name}\\.py\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"el", "la", "los", "las", "un", "una", "unos", "unas",
"de", "del", "al", "a", "en", "con", "sin", "por", "para", "sobre",
"entre", "hasta", "desde", "hacia", "contra", "según", "tras",
"y", "o", "u", "ni", "pero", "sino", "aunque", "porque", "pues",
"que", "quien", "quienes", "cual", "cuales", "cuyo", "cuya",
"donde", "cuando", "como", "cuanto", "cuanta",
"yo", "tú", "él", "ella", "nosotros", "vosotros", "ellos", "ellas",
"me", "te", "se", "nos", "os", "lo", "la", "le", "los", "las", "les",
"mi", "mis", "tu", "tus", "su", "sus", "nuestro", "nuestra", "vuestro",
"este", "esta", "estos", "estas", "ese", "esa", "esos", "esas",
"aquel", "aquella", "aquellos", "aquellas", "esto", "eso", "aquello",
"ser", "estar", "haber", "tener", "hacer", "poder", "querer", "saber",
"es", "son", "fue", "fueron", "era", "eran", "está", "están", "estaba",
"he", "ha", "hemos", "han", "había", "hay",
"muy", "mucho", "mucha", "muchos", "muchas", "poco", "poca", "pocos", "pocas",
"más", "menos", "tan", "tanto", "también", "tampoco",
"sí", "no", "quizás", "tal", "vez",
"aquí", "allí", "allá", "ahí", "acá",
"hoy", "ayer", "mañana", "ahora", "antes", "después", "luego", "entonces",
"siempre", "nunca", "jamás", "todavía", "aún", "ya",
"bien", "mal", "mejor", "peor", "bueno", "buena", "malo", "mala",
"gracias", "hola", "adiós", "por favor", "perdón",
"día", "días", "semana", "mes", "año", "años", "tiempo", "vez", "veces",
"cosa", "cosas", "persona", "gente", "mundo", "vida", "casa", "lugar",
"forma", "manera", "parte", "caso", "punto", "idea", "hecho", "razón",
"nombre", "número", "versión", "sistema", "modelo"
]
}
}
+82
View File
@@ -40,5 +40,87 @@
"stop_words": "le la les un une des de du au aux en et ou mais donc or ni car que qui ce cette ces son sa ses mon ma mes ton ta tes leur leurs nous vous ils elles on ne pas plus très bien aussi avec pour dans sur par est sont fait être avoir été comme tout tous toute toutes",
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
"action_pattern": "(?:construit|corrigé|écrit|ajouté|poussé|mesuré|testé|révisé|créé|supprimé|mis à jour|configuré|déployé|migré)\\s+[\\wà-ÿ\\s]{3,30}"
},
"entity": {
"candidate_pattern": "[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]{1,19}",
"multi_word_pattern": "[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]+(?:\\s+[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸÆŒ][a-zàâäçéèêëîïôöùûüÿæœ]+)+",
"person_verb_patterns": [
"\\b{name}\\s+a\\s+dit\\b",
"\\b{name}\\s+a\\s+demandé\\b",
"\\b{name}\\s+a\\s+répondu\\b",
"\\b{name}\\s+a\\s+raconté\\b",
"\\b{name}\\s+a\\s+ri\\b",
"\\b{name}\\s+a\\s+souri\\b",
"\\b{name}\\s+a\\s+pleuré\\b",
"\\b{name}\\s+a\\s+senti\\b",
"\\b{name}\\s+pense\\b",
"\\b{name}\\s+veut\\b",
"\\b{name}\\s+aime\\b",
"\\b{name}\\s+déteste\\b",
"\\b{name}\\s+sait\\b",
"\\b{name}\\s+a\\s+décidé\\b",
"\\b{name}\\s+a\\s+écrit\\b"
],
"pronoun_patterns": [
"\\bil\\b",
"\\belle\\b",
"\\blui\\b",
"\\bils\\b",
"\\belles\\b",
"\\bleur\\b",
"\\bleurs\\b",
"\\beux\\b",
"\\bse\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+a\\s+dit"
],
"direct_address_pattern": "\\bbonjour\\s+{name}\\b|\\bsalut\\s+{name}\\b|\\bmerci\\s+{name}\\b|\\bcher\\s+{name}\\b|\\bchère\\s+{name}\\b|\\bmonsieur\\s+{name}\\b|\\bmadame\\s+{name}\\b|\\bhey\\s+{name}\\b|\\bhi\\s+{name}\\b",
"project_verb_patterns": [
"\\bconstruit\\s+{name}\\b",
"\\blancé\\s+{name}\\b",
"\\bdéployé\\s+{name}\\b",
"\\binstallé\\s+{name}\\b",
"\\bl'architecture\\s+{name}\\b",
"\\ble\\s+pipeline\\s+{name}\\b",
"\\ble\\s+système\\s+{name}\\b",
"\\ble\\s+projet\\s+{name}\\b",
"\\ble\\s+dépôt\\s+{name}\\b",
"\\b{name}\\s+v\\d+\\b",
"\\b{name}\\.py\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"le", "la", "les", "un", "une", "des", "du", "de", "au", "aux",
"en", "dans", "sur", "sous", "avec", "sans", "pour", "par", "vers",
"chez", "entre", "depuis", "pendant", "avant", "après", "jusqu",
"et", "ou", "mais", "donc", "or", "ni", "car", "que", "qui",
"dont", "où", "quand", "comment", "pourquoi", "combien", "lequel",
"ce", "cet", "cette", "ces", "celui", "celle", "ceux", "celles",
"mon", "ma", "mes", "ton", "ta", "tes", "son", "sa", "ses",
"notre", "nos", "votre", "vos", "leur", "leurs",
"je", "tu", "il", "elle", "on", "nous", "vous", "ils", "elles",
"me", "te", "se", "lui", "eux",
"être", "avoir", "faire", "dire", "aller", "voir", "savoir", "pouvoir",
"est", "sont", "était", "étaient", "fut", "furent", "sera", "seront",
"ai", "as", "a", "avons", "avez", "ont", "avait", "avaient",
"très", "bien", "mal", "peu", "beaucoup", "trop", "assez", "aussi",
"plus", "moins", "tant", "si", "tellement",
"oui", "non", "peut-être", "vraiment",
"ici", "là", "là-bas", "partout", "ailleurs",
"aujourd'hui", "hier", "demain", "maintenant", "alors", "ensuite",
"toujours", "jamais", "souvent", "parfois", "déjà", "encore",
"bon", "bonne", "mauvais", "mauvaise", "meilleur", "pire",
"merci", "bonjour", "salut", "au revoir",
"jour", "jours", "semaine", "mois", "année", "temps", "fois",
"chose", "choses", "personne", "gens", "monde", "vie", "maison",
"endroit", "lieu", "partie", "façon", "manière", "sorte", "type",
"cas", "point", "idée", "fait", "raison", "nom", "nombre",
"version", "système", "modèle", "question", "réponse"
]
}
}
+88
View File
@@ -40,5 +40,93 @@
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一个 上 也 很 到 说 要 去 你 会 着 没有 看 好 自己 这 那 她 他 它 们 但是 因为 所以 如果 虽然 然后 或者 而且",
"quote_pattern": "\\u201C([^\\u201D]{10,100})\\u201D|\"([^\"]{10,200})\"",
"action_pattern": "(构建|修复|添加|删除|确认|创建|实现|修理|编写|测试|验证|更新|配置|启动|停止)(?:了|完成|成功)"
},
"entity": {
"boundary_chars": "\\u4E00-\\u9FFF",
"candidate_pattern": "[王李张刘陈杨赵黄周吴徐孙朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾萧田董袁潘于蒋蔡余杜叶程苏魏吕丁任沈姚卢姜崔钟谭陆汪范金石廖贾夏韦方白邹孟熊秦邱江尹薛阎段雷侯龙史陶黎贺顾毛郝龚邵万钱严武戴莫孔向汤温庞殷章葛管甘卞冉蓝殷习][\\u4E00-\\u9FFF]{1,2}",
"person_verb_patterns": [
"{name}说",
"{name}问",
"{name}答",
"{name}表示",
"{name}回答",
"{name}提出",
"{name}决定",
"{name}认为",
"{name}指出",
"{name}解释",
"{name}告诉",
"{name}写道",
"{name}想",
"{name}觉得",
"{name}知道",
"{name}喜欢",
"{name}讨厌",
"{name}确认",
"{name}提醒",
"{name}分享",
"{name}建议",
"{name}同意",
"{name}反对"
],
"pronoun_patterns": [
"他们",
"她们",
"他",
"她",
"它",
"您",
"咱"
],
"dialogue_patterns": [
"^>\\s*{name}[::\\s]",
"^{name}[::]\\s?",
"^\\[{name}\\]",
"\u201C{name}[\u201D::]",
"「{name}[」::]"
],
"direct_address_pattern": "嘿\\s*{name}|喂\\s*{name}|谢谢\\s*{name}|感谢\\s*{name}|哈喽\\s*{name}|亲爱的\\s*{name}",
"project_verb_patterns": [
"建立{name}",
"打造{name}",
"部署{name}",
"启动{name}",
"发布{name}",
"上线{name}",
"开发{name}",
"维护{name}",
"{name}系统",
"{name}平台",
"{name}项目",
"{name}架构",
"{name}管线",
"{name}v\\d+",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"的", "了", "着", "过", "得", "地", "吗", "吧", "呢", "啊", "喔", "耶",
"我", "你", "妳", "他", "她", "它", "您", "咱",
"我们", "你们", "妳们", "他们", "她们", "它们", "咱们",
"自己", "大家", "有人", "没人",
"今天", "明天", "昨天", "前天", "后天", "今年", "明年", "去年",
"早上", "下午", "晚上", "中午", "凌晨",
"现在", "刚才", "刚刚", "等等", "等下", "待会",
"最近", "以前", "之前", "之后", "以后", "后来",
"什么", "为什么", "怎么", "怎样", "哪里", "哪个",
"这个", "那个", "这里", "那里", "这些", "那些", "这样", "那样",
"但是", "可是", "然后", "所以", "因为", "如果", "虽然",
"而且", "或者", "或是", "还是", "不过", "只是", "不只",
"既然", "不然", "否则", "此外", "另外",
"很", "非常", "相当", "真的", "确实", "当然", "其实",
"已经", "正在", "即将", "将要", "刚好", "恰好",
"可能", "也许", "或许", "大概", "应该", "必须", "一定",
"完成", "执行", "进行", "开始", "结束", "继续", "停止", "完毕",
"没有", "有点", "有些", "一些", "许多", "很多",
"问题", "答案", "原因", "结果", "情况", "状况",
"主要", "重要", "基本", "简单", "复杂", "特别",
"谢谢", "感谢", "对不起", "不好意思", "请问",
"欢迎", "再见", "你好", "您好", "哈喽", "拜拜"
]
}
}
+88
View File
@@ -40,5 +40,93 @@
"stop_words": "的 了 在 是 我 有 和 就 不 人 都 一 一個 上 也 很 到 說 要 去 你 會 著 沒有 看 好 自己 這 那 她 他 它 們 但是 因為 所以 如果 雖然 然後 或者 而且",
"quote_pattern": "「([^」]{10,100})」|\u201c([^\u201d]{10,100})\u201d",
"action_pattern": "(構建|修復|添加|刪除|確認|創建|實現|修理|編寫|測試|驗證|更新|配置|啟動|停止)(?:了|完成|成功)"
},
"entity": {
"boundary_chars": "\\u4E00-\\u9FFF",
"candidate_pattern": "[王李張劉陳楊趙黃周吳徐孫朱胡郭何高林羅鄭梁謝宋唐許韓馮鄧曹彭曾蕭田董袁潘于蔣蔡余杜葉程蘇魏呂丁任沈姚盧姜崔鍾譚陸汪范金石廖賈夏韋方白鄒孟熊秦邱江尹薛閻段雷侯龍史陶黎賀顧毛郝龔邵萬錢嚴武戴莫孔向湯溫龐殷章葛管甘卞冉藍殷習][\\u4E00-\\u9FFF]{1,2}",
"person_verb_patterns": [
"{name}說",
"{name}問",
"{name}答",
"{name}表示",
"{name}回答",
"{name}提出",
"{name}決定",
"{name}認為",
"{name}指出",
"{name}解釋",
"{name}告訴",
"{name}寫道",
"{name}想",
"{name}覺得",
"{name}知道",
"{name}喜歡",
"{name}討厭",
"{name}確認",
"{name}提醒",
"{name}分享",
"{name}建議",
"{name}同意",
"{name}反對"
],
"pronoun_patterns": [
"他們",
"她們",
"他",
"她",
"它",
"您",
"咱"
],
"dialogue_patterns": [
"^>\\s*{name}[::\\s]",
"^{name}[::]\\s?",
"^\\[{name}\\]",
"「{name}[」::]",
"『{name}[』::]"
],
"direct_address_pattern": "嘿\\s*{name}|喂\\s*{name}|謝謝\\s*{name}|感謝\\s*{name}|哈囉\\s*{name}|親愛的\\s*{name}",
"project_verb_patterns": [
"建立{name}",
"打造{name}",
"部署{name}",
"啟動{name}",
"發布{name}",
"上線{name}",
"開發{name}",
"維護{name}",
"{name}系統",
"{name}平台",
"{name}專案",
"{name}架構",
"{name}管線",
"{name}v\\d+",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"的", "了", "著", "過", "得", "地", "嗎", "吧", "呢", "啊", "喔", "耶",
"我", "你", "妳", "他", "她", "它", "您", "咱",
"我們", "你們", "妳們", "他們", "她們", "它們", "咱們",
"自己", "大家", "有人", "沒人",
"今天", "明天", "昨天", "前天", "後天", "今年", "明年", "去年",
"早上", "下午", "晚上", "中午", "凌晨",
"現在", "剛才", "剛剛", "等等", "等下", "待會",
"最近", "以前", "之前", "之後", "以後", "後來",
"什麼", "甚麼", "為什麼", "怎麼", "怎樣", "哪裡", "哪個",
"這個", "那個", "這裡", "那裡", "這些", "那些", "這樣", "那樣",
"但是", "可是", "然後", "所以", "因為", "如果", "雖然",
"而且", "或者", "或是", "還是", "不過", "只是", "不只",
"既然", "不然", "否則", "此外", "另外",
"很", "非常", "相當", "真的", "確實", "當然", "其實",
"已經", "正在", "即將", "將要", "剛好", "恰好",
"可能", "也許", "或許", "大概", "應該", "必須", "一定",
"完成", "執行", "進行", "開始", "結束", "繼續", "停止", "完畢",
"沒有", "有點", "有些", "一些", "許多", "很多",
"問題", "答案", "原因", "結果", "情況", "狀況",
"主要", "重要", "基本", "簡單", "複雜", "特別",
"謝謝", "感謝", "對不起", "不好意思", "請問",
"歡迎", "再見", "你好", "您好", "哈囉", "掰掰"
]
}
}
+22 -10
View File
@@ -11,27 +11,39 @@ tell the user they need Python 3.9+ installed and stop.
## Step 2: Check if mempalace is already installed
Run `pip show mempalace` to see if the package is already present. If it is,
report the installed version and skip to Step 4.
Run `mempalace --version`. If it succeeds, the CLI is on PATH — report
the installed version and skip to Step 4.
If `mempalace --version` fails, **do not** skip to Step 4 just because
`pip show mempalace` or `uv tool list` reports the package as installed:
the package may live inside a venv that isn't activated, in which case
Step 5 (`mempalace init ...`) will fail with `command not found`. Treat
that case as not-installed and continue to Step 3, which will (re)install
into a PATH-visible location via `uv tool install` or `pip`.
## Step 3: Install mempalace
Run `pip install mempalace`.
Prefer [`uv`](https://docs.astral.sh/uv/) — it isolates the CLI from system
Python and avoids most environment-related failures:
### Error handling -- pip failures
1. If `uv` is on PATH (`uv --version`), run `uv tool install mempalace`.
2. Otherwise run `pip install mempalace`.
If `pip install mempalace` fails, try these fallbacks in order:
### Error handling -- install failures
1. Try `pip3 install mempalace`
2. Try `python -m pip install mempalace` (or `python3 -m pip install mempalace`)
3. If the error mentions missing build tools or compilation failures (commonly
If the install command fails, try these fallbacks in order:
1. If `uv tool install` failed, try `pip install mempalace` (or vice versa).
2. Try `pip3 install mempalace`.
3. Try `python -m pip install mempalace` (or `python3 -m pip install mempalace`).
4. If the error mentions missing build tools or compilation failures (commonly
from chromadb or its native dependencies):
- On Linux/macOS: suggest `sudo apt-get install build-essential python3-dev`
(Debian/Ubuntu) or `xcode-select --install` (macOS)
- On Windows: suggest installing Microsoft C++ Build Tools from
https://visualstudio.microsoft.com/visual-cpp-build-tools/
- Then retry the install command
4. If all attempts fail, report the error clearly and stop.
5. If all attempts fail, report the error clearly and stop.
## Step 4: Ask for project directory
@@ -49,7 +61,7 @@ If this fails, report the error and stop.
Run the following command to register the MemPalace MCP server with Claude:
claude mcp add mempalace -- python -m mempalace.mcp_server
claude mcp add mempalace -- mempalace-mcp
If this fails, report the error but continue to the next step (MCP
configuration can be done manually later).
+161 -27
View File
@@ -42,11 +42,90 @@ import sqlite3
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Optional
from .config import sanitize_iso_temporal
DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
def _is_date_only_temporal(value: str) -> bool:
return isinstance(value, str) and len(value) == 10 and value[4] == "-" and value[7] == "-"
def _temporal_start_key(value: Optional[str]) -> Optional[str]:
"""Return the comparable instant for a valid_from/as_of value."""
if value is None:
return None
if _is_date_only_temporal(value):
return f"{value}T00:00:00Z"
return value
def _temporal_end_key(value: Optional[str]) -> Optional[str]:
"""Return the comparable instant for a valid_to value.
Date-only valid_to values represent the whole day for backward
compatibility with existing KG facts.
"""
if value is None:
return None
if _is_date_only_temporal(value):
return f"{value}T23:59:59Z"
return value
def _sql_temporal_start_expr(column: str) -> str:
"""SQLite expression for comparing valid_from-style temporal values."""
return (
f"CASE WHEN length({column}) = 10 "
f"AND substr({column}, 5, 1) = '-' "
f"AND substr({column}, 8, 1) = '-' "
f"THEN {column} || 'T00:00:00Z' ELSE {column} END"
)
def _sql_temporal_end_expr(column: str) -> str:
"""SQLite expression for comparing valid_to-style temporal values."""
return (
f"CASE WHEN length({column}) = 10 "
f"AND substr({column}, 5, 1) = '-' "
f"AND substr({column}, 8, 1) = '-' "
f"THEN {column} || 'T23:59:59Z' ELSE {column} END"
)
def _temporal_filter_sql(as_of: str) -> tuple[str, list[str]]:
"""Return SQL and parameters for an as-of temporal filter.
Date-only KG values are normalized for comparison:
- valid_from='2026-05-06' compares as '2026-05-06T00:00:00Z'
- valid_to='2026-05-06' compares as '2026-05-06T23:59:59Z'
This keeps legacy date-only facts working when callers query with
canonical UTC datetimes such as '2026-05-06T15:00:00Z'.
"""
as_of_key = _temporal_start_key(as_of)
valid_from_expr = _sql_temporal_start_expr("t.valid_from")
valid_to_expr = _sql_temporal_end_expr("t.valid_to")
return (
f" AND (t.valid_from IS NULL OR {valid_from_expr} <= ?) "
f"AND (t.valid_to IS NULL OR {valid_to_expr} >= ?)",
[as_of_key, as_of_key],
)
class KnowledgeGraph:
def __init__(self, db_path: str = None):
self.db_path = db_path or DEFAULT_KG_PATH
@@ -128,6 +207,15 @@ class KnowledgeGraph:
self._connection.close()
self._connection = None
def __enter__(self):
"""Allow KnowledgeGraph to be used as a context manager."""
return self
def __exit__(self, exc_type, exc, tb):
"""Close the SQLite connection when leaving a context manager block."""
self.close()
return False
def _entity_id(self, name: str) -> str:
return name.lower().replace(" ", "_").replace("'", "")
@@ -164,13 +252,31 @@ class KnowledgeGraph:
``source_drawer_id`` and ``adapter_name`` are RFC 002 §5.5 provenance
fields populated by adapters that advertise ``supports_kg_triples``;
they default to ``None`` so every existing caller stays source-compatible.
they default to ``None`` so every existing caller stays
source-compatible.
Examples:
add_triple("Max", "child_of", "Alice", valid_from="2015-04-01")
add_triple("Max", "does", "swimming", valid_from="2025-01-01")
add_triple("Alice", "worried_about", "Max injury", valid_from="2026-01", valid_to="2026-02")
add_triple("Alice", "worried_about", "Max injury", valid_from="2026-01-01")
"""
valid_from = sanitize_iso_temporal(valid_from, "valid_from")
valid_to = sanitize_iso_temporal(valid_to, "valid_to")
# Reject inverted intervals. Use temporal comparison keys rather than
# raw string comparison so legacy date-only values and canonical UTC
# datetimes can safely coexist.
if (
valid_from is not None
and valid_to is not None
and _temporal_end_key(valid_to) < _temporal_start_key(valid_from)
):
raise ValueError(
f"valid_to={valid_to!r} is before valid_from={valid_from!r}; "
"an inverted interval would be invisible to every KG query"
)
sub_id = self._entity_id(subject)
obj_id = self._entity_id(obj)
pred = predicate.lower().replace(" ", "_")
@@ -180,10 +286,12 @@ class KnowledgeGraph:
conn = self._conn()
with conn:
conn.execute(
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (sub_id, subject)
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
(sub_id, subject),
)
conn.execute(
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)", (obj_id, obj)
"INSERT OR IGNORE INTO entities (id, name) VALUES (?, ?)",
(obj_id, obj),
)
# Check for existing identical triple
@@ -191,17 +299,14 @@ class KnowledgeGraph:
"SELECT id FROM triples WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, obj_id),
).fetchone()
if existing:
return existing["id"] # Already exists and still valid
triple_id = f"t_{sub_id}_{pred}_{obj_id}_{hashlib.sha256(f'{valid_from}{datetime.now().isoformat()}'.encode()).hexdigest()[:12]}"
conn.execute(
"""INSERT INTO triples (
id, subject, predicate, object,
valid_from, valid_to, confidence,
source_closet, source_file,
id, subject, predicate, object, valid_from, valid_to,
confidence, source_closet, source_file,
source_drawer_id, adapter_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
@@ -218,20 +323,37 @@ class KnowledgeGraph:
adapter_name,
),
)
return triple_id
return triple_id
def invalidate(self, subject: str, predicate: str, obj: str, ended: str = None):
"""Mark a relationship as no longer valid (set valid_to date)."""
"""Mark a relationship as no longer valid (set valid_to date/time)."""
sub_id = self._entity_id(subject)
obj_id = self._entity_id(obj)
pred = predicate.lower().replace(" ", "_")
ended = ended or date.today().isoformat()
ended = sanitize_iso_temporal(ended or date.today().isoformat(), "ended")
with self._lock:
conn = self._conn()
with conn:
rows = conn.execute(
"SELECT id, valid_from FROM triples "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(sub_id, pred, obj_id),
).fetchall()
for row in rows:
valid_from = row["valid_from"]
if valid_from is not None and _temporal_end_key(ended) < _temporal_start_key(
valid_from
):
raise ValueError(
f"valid_to={ended!r} is before valid_from={valid_from!r}; "
"an inverted interval would be invisible to every KG query"
)
conn.execute(
"UPDATE triples SET valid_to=? WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
"UPDATE triples SET valid_to=? "
"WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL",
(ended, sub_id, pred, obj_id),
)
@@ -242,20 +364,27 @@ class KnowledgeGraph:
Get all relationships for an entity.
direction: "outgoing" (entity → ?), "incoming" (? → entity), "both"
as_of: date string — only return facts valid at that time
as_of: ISO date or canonical UTC datetime — only return facts valid then
"""
as_of = sanitize_iso_temporal(as_of, "as_of")
eid = self._entity_id(name)
results = []
temporal_sql = ""
temporal_params = []
if as_of:
temporal_sql, temporal_params = _temporal_filter_sql(as_of)
with self._lock:
conn = self._conn()
if direction in ("outgoing", "both"):
query = "SELECT t.*, e.name as obj_name FROM triples t JOIN entities e ON t.object = e.id WHERE t.subject = ?"
params = [eid]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
query = (
"SELECT t.*, e.name as obj_name FROM triples t "
"JOIN entities e ON t.object = e.id WHERE t.subject = ?" + temporal_sql
)
params = [eid] + temporal_params
for row in conn.execute(query, params).fetchall():
results.append(
{
@@ -272,11 +401,12 @@ class KnowledgeGraph:
)
if direction in ("incoming", "both"):
query = "SELECT t.*, e.name as sub_name FROM triples t JOIN entities e ON t.subject = e.id WHERE t.object = ?"
params = [eid]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
query = (
"SELECT t.*, e.name as sub_name FROM triples t "
"JOIN entities e ON t.subject = e.id WHERE t.object = ?" + temporal_sql
)
params = [eid] + temporal_params
for row in conn.execute(query, params).fetchall():
results.append(
{
@@ -296,7 +426,9 @@ class KnowledgeGraph:
def query_relationship(self, predicate: str, as_of: str = None):
"""Get all triples with a given relationship type."""
as_of = sanitize_iso_temporal(as_of, "as_of")
pred = predicate.lower().replace(" ", "_")
query = """
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
@@ -305,9 +437,11 @@ class KnowledgeGraph:
WHERE t.predicate = ?
"""
params = [pred]
if as_of:
query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)"
params.extend([as_of, as_of])
temporal_sql, temporal_params = _temporal_filter_sql(as_of)
query += temporal_sql
params.extend(temporal_params)
results = []
with self._lock:
+6 -2
View File
@@ -124,6 +124,8 @@ class Layer1:
# Score each drawer: prefer high importance, recent filing
scored = []
for doc, meta in zip(docs, metas):
meta = meta or {}
doc = doc or ""
importance = 3
# Try multiple metadata keys that might carry weight info
for key in ("importance", "emotional_weight", "weight"):
@@ -155,7 +157,7 @@ class Layer1:
lines.append(room_line)
total_len += len(room_line)
for imp, meta, doc in entries:
for _imp, meta, doc in entries:
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
# Truncate doc to keep L1 compact
@@ -222,6 +224,8 @@ class Layer2:
lines = [f"## L2 — ON-DEMAND ({len(docs)} drawers)"]
for doc, meta in zip(docs[:n_results], metas[:n_results]):
meta = meta or {}
doc = doc or ""
room_name = meta.get("room", "?")
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
snippet = doc.strip().replace("\n", " ")
@@ -283,7 +287,7 @@ class Layer3:
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
meta = meta or {}
doc = doc or ""
similarity = round(1 - dist, 3)
similarity = round(max(0.0, 1 - dist), 3)
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
+417
View File
@@ -0,0 +1,417 @@
"""
llm_client.py — Minimal provider abstraction for LLM-assisted entity refinement.
Three providers cover the useful space:
- ``ollama`` (default): local models via http://localhost:11434. Works fully
offline. Honors MemPalace's "zero-API required" principle.
- ``openai-compat``: any OpenAI-compatible ``/v1/chat/completions`` endpoint.
Covers OpenRouter, LM Studio, llama.cpp server, vLLM, Groq, Fireworks,
Together, and most self-hosted setups.
- ``anthropic``: the official Messages API. Opt-in for users who want Haiku
quality without setting up a local model.
All providers expose the same ``classify(system, user, json_mode)`` method and
the same ``check_available()`` probe. No external SDK dependencies — stdlib
``urllib`` only.
JSON mode matters here: we always ask for structured output. Providers
differ on how to request it (Ollama: ``format: json``; OpenAI-compat:
``response_format``; Anthropic: prompt-level instruction) and this module
normalizes that away from the caller.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Optional
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
# ── External-service heuristic (issue #24 — privacy warning support) ─────
# Used by ``LLMProvider.is_external_service`` to decide whether the
# provider's configured endpoint will send user content off the local
# machine/network. Single source of truth so all three providers share
# identical "local vs external" semantics.
_LOCALHOST_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
def _endpoint_is_local(url: Optional[str]) -> bool:
"""Return True if ``url``'s hostname is on the user's machine or
private network.
Local includes:
- localhost, 127.0.0.1, ::1
- hostnames ending in .local (mDNS/Bonjour)
- IPv4 RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
- IPv4 CGNAT (Tailscale and similar VPN/tunnel networks):
100.64.0.0/10 — first octet 100, second octet 64-127 inclusive
- IPv6 unique-local addresses (fc00::/7) — fc.../fd... prefixes
None / empty / unparseable URLs are treated as local (defensive default —
no endpoint means no external request can happen yet).
Anything else (including public IPs and FQDNs) is external.
"""
if not url:
return True
try:
host = (urlparse(url).hostname or "").lower()
except (ValueError, AttributeError):
return False
if not host:
return True
if host in _LOCALHOST_HOSTS:
return True
if host.endswith(".local"):
return True
if host.startswith("10."):
return True
if host.startswith("192.168."):
return True
if host.startswith("172."):
# 172.16.0.0 - 172.31.255.255
parts = host.split(".")
if len(parts) >= 2:
try:
if 16 <= int(parts[1]) <= 31:
return True
except ValueError:
pass
if host.startswith("100."):
# 100.64.0.0/10 — Tailscale CGNAT range. First octet 100, second
# octet 64-127 inclusive. Users running a local LLM (LM Studio,
# Ollama, etc.) accessible via Tailscale on a 100.x.x.x address
# should not trigger the external-API privacy warning.
# 100.x.x.x outside this range is regular allocated public space
# and remains external.
parts = host.split(".")
if len(parts) >= 2:
try:
if 64 <= int(parts[1]) <= 127:
return True
except ValueError:
pass
# IPv6 unique-local addresses fc00::/7 — match leading hex chars
if host.startswith("fc") or host.startswith("fd"):
return True
return False
class LLMError(RuntimeError):
"""Raised for any provider failure — transport, parse, auth, missing model."""
@dataclass
class LLMResponse:
text: str
model: str
provider: str
raw: dict
# ==================== BASE ====================
class LLMProvider:
name: str = "base"
def __init__(
self,
model: str,
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 120,
api_key_source: Optional[str] = None,
):
self.model = model
self.endpoint = endpoint
self.api_key = api_key
self.timeout = timeout
# Provenance of api_key (issue #26): "flag" when the constructor
# received an explicit api_key arg, "env" when it fell back to an
# environment variable, None when no key is in play. cmd_init
# uses this to gate the consent prompt — stray env-resolved keys
# require explicit user confirmation.
self.api_key_source = api_key_source
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
raise NotImplementedError
def check_available(self) -> tuple[bool, str]:
"""Return ``(ok, message)``. Fast probe that the provider is reachable."""
raise NotImplementedError
@property
def is_external_service(self) -> bool:
"""Return True if this provider's endpoint will send user content
off the local machine/network.
Used by ``mempalace init`` to decide whether to print a privacy
warning before first use (issue #24). URL-based heuristic only —
the endpoint determines, regardless of which provider class.
Subclasses that resolve their endpoint dynamically should override
if needed; the default works for the three in-tree providers
(Ollama / OpenAI-compat / Anthropic).
"""
return not _endpoint_is_local(self.endpoint)
def _http_post_json(url: str, body: dict, headers: dict, timeout: int) -> dict:
"""POST JSON and return the parsed response. Raises LLMError on any failure."""
req = Request(
url,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", **headers},
)
try:
with urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except HTTPError as e:
detail = ""
try:
detail = e.read().decode("utf-8", errors="replace")[:500]
except Exception:
pass
raise LLMError(f"HTTP {e.code} from {url}: {detail or e.reason}") from e
except (URLError, OSError) as e:
raise LLMError(f"Cannot reach {url}: {e}") from e
except json.JSONDecodeError as e:
raise LLMError(f"Malformed response from {url}: {e}") from e
# ==================== OLLAMA ====================
class OllamaProvider(LLMProvider):
name = "ollama"
DEFAULT_ENDPOINT = "http://localhost:11434"
def __init__(
self,
model: str,
endpoint: Optional[str] = None,
timeout: int = 180,
**_: object,
):
super().__init__(
model=model,
endpoint=endpoint or self.DEFAULT_ENDPOINT,
timeout=timeout,
)
def check_available(self) -> tuple[bool, str]:
try:
with urlopen(f"{self.endpoint}/api/tags", timeout=5) as resp:
data = json.loads(resp.read())
except (URLError, HTTPError, OSError, json.JSONDecodeError) as e:
return False, f"Cannot reach Ollama at {self.endpoint}: {e}"
names = {m.get("name", "") for m in data.get("models", []) or []}
# Ollama tags may or may not include ':latest' — accept either form
wanted = {self.model, f"{self.model}:latest"}
if not names & wanted:
return (
False,
f"Model '{self.model}' not loaded in Ollama. Run: ollama pull {self.model}",
)
return True, "ok"
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
body: dict = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"options": {"temperature": 0.1},
}
if json_mode:
body["format"] = "json"
data = _http_post_json(f"{self.endpoint}/api/chat", body, headers={}, timeout=self.timeout)
text = (data.get("message") or {}).get("content", "")
if not text:
raise LLMError(f"Empty response from Ollama (model={self.model})")
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
# ==================== OPENAI-COMPAT ====================
class OpenAICompatProvider(LLMProvider):
"""Any OpenAI-compatible ``/v1/chat/completions`` endpoint.
Supply ``--llm-endpoint http://host:port`` (with or without ``/v1``).
API key via ``--llm-api-key`` or the ``OPENAI_API_KEY`` env var.
"""
name = "openai-compat"
def __init__(
self,
model: str,
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 120,
**_: object,
):
if api_key:
resolved_key = api_key
source: Optional[str] = "flag"
else:
env_key = os.environ.get("OPENAI_API_KEY")
resolved_key = env_key or None
source = "env" if env_key else None
super().__init__(
model=model,
endpoint=endpoint,
api_key=resolved_key,
timeout=timeout,
api_key_source=source,
)
def _resolve_url(self) -> str:
if not self.endpoint:
raise LLMError("openai-compat provider requires --llm-endpoint")
url = self.endpoint.rstrip("/")
if url.endswith("/chat/completions"):
return url
if not url.endswith("/v1"):
url = f"{url}/v1"
return f"{url}/chat/completions"
def check_available(self) -> tuple[bool, str]:
if not self.endpoint:
return False, "no --llm-endpoint configured"
base = self.endpoint.rstrip("/")
base = base.removesuffix("/chat/completions").removesuffix("/v1")
try:
req = Request(f"{base}/v1/models")
if self.api_key:
req.add_header("Authorization", f"Bearer {self.api_key}")
with urlopen(req, timeout=5):
pass
except (URLError, HTTPError, OSError) as e:
return False, f"Cannot reach {self.endpoint}: {e}"
return True, "ok"
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
body: dict = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.1,
}
if json_mode:
body["response_format"] = {"type": "json_object"}
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
data = _http_post_json(self._resolve_url(), body, headers=headers, timeout=self.timeout)
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as e:
raise LLMError(f"Unexpected response shape: {e}") from e
if not text:
raise LLMError(f"Empty response from {self.name} (model={self.model})")
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
# ==================== ANTHROPIC ====================
class AnthropicProvider(LLMProvider):
name = "anthropic"
DEFAULT_ENDPOINT = "https://api.anthropic.com"
API_VERSION = "2023-06-01"
def __init__(
self,
model: str,
api_key: Optional[str] = None,
endpoint: Optional[str] = None,
timeout: int = 120,
**_: object,
):
if api_key:
resolved_key = api_key
source: Optional[str] = "flag"
else:
env_key = os.environ.get("ANTHROPIC_API_KEY")
resolved_key = env_key or None
source = "env" if env_key else None
super().__init__(
model=model,
endpoint=endpoint or self.DEFAULT_ENDPOINT,
api_key=resolved_key,
timeout=timeout,
api_key_source=source,
)
def check_available(self) -> tuple[bool, str]:
if not self.api_key:
return False, "ANTHROPIC_API_KEY not set (use --llm-api-key or env)"
# Don't probe — a live request would cost money. First real call will
# surface auth errors if the key is invalid.
return True, "ok"
def classify(self, system: str, user: str, json_mode: bool = True) -> LLMResponse:
if not self.api_key:
raise LLMError("Anthropic provider requires ANTHROPIC_API_KEY env or --llm-api-key")
sys_prompt = system
if json_mode:
sys_prompt += "\n\nRespond with valid JSON only, no prose."
body = {
"model": self.model,
"max_tokens": 2048,
"temperature": 0.1,
"system": sys_prompt,
"messages": [{"role": "user", "content": user}],
}
headers = {
"X-API-Key": self.api_key,
"anthropic-version": self.API_VERSION,
}
data = _http_post_json(
f"{self.endpoint}/v1/messages", body, headers=headers, timeout=self.timeout
)
try:
text = "".join(
b.get("text", "") for b in data.get("content", []) or [] if b.get("type") == "text"
)
except (AttributeError, TypeError) as e:
raise LLMError(f"Unexpected response shape: {e}") from e
if not text:
raise LLMError(f"Empty response from Anthropic (model={self.model})")
return LLMResponse(text=text, model=self.model, provider=self.name, raw=data)
# ==================== FACTORY ====================
PROVIDERS: dict[str, type[LLMProvider]] = {
"ollama": OllamaProvider,
"openai-compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
}
def get_provider(
name: str,
model: str,
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 120,
) -> LLMProvider:
"""Build a provider by name. Raises LLMError on unknown provider."""
cls = PROVIDERS.get(name)
if cls is None:
raise LLMError(f"Unknown provider '{name}'. Choices: {sorted(PROVIDERS.keys())}")
return cls(model=model, endpoint=endpoint, api_key=api_key, timeout=timeout)
+500
View File
@@ -0,0 +1,500 @@
"""
llm_refine.py — Optional LLM refinement of regex-detected entities.
Takes the candidate set produced by phase-1 detection (manifests, git
authors, regex on prose) and asks an LLM to reclassify each candidate as
PERSON / PROJECT / TOPIC / COMMON_WORD / AMBIGUOUS.
Design constraints:
- Opt-in. Default init path never imports this module.
- Local-first by default (Ollama).
- Interactive UX: visible progress, clean cancellation (Ctrl-C returns
whatever was classified before the interrupt).
- Don't feed the raw corpus to the LLM — feed candidates + a few sampled
context lines each. Keeps total input to ~50-100K tokens even for huge
prose corpora.
Public:
refine_entities(detected, corpus_text, provider, ...) -> dict
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass
from mempalace.llm_client import LLMError, LLMProvider
BATCH_SIZE = 25 # candidates per LLM call; tuned for 4B local models
CONTEXT_LINES_PER_CANDIDATE = 3
CONTEXT_WINDOW_CHARS = 240 # max chars per context line to keep tokens bounded
# Valid labels the LLM is allowed to return. Anything else is treated as
# AMBIGUOUS so the user reviews it.
VALID_LABELS = {"PERSON", "PROJECT", "TOPIC", "COMMON_WORD", "AMBIGUOUS"}
SYSTEM_PROMPT = """You are helping organize a user's memory palace by classifying capitalized tokens found in their files.
For each candidate, pick exactly ONE label:
- PERSON: a specific real person the user knows (colleague, family, character they write about)
- PROJECT: a named product, codebase, or effort the user works on
- TOPIC: a recurring theme or subject (not a person, not a project) — cities, technologies, concepts
- COMMON_WORD: an English word, verb, or fragment that isn't a named entity at all (e.g. "Created", "Before", "Never")
- AMBIGUOUS: context is insufficient to decide between two of the above
Frameworks, runtimes, APIs, cloud services, vendors, and third-party products
(e.g. Angular, OpenAPI, Terraform, Bun, Google) are TOPIC unless the context
clearly says this is the user's own named codebase, product, or active effort.
Use the provided context lines to disambiguate. A capitalized word that only appears in metadata ("Created: 2026-04-24") is COMMON_WORD. A name that appears with pronouns and dialogue is PERSON.
Respond with JSON only. Schema:
{"classifications": [{"name": "<exact candidate name>", "label": "<LABEL>", "reason": "<one short sentence>"}]}
One entry per candidate, same order as the input."""
@dataclass
class RefineResult:
merged: dict # updated detected dict
reclassified: int # entries whose type changed
dropped: int # entries removed from the merged result (COMMON_WORD only)
errors: list[str] # per-batch error messages (transport/parse failures)
batches_completed: int
batches_total: int
cancelled: bool
def _collect_contexts(
corpus_lines: list[str], name: str, max_lines: int = CONTEXT_LINES_PER_CANDIDATE
) -> list[str]:
"""Return up to `max_lines` distinct lines from the corpus that mention `name`.
Case-insensitive token-boundary match. Lines are truncated to
CONTEXT_WINDOW_CHARS chars to keep token usage bounded.
"""
needle = re.compile(rf"(?<!\w){re.escape(name)}(?!\w)", re.IGNORECASE)
seen: set[str] = set()
out: list[str] = []
for line in corpus_lines:
if not needle.search(line):
continue
trimmed = line.strip()[:CONTEXT_WINDOW_CHARS]
if not trimmed or trimmed in seen:
continue
seen.add(trimmed)
out.append(trimmed)
if len(out) >= max_lines:
break
return out
def _build_user_prompt(candidates_with_contexts: list[tuple[str, str, list[str]]]) -> str:
"""Shape: for each candidate, list its current type guess + sampled contexts."""
parts: list[str] = ["CANDIDATES:"]
for i, (name, current_type, contexts) in enumerate(candidates_with_contexts, 1):
parts.append(f"\n{i}. {name} (currently: {current_type})")
if contexts:
for c in contexts:
parts.append(f" > {c}")
else:
parts.append(" > (no context available)")
return "\n".join(parts)
def _extract_json_candidates(text: str) -> list[str]:
"""Return plausible JSON payloads extracted from an LLM response."""
text = text.strip()
if not text:
return []
candidates: list[str] = [text]
for match in re.finditer(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE):
candidate = match.group(1).strip()
if candidate and candidate not in candidates:
candidates.append(candidate)
for start, opener in ((i, ch) for i, ch in enumerate(text) if ch in "{["):
closer = "}" if opener == "{" else "]"
depth = 0
in_string = False
escaped = False
for i in range(start, len(text)):
ch = text[i]
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == opener:
depth += 1
elif ch == closer:
depth -= 1
if depth == 0:
candidate = text[start : i + 1].strip()
if candidate and candidate not in candidates:
candidates.append(candidate)
break
return candidates
def _parse_response(text: str, expected_names: list[str]) -> dict[str, tuple[str, str]]:
"""Parse the LLM's JSON response into {name: (label, reason)}.
Robust to the model occasionally wrapping JSON in text or returning
slight schema variations. Falls back to matching by candidate name.
"""
data = None
for candidate in _extract_json_candidates(text):
try:
data = json.loads(candidate)
break
except json.JSONDecodeError:
continue
if data is None:
return {}
entries = data.get("classifications") if isinstance(data, dict) else data
if not isinstance(entries, list):
return {}
name_to_label: dict[str, tuple[str, str]] = {}
expected_set = {n.lower(): n for n in expected_names}
for entry in entries:
if not isinstance(entry, dict):
continue
name = entry.get("name") or entry.get("candidate")
label = entry.get("label") or entry.get("type") or entry.get("classification")
reason = entry.get("reason") or ""
if not isinstance(name, str) or not isinstance(label, str):
continue
# Restore canonical casing from expected_names
canonical = expected_set.get(name.lower(), name)
lbl = label.strip().upper()
if lbl not in VALID_LABELS:
lbl = "AMBIGUOUS"
name_to_label[canonical] = (lbl, reason.strip()[:120])
return name_to_label
def _apply_classifications(
detected: dict,
decisions: dict[str, tuple[str, str]],
allow_project_promotions: bool = True,
) -> tuple[dict, int, int]:
"""Merge LLM decisions back into the detected dict.
Returns (new_detected, reclassified_count, dropped_count).
Topics get their own bucket so the caller can persist them as
cross-wing tunnel signal. ``AMBIGUOUS`` still falls back to
``uncertain`` for human review.
"""
label_to_bucket = {
"PERSON": "people",
"PROJECT": "projects",
"TOPIC": "topics",
"AMBIGUOUS": "uncertain",
}
bucket_to_type = {
"people": "person",
"projects": "project",
"topics": "topic",
"uncertain": "uncertain",
}
# Index every entity by name for in-place update
all_entries: list[tuple[str, dict]] = []
for bucket, items in detected.items():
for e in items:
all_entries.append((bucket, e))
reclassified = 0
dropped = 0
new_detected: dict[str, list[dict]] = {
"people": [],
"projects": [],
"topics": [],
"uncertain": [],
}
for old_bucket, entry in all_entries:
decision = decisions.get(entry["name"])
if decision is None:
# No LLM opinion — keep as-is
new_detected.setdefault(old_bucket, []).append(entry)
continue
label, reason = decision
if label == "COMMON_WORD":
dropped += 1
continue
target_bucket = label_to_bucket[label]
if (
label == "PROJECT"
and not allow_project_promotions
and not _is_authoritative_project(entry)
):
target_bucket = "uncertain"
updated = dict(entry)
# Append the LLM's reason as a new signal so the user sees why it moved
signals = list(updated.get("signals", []))
signals.append(f"LLM: {label.lower()} — {reason}" if reason else f"LLM: {label.lower()}")
updated["signals"] = signals
if target_bucket != old_bucket:
reclassified += 1
updated["type"] = bucket_to_type.get(target_bucket, "uncertain")
new_detected[target_bucket].append(updated)
return new_detected, reclassified, dropped
def _build_corpus_origin_preamble(corpus_origin: dict | None) -> str:
"""Build a system-prompt preamble carrying corpus-origin context.
When the corpus has been identified as AI-dialogue with known persona
names, this preamble lets the LLM disambiguate ambiguous candidates
with knowledge that this is AI-dialogue. It does NOT add a new label
or change the classification schema — the post-refine sweep in
project_scanner.discover_entities still moves persona names into
``agent_personas``. The preamble is purely classification context for
the OTHER candidates (ambiguous, common-word) that benefit from
knowing the corpus shape.
Returns ``""`` when no usable origin context is available, so callers
can concatenate unconditionally without changing the v3.3.3 prompt
shape for opt-out paths.
"""
if not corpus_origin:
return ""
result = corpus_origin.get("result") or {}
if not result.get("likely_ai_dialogue"):
return ""
lines = ["\n\nCORPUS CONTEXT (corpus-origin detection):"]
platform = result.get("primary_platform")
if platform:
lines.append(f"- This corpus is AI-dialogue from {platform}.")
user_name = result.get("user_name")
if user_name:
lines.append(
f"- The corpus author (the human user) is named '{user_name}'. "
f"Treat this name as PERSON."
)
personas = result.get("agent_persona_names") or []
if personas:
lines.append(
"- The user has assigned these persona names to AI agents in "
f"this corpus: {', '.join(personas)}."
)
lines.append(
"- Persona names refer to AI agents, not biological people. "
"Classify them as PERSON (a downstream step tags them as "
"agent personas)."
)
return "\n".join(lines)
def _is_authoritative_person(entry: dict) -> bool:
"""Return True for git-author people that should not be second-guessed."""
signals = " ".join(entry.get("signals", [])).lower()
return "commit" in signals and "repo" in signals
def _is_authoritative_project(entry: dict) -> bool:
"""Return True for manifest/git-backed projects that are already source-backed."""
signals = " ".join(entry.get("signals", [])).lower()
manifest_markers = ("package.json", "pyproject.toml", "cargo.toml", "go.mod")
return any(marker in signals for marker in manifest_markers) or "commit" in signals
def _print_progress(batch_idx: int, total: int, current_name: str) -> None:
"""Overwrite-line progress indicator."""
width = 40
filled = int(width * batch_idx / total) if total else 0
bar = "█" * filled + "░" * (width - filled)
msg = f"\r LLM refine: [{bar}] batch {batch_idx}/{total} current: {current_name[:30]:<30}"
sys.stderr.write(msg)
sys.stderr.flush()
def refine_entities(
detected: dict,
corpus_text: str,
provider: LLMProvider,
batch_size: int = BATCH_SIZE,
show_progress: bool = True,
allow_project_promotions: bool = True,
corpus_origin: dict | None = None,
) -> RefineResult:
"""Reclassify detected entities using the LLM provider.
Only regex-derived candidates are sent for refinement. Git authors and
manifest/git-backed projects are already source-backed and don't benefit
from LLM second-guessing.
Ctrl-C during refinement: cancels the remaining batches, returns a
RefineResult with ``cancelled=True`` and whatever was classified before
the interrupt. The partial result is safe to pass straight to
``confirm_entities``.
Transport or parse failures in individual batches are recorded in
``errors`` and do not abort the run.
``allow_project_promotions=False`` keeps LLM-only project guesses in the
uncertain bucket. This is useful when manifest/git signal already supplied
canonical projects and regex/LLM hits are likely tools, vendors, or topics.
"""
candidates: list[tuple[str, str]] = []
current_type = {"people": "person", "projects": "project", "uncertain": "uncertain"}
for bucket in ("people", "projects", "uncertain"):
for e in detected.get(bucket, []):
if bucket == "people" and _is_authoritative_person(e):
continue
if bucket == "projects" and _is_authoritative_project(e):
continue
candidates.append((e["name"], current_type[bucket]))
corpus_lines = corpus_text.splitlines() if corpus_text else []
# Deduplicate candidate names while preserving order
seen: set[str] = set()
unique: list[tuple[str, str]] = []
for name, kind in candidates:
if name not in seen:
seen.add(name)
unique.append((name, kind))
if not unique:
return RefineResult(
merged=detected,
reclassified=0,
dropped=0,
errors=[],
batches_completed=0,
batches_total=0,
cancelled=False,
)
# Build batches
batches: list[list[tuple[str, str, list[str]]]] = []
for i in range(0, len(unique), batch_size):
chunk = unique[i : i + batch_size]
enriched = [(name, kind, _collect_contexts(corpus_lines, name)) for name, kind in chunk]
batches.append(enriched)
all_decisions: dict[str, tuple[str, str]] = {}
errors: list[str] = []
completed = 0
cancelled = False
system_prompt = SYSTEM_PROMPT + _build_corpus_origin_preamble(corpus_origin)
for idx, batch in enumerate(batches, 1):
if show_progress and batch:
_print_progress(idx - 1, len(batches), batch[0][0])
user_prompt = _build_user_prompt(batch)
try:
resp = provider.classify(system_prompt, user_prompt, json_mode=True)
except KeyboardInterrupt:
cancelled = True
break
except LLMError as e:
errors.append(f"batch {idx}: {e}")
continue
names_in_batch = [name for name, _, _ in batch]
decisions = _parse_response(resp.text, names_in_batch)
if not decisions:
errors.append(f"batch {idx}: could not parse response")
all_decisions.update(decisions)
completed += 1
if show_progress:
_print_progress(idx, len(batches), batch[-1][0])
if show_progress:
sys.stderr.write("\n")
sys.stderr.flush()
merged, reclassified, dropped = _apply_classifications(
detected,
all_decisions,
allow_project_promotions=allow_project_promotions,
)
return RefineResult(
merged=merged,
reclassified=reclassified,
dropped=dropped,
errors=errors,
batches_completed=completed,
batches_total=len(batches),
cancelled=cancelled,
)
def collect_corpus_text(
project_dir: str,
max_files: int = 30,
max_bytes_per_file: int = 20_000,
) -> str:
"""Gather prose text from ``project_dir`` for use as LLM context source.
Stratified: reads up to ``max_files`` prose files (``.md``, ``.txt``,
``.rst``), preferring recently-modified. Each file capped at
``max_bytes_per_file`` to bound total input.
"""
from pathlib import Path
from mempalace.entity_detector import PROSE_EXTENSIONS, SKIP_DIRS
root = Path(project_dir).expanduser().resolve()
if not root.is_dir():
return ""
candidates: list[tuple[float, Path]] = []
for dirpath, dirs, files in _walk_prose(root, SKIP_DIRS):
for fname in files:
p = dirpath / fname
if p.suffix.lower() not in PROSE_EXTENSIONS:
continue
try:
mtime = p.stat().st_mtime
except OSError:
continue
candidates.append((mtime, p))
candidates.sort(reverse=True)
selected = [p for _, p in candidates[:max_files]]
chunks: list[str] = []
for p in selected:
try:
with open(p, encoding="utf-8", errors="replace") as f:
chunks.append(f.read(max_bytes_per_file))
except OSError:
continue
return "\n".join(chunks)
def _walk_prose(root, skip_dirs):
"""Walk a directory yielding (Path, dirs, files), pruning skip_dirs.
Inlined from ``project_scanner._walk`` to avoid a private-name import
coupling. Functionality is intentionally narrow: prose collection only.
"""
import os
from pathlib import Path
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
yield Path(dirpath), dirs, files
+693 -73
View File
File diff suppressed because it is too large Load Diff
+108 -9
View File
@@ -18,13 +18,35 @@ Usage:
mempalace migrate --dry-run # show what would be migrated
"""
import errno
import os
import shutil
import sqlite3
import uuid
from collections import defaultdict
from datetime import datetime
def _restore_stale_palace(palace_path: str, stale_path: str) -> None:
"""Roll back a failed swap.
shutil.move() can partially create palace_path before raising, which
would make a bare os.replace(stale_path, palace_path) fail (dest exists).
Clear any partial destination first, then restore. Best-effort: if the
restore itself fails, log both paths so the operator can recover by hand.
"""
try:
if os.path.lexists(palace_path):
shutil.rmtree(palace_path, ignore_errors=True)
os.replace(stale_path, palace_path)
except Exception as err:
print(
f" CRITICAL: rollback failed — original palace at {stale_path}, "
f"partial migration data at {palace_path}. Restore manually. "
f"({err})"
)
def extract_drawers_from_sqlite(db_path: str) -> list:
"""Read all drawers directly from ChromaDB's SQLite, bypassing the API.
@@ -134,6 +156,55 @@ def confirm_destructive_action(
return True
def _result_ids(result) -> list:
"""Return ids from either the backend typed result or raw Chroma dict."""
if isinstance(result, dict):
return list(result.get("ids") or [])
return list(getattr(result, "ids", []) or [])
def collection_write_roundtrip_works(col) -> bool:
"""Return True only if the collection can upsert, read, and delete.
Some ChromaDB 0.6.x -> 1.5.x migrated collections remain readable while
writes and deletes silently no-op. A plain ``count()`` probe misses that
failure mode, so migrate must verify an actual write round-trip before
deciding that no rebuild is needed.
"""
probe_id = f"_mempalace_migrate_probe_{uuid.uuid4().hex}"
probe_doc = "mempalace migrate write round-trip probe"
probe_meta = {
"wing": "_mempalace_probe",
"room": "_mempalace_probe",
"source_file": "mempalace_migrate_probe",
"chunk_index": 0,
}
try:
col.upsert(
ids=[probe_id],
documents=[probe_doc],
metadatas=[probe_meta],
)
after_upsert = col.get(ids=[probe_id], include=[])
if probe_id not in _result_ids(after_upsert):
return False
col.delete(ids=[probe_id])
after_delete = col.get(ids=[probe_id], include=[])
if probe_id in _result_ids(after_delete):
return False
return True
except Exception:
return False
def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
"""Migrate a palace to the currently installed ChromaDB version."""
from .backends.chroma import ChromaBackend
@@ -158,16 +229,27 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
print(f" Source: ChromaDB {source_version}")
print(f" Target: ChromaDB {target_version}")
# Try reading with current chromadb first
# Try reading and writing with current chromadb first.
#
# A plain count() is not enough: some 0.6.x -> 1.5.x migrated collections
# are readable but silently drop upsert/delete operations. In that state,
# migrate must rebuild from SQLite instead of returning "No migration needed."
try:
col = ChromaBackend().get_collection(palace_path, "mempalace_drawers")
count = col.count()
print(f"\n Palace is already readable by chromadb {target_version}.")
print(f" {count} drawers found. No migration needed.")
return True
if collection_write_roundtrip_works(col):
print(f"\n Palace is already readable and writable by chromadb {target_version}.")
print(f" {count} drawers found. No migration needed.")
return True
print(
f"\n Palace is readable by chromadb {target_version}, but write/delete verification failed."
)
print(" Rebuilding from SQLite to restore native write/delete behavior...")
except Exception:
print(f"\n Palace is NOT readable by chromadb {target_version}.")
print(" Extracting from SQLite directly...")
print(f"\n Palace is NOT readable by chromadb {target_version}.")
print(" Extracting from SQLite directly...")
# Extract all drawers via raw SQL
drawers = extract_drawers_from_sqlite(db_path)
@@ -231,10 +313,27 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
del col
del fresh_backend
# Swap: remove old palace, move new one into place
# Swap: rename old palace aside, then move new one into place.
# This avoids a window where both old and new are missing.
print(" Swapping old palace for migrated version...")
shutil.rmtree(palace_path)
shutil.move(temp_palace, palace_path)
stale_path = palace_path + ".old"
if os.path.exists(stale_path):
shutil.rmtree(stale_path)
os.replace(palace_path, stale_path)
try:
os.replace(temp_palace, palace_path)
except OSError as e:
# EXDEV = temp lives on a different filesystem; fall back to copy+delete.
# Anything else is a real error — don't mask it with shutil.move.
if getattr(e, "errno", None) != errno.EXDEV:
_restore_stale_palace(palace_path, stale_path)
raise
try:
shutil.move(temp_palace, palace_path)
except Exception:
_restore_stale_palace(palace_path, stale_path)
raise
shutil.rmtree(stale_path, ignore_errors=True)
print("\n Migration complete.")
print(f" Drawers migrated: {final_count}")
+537 -88
View File
@@ -8,12 +8,16 @@ Stores verbatim chunks as drawers. No summaries. Ever.
"""
import os
import re
import sys
import shlex
import hashlib
import fnmatch
import logging
from pathlib import Path
from datetime import datetime
from collections import defaultdict
from typing import Optional
from .palace import (
NORMALIZE_VERSION,
@@ -23,10 +27,13 @@ from .palace import (
get_closets_collection,
get_collection,
mine_lock,
mine_palace_lock,
purge_file_closets,
upsert_closet_lines,
)
logger = logging.getLogger("mempalace_mcp")
READABLE_EXTENSIONS = {
".txt",
".md",
@@ -52,18 +59,29 @@ READABLE_EXTENSIONS = {
}
SKIP_FILENAMES = {
"entities.json",
"mempalace.yaml",
"mempalace.yml",
"mempal.yaml",
"mempal.yml",
".gitignore",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
}
CHUNK_SIZE = 800 # chars per drawer
CHUNK_OVERLAP = 100 # overlap between chunks
MIN_CHUNK_SIZE = 50 # skip tiny chunks
DRAWER_UPSERT_BATCH_SIZE = 1000
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
# A single file producing more chunks than this is almost always a generated
# artifact (CSV/JSON dump, lockfile not in SKIP_FILENAMES, etc.). Embedding
# thousands of chunks from one file in one batch has triggered ONNX runtime
# `bad allocation` errors on Windows (#1296). The cap is conservative: a
# 500-chunk file at CHUNK_SIZE=800 is ~400 KB of source, which covers most
# legitimate hand-written content while bounding the worst-case batch.
MAX_CHUNKS_PER_FILE = 500
# Long Claude Code sessions and large transcript exports routinely exceed
# 10 MB. The cap exists as a defensive rail against pathological binary
# files, not as a limit on legitimate text. Per-drawer size is bounded
@@ -280,7 +298,15 @@ def load_config(project_dir: str) -> dict:
if legacy_path.exists():
config_path = legacy_path
else:
wing_name = resolved_project_dir.name
from .config import normalize_wing_name
# Normalize the dirname-derived fallback wing the same way
# ``cmd_init`` and ``room_detector_local`` do — otherwise a
# hyphenated project mined without a yaml file lands under a
# raw-name wing while ``topics_by_wing`` was keyed under the
# normalized slug, silently dropping every topic tunnel
# (the no-yaml branch of issue #1194).
wing_name = normalize_wing_name(resolved_project_dir.name)
print(
f" No mempalace.yaml found in {resolved_project_dir} "
f"— using auto-detected defaults (wing='{wing_name}'). "
@@ -306,6 +332,28 @@ def load_config(project_dir: str) -> dict:
# FILE ROUTING — which room does this file belong to?
# =============================================================================
_TOKEN_SPLIT = re.compile(r"[-_./]+")
def _tokens(value: str) -> set:
"""Split ``value`` into lowercased tokens bounded by ``-``, ``_``, ``.`` or ``/``."""
return {t for t in _TOKEN_SPLIT.split(value.lower()) if t}
def _name_matches(a: str, b: str) -> bool:
"""Return True when ``a`` and ``b`` match as equal strings or as
separator-bounded tokens of each other.
Prevents incidental substring collisions (e.g., ``"views" in "interviews"``)
that a raw ``in`` check would produce, while preserving the intended
match for real tokens (e.g., ``"frontend"`` in ``"frontend-app"``).
"""
a = a.lower()
b = b.lower()
if a == b:
return True
return b in _tokens(a) or a in _tokens(b)
def detect_room(filepath: Path, content: str, rooms: list, project_path: Path) -> str:
"""
@@ -325,12 +373,12 @@ def detect_room(filepath: Path, content: str, rooms: list, project_path: Path) -
for part in path_parts[:-1]: # skip filename itself
for room in rooms:
candidates = [room["name"].lower()] + [k.lower() for k in room.get("keywords", [])]
if any(part == c or c in part or part in c for c in candidates):
if any(_name_matches(part, c) for c in candidates):
return room["name"]
# Priority 2: filename matches room name
for room in rooms:
if room["name"].lower() in filename or filename in room["name"].lower():
if _name_matches(filename, room["name"]):
return room["name"]
# Priority 3: keyword scoring from room keywords + name
@@ -436,7 +484,16 @@ def _refresh_known_entities_cache() -> None:
data = json.load(f)
if isinstance(data, dict):
raw = data
for cat in data.values():
for cat_key, cat in data.items():
# Special wing-keyed map — its inner values are topic
# names but its outer keys are wings, which must NOT be
# surfaced as known entities. Pull the topic names out
# explicitly instead of treating it as a generic category.
if cat_key == "topics_by_wing" and isinstance(cat, dict):
for topic_list in cat.values():
if isinstance(topic_list, list):
names.update(str(n) for n in topic_list if n)
continue
if isinstance(cat, list):
names.update(str(n) for n in cat if n)
elif isinstance(cat, dict):
@@ -471,6 +528,172 @@ def _load_known_entities_raw() -> dict:
return dict(_ENTITY_REGISTRY_CACHE["raw"])
def _set_wing_topics(existing: dict, wing_key: str, topics_for_wing: list, coerce) -> None:
"""Update ``existing['topics_by_wing'][wing_key]`` to the deduped list.
Replaces (does not union) the wing's topic list — re-running ``init``
should reflect the user's latest confirmation rather than accumulate
stale labels. Empty input drops the wing entry; an empty map drops
the ``topics_by_wing`` key entirely.
"""
topics_map = existing.get("topics_by_wing")
if not isinstance(topics_map, dict):
topics_map = {}
seen_lower: set = set()
ordered: list = []
for n in topics_for_wing:
name = coerce(n)
if not name:
continue
key = name.lower()
if key in seen_lower:
continue
seen_lower.add(key)
ordered.append(name)
if ordered:
topics_map[wing_key] = ordered
else:
topics_map.pop(wing_key, None)
if topics_map:
existing["topics_by_wing"] = topics_map
else:
existing.pop("topics_by_wing", None)
def add_to_known_entities(entities_by_category: dict, wing: str = None) -> str:
"""Union ``entities_by_category`` into ``~/.mempalace/known_entities.json``.
Accepts ``{category: [names]}`` shape as produced by ``mempalace init``
and merges into the registry the miner reads at mine time. Existing
categories are preserved untouched unless also present in the input;
for categories present in both, entries are unioned case-insensitively
without changing the on-disk ordering of pre-existing names.
If a category is stored on-disk as ``{name: code}`` (the alternate
miner-supported shape, used by dialect-style configs), new names are
added as keys with ``None`` values so existing code mappings aren't
overwritten. A later compress pass can assign codes.
When ``wing`` is provided AND ``entities_by_category`` contains a
``topics`` list, those topics are also recorded under
``topics_by_wing[wing]`` (case-insensitive dedup, preserving the
casing of the first observed name). This is the signal source for
``palace_graph.compute_topic_tunnels`` at mine time. Topics for a
wing are *replaced*, not unioned, so a re-run of ``init`` reflects
the user's latest confirmation rather than accumulating stale labels
indefinitely.
The in-process cache is invalidated on write so same-process callers
(notably ``cmd_init`` → ``cmd_mine`` in sequence) see the update
immediately instead of waiting for a mtime re-check.
Returns the registry path as a string for logging.
"""
import json as _json
from pathlib import Path as _Path
registry_path = _Path(_ENTITY_REGISTRY_PATH)
registry_path.parent.mkdir(parents=True, exist_ok=True)
existing: dict = {}
if registry_path.exists():
try:
loaded = _json.loads(registry_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
existing = loaded
except (_json.JSONDecodeError, OSError):
existing = {}
def _coerce_name(value):
if not value:
return None
name = str(value)
return name if name else None
# Separate the topics_by_wing key from regular categories so we don't
# treat it as a flat name-list elsewhere in this function.
topics_for_wing = None
if wing and isinstance(wing, str) and wing.strip():
topics_for_wing = entities_by_category.get("topics") or []
for category, names in entities_by_category.items():
if category == "topics_by_wing":
# Reserved key — managed separately below.
continue
if not isinstance(names, list) or not names:
continue
current = existing.get(category)
if isinstance(current, list):
seen_lower = {str(n).lower() for n in current}
for n in names:
name = _coerce_name(n)
if not name:
continue
if name.lower() not in seen_lower:
current.append(name)
seen_lower.add(name.lower())
elif isinstance(current, dict):
seen_lower = {str(name).lower() for name in current}
for n in names:
name = _coerce_name(n)
if not name or name.lower() in seen_lower:
continue
current[name] = None
seen_lower.add(name.lower())
else:
# Missing or unrecognized shape — seed as a fresh list, deduped
seen: set = set()
ordered: list = []
for n in names:
name = _coerce_name(n)
if not name:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
ordered.append(name)
existing[category] = ordered
if topics_for_wing is not None:
_set_wing_topics(existing, wing.strip(), topics_for_wing, _coerce_name)
registry_path.write_text(_json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
try:
registry_path.chmod(0o600)
except (OSError, NotImplementedError):
pass
# Invalidate in-process cache so later calls in the same run see the write.
_ENTITY_REGISTRY_CACHE["mtime"] = None
_ENTITY_REGISTRY_CACHE["names"] = frozenset()
_ENTITY_REGISTRY_CACHE["raw"] = {}
return str(registry_path)
def get_topics_by_wing() -> dict:
"""Return ``topics_by_wing`` from the global registry as a dict.
Returns ``{}`` if the registry is missing, malformed, or has no
``topics_by_wing`` key. Casing is preserved from disk; callers that
need case-insensitive comparison should normalize themselves.
"""
raw = _load_known_entities_raw()
topics_map = raw.get("topics_by_wing")
if not isinstance(topics_map, dict):
return {}
out: dict = {}
for wing, topics in topics_map.items():
if not isinstance(wing, str) or not wing.strip():
continue
if isinstance(topics, list):
cleaned = [str(t) for t in topics if isinstance(t, str) and t.strip()]
if cleaned:
out[wing.strip()] = cleaned
return out
_HALL_KEYWORDS_CACHE = None
@@ -541,40 +764,62 @@ def _extract_entities_for_metadata(content: str) -> str:
return ";".join(capped)
def _build_drawer_metadata(
wing: str,
room: str,
source_file: str,
chunk_index: int,
agent: str,
content: str,
source_mtime: Optional[float],
) -> dict:
"""Build the metadata dict for one drawer without upserting.
Split out from ``add_drawer`` so ``process_file`` can batch all chunks
of a file into a single ``collection.upsert`` — one embedding forward
pass per batch instead of per chunk.
"""
metadata = {
"wing": wing,
"room": room,
"source_file": source_file,
"chunk_index": chunk_index,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"normalize_version": NORMALIZE_VERSION,
}
if source_mtime is not None:
metadata["source_mtime"] = source_mtime
metadata["hall"] = detect_hall(content)
entities = _extract_entities_for_metadata(content)
if entities:
metadata["entities"] = entities
return metadata
def add_drawer(
collection, wing: str, room: str, content: str, source_file: str, chunk_index: int, agent: str
):
"""Add one drawer to the palace."""
"""Add one drawer to the palace.
Kept for backward compatibility with external callers. In-tree the
miner uses ``_build_drawer_metadata`` + a batched ``collection.upsert``
to amortize the embedding model's forward-pass cost across chunks.
"""
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk_index)).encode()).hexdigest()[:24]}"
try:
metadata = {
"wing": wing,
"room": room,
"source_file": source_file,
"chunk_index": chunk_index,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"normalize_version": NORMALIZE_VERSION,
}
# Store file mtime so we can detect modifications later.
try:
metadata["source_mtime"] = os.path.getmtime(source_file)
except OSError:
pass
# Tag with hall for graph connectivity within wings
metadata["hall"] = detect_hall(content)
# Tag with entity names for filterable search
entities = _extract_entities_for_metadata(content)
if entities:
metadata["entities"] = entities
collection.upsert(
documents=[content],
ids=[drawer_id],
metadatas=[metadata],
)
return True
except Exception:
raise
source_mtime = os.path.getmtime(source_file)
except OSError:
source_mtime = None
metadata = _build_drawer_metadata(
wing, room, source_file, chunk_index, agent, content, source_mtime
)
collection.upsert(
documents=[content],
ids=[drawer_id],
metadatas=[metadata],
)
return True
# =============================================================================
@@ -611,6 +856,13 @@ def process_file(
room = detect_room(filepath, content, rooms, project_path)
chunks = chunk_text(content, source_file)
if len(chunks) > MAX_CHUNKS_PER_FILE:
print(
f" ! [skip] {filepath.name[:50]:50} produced {len(chunks)} chunks "
f"(> {MAX_CHUNKS_PER_FILE}); add to SKIP_FILENAMES or .gitignore"
)
return 0, room
if dry_run:
print(f" [DRY RUN] {filepath.name} -> room:{room} ({len(chunks)} drawers)")
return len(chunks), room
@@ -631,21 +883,43 @@ def process_file(
try:
collection.delete(where={"source_file": source_file})
except Exception:
pass
logger.debug("Stale-drawer purge failed for %s", source_file, exc_info=True)
# Batch chunks into bounded upserts so the embedding model sees many
# chunks per forward pass without building one huge Chroma/SQLite
# request for pathological files. A bad chunk can fail its sub-batch;
# that is the deliberate trade-off for amortizing embedding overhead.
try:
source_mtime = os.path.getmtime(source_file)
except OSError:
source_mtime = None
drawers_added = 0
for chunk in chunks:
added = add_drawer(
collection=collection,
wing=wing,
room=room,
content=chunk["content"],
source_file=source_file,
chunk_index=chunk["chunk_index"],
agent=agent,
for batch_start in range(0, len(chunks), DRAWER_UPSERT_BATCH_SIZE):
batch_docs: list = []
batch_ids: list = []
batch_metas: list = []
for chunk in chunks[batch_start : batch_start + DRAWER_UPSERT_BATCH_SIZE]:
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
batch_docs.append(chunk["content"])
batch_ids.append(drawer_id)
batch_metas.append(
_build_drawer_metadata(
wing,
room,
source_file,
chunk["chunk_index"],
agent,
chunk["content"],
source_mtime,
)
)
collection.upsert(
documents=batch_docs,
ids=batch_ids,
metadatas=batch_metas,
)
if added:
drawers_added += 1
drawers_added += len(batch_docs)
# Build closet — the searchable index pointing to these drawers.
# Purge first: a re-mine (mtime change or normalize_version bump) must
@@ -759,23 +1033,75 @@ def mine(
dry_run: bool = False,
respect_gitignore: bool = True,
include_ignored: list = None,
files: list = None,
):
"""Mine a project directory into the palace."""
"""Mine a project directory into the palace.
``files`` may optionally be a pre-scanned list of file paths from
:func:`scan_project`. When provided, the corpus walk is skipped — the
caller (e.g. ``init`` showing a file-count estimate before the mine
prompt) avoids walking the tree twice. When ``None`` (the default),
``mine`` walks the tree itself just like before.
"""
if dry_run:
return _mine_impl(
project_dir,
palace_path,
wing_override=wing_override,
agent=agent,
limit=limit,
dry_run=dry_run,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
files=files,
)
# MineAlreadyRunning propagates so the CLI can render a clear holder-aware
# message and exit non-zero. In-process callers (tests, library users) that
# expect to coexist with another writer should handle the exception.
with mine_palace_lock(palace_path):
return _mine_impl(
project_dir,
palace_path,
wing_override=wing_override,
agent=agent,
limit=limit,
dry_run=dry_run,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
files=files,
)
def _mine_impl(
project_dir: str,
palace_path: str,
wing_override: str = None,
agent: str = "mempalace",
limit: int = 0,
dry_run: bool = False,
respect_gitignore: bool = True,
include_ignored: list = None,
files: list = None,
):
project_path = Path(project_dir).expanduser().resolve()
config = load_config(project_dir)
wing = wing_override or config["wing"]
rooms = config.get("rooms", [{"name": "general", "description": "All project files"}])
files = scan_project(
project_dir,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
)
if files is None:
files = scan_project(
project_dir,
respect_gitignore=respect_gitignore,
include_ignored=include_ignored,
)
if limit > 0:
files = files[:limit]
from .embedding import describe_device
print(f"\n{'=' * 55}")
print(" MemPalace Mine")
print(f"{'=' * 55}")
@@ -783,6 +1109,7 @@ def mine(
print(f" Rooms: {', '.join(r['name'] for r in rooms)}")
print(f" Files: {len(files)}")
print(f" Palace: {palace_path}")
print(f" Device: {describe_device()}")
if dry_run:
print(" DRY RUN — nothing will be filed")
if not respect_gitignore:
@@ -800,37 +1127,153 @@ def mine(
total_drawers = 0
files_skipped = 0
files_processed = 0
last_file = None
room_counts = defaultdict(int)
for i, filepath in enumerate(files, 1):
drawers, room = process_file(
filepath=filepath,
project_path=project_path,
collection=collection,
wing=wing,
rooms=rooms,
agent=agent,
dry_run=dry_run,
closets_col=closets_col,
)
if drawers == 0 and not dry_run:
files_skipped += 1
else:
total_drawers += drawers
room_counts[room] += 1
if not dry_run:
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
try:
for i, filepath in enumerate(files, 1):
try:
drawers, room = process_file(
filepath=filepath,
project_path=project_path,
collection=collection,
wing=wing,
rooms=rooms,
agent=agent,
dry_run=dry_run,
closets_col=closets_col,
)
except KeyboardInterrupt:
# Re-raise so the outer handler prints the summary; we
# capture the last-attempted file via last_file below.
last_file = filepath.name
raise
files_processed = i
last_file = filepath.name
if drawers == 0 and not dry_run:
files_skipped += 1
else:
total_drawers += drawers
room_counts[room] += 1
if not dry_run:
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
print(f"\n{'=' * 55}")
print(" Done.")
print(f" Files processed: {len(files) - files_skipped}")
print(f" Files skipped (already filed): {files_skipped}")
print(f" Drawers filed: {total_drawers}")
print("\n By room:")
for room, count in sorted(room_counts.items(), key=lambda x: x[1], reverse=True):
print(f" {room:20} {count} files")
print('\n Next: mempalace search "what you\'re looking for"')
print(f"{'=' * 55}\n")
if not dry_run:
# 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)
if tunnels_added:
print(f"\n Topic tunnels: +{tunnels_added} cross-wing link(s)")
except Exception as e:
# Tunnel computation must never fail a mine — degrade quietly.
print(
f"\n WARNING: topic tunnel computation skipped — {e}",
file=sys.stderr,
)
print(f"\n{'=' * 55}")
print(" Done.")
print(f" Files processed: {len(files) - files_skipped}")
print(f" Files skipped (already filed): {files_skipped}")
print(f" Drawers filed: {total_drawers}")
print("\n By room:")
for room, count in sorted(room_counts.items(), key=lambda x: x[1], reverse=True):
print(f" {room:20} {count} files")
print('\n Next: mempalace search "what you\'re looking for"')
print(f"{'=' * 55}\n")
except KeyboardInterrupt:
# Idempotent re-mine: deterministic drawer IDs mean already-filed
# drawers upsert to the same row on next run, so partial progress
# is safe to leave in place. A second Ctrl-C during this print
# propagates to the default handler — we don't try to catch
# everything.
print("\n\n Mine interrupted.")
print(f" files_processed: {files_processed}/{len(files)}")
print(f" drawers_filed: {total_drawers}")
print(f" last_file: {last_file or '<none>'}")
print(
f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` to resume — "
"already-filed drawers are\n upserted idempotently and will not duplicate.\n"
)
sys.exit(130)
except Exception as exc:
# Without this, an arbitrary exception (ONNX bad_alloc, chromadb HNSW
# error, OS fault) propagates and the process exits with no completion
# banner — the operator sees only the final progress line and assumes
# the mine succeeded (#1296). Print the partial-progress summary the
# way we do for KeyboardInterrupt, then re-raise so the original
# traceback still surfaces and the exit code is non-zero.
print("\n\n Mine aborted by exception.")
print(f" files_processed: {files_processed}/{len(files)}")
print(f" drawers_filed: {total_drawers}")
print(f" last_file: {last_file or '<none>'}")
print(f" error: {type(exc).__name__}: {exc}")
print(
f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` after addressing "
"the cause — already-filed\n drawers are upserted idempotently and will "
"not duplicate.\n"
)
raise
finally:
# Clean up the hooks-side PID lock if it points at us. Stale
# entries already pass _pid_alive() == False on POSIX, but
# actively removing the file makes the state observable
# (callers can stat it) and avoids accidental PID reuse on
# short-lived test runs. Only remove if the file claims our
# own PID — never another process's.
_cleanup_mine_pid_file()
def _cleanup_mine_pid_file() -> None:
"""Remove this process's per-target PID slot on exit.
Hook-spawned mines receive ``MEMPALACE_MINE_PID_FILE`` in their env
pointing at the slot the hook claimed for them
(``~/.mempalace/hook_state/mine_pids/mine_<sha>.pid``). When the
subprocess exits — cleanly, on error, or via Ctrl-C — it removes its
own slot so the next hook fire isn't briefly fooled by a stale PID
before ``_pid_alive`` returns False.
Only delete the slot if it claims our own PID; any other PID is left
alone (it could belong to an unrelated mine that just claimed the
same slot via a stale-reclaim race).
"""
pid_file_env = os.environ.get("MEMPALACE_MINE_PID_FILE", "")
if not pid_file_env:
return
try:
pid_file = Path(pid_file_env)
if not pid_file.exists():
return
recorded = pid_file.read_text().strip()
if recorded and recorded.isdigit() and int(recorded) == os.getpid():
pid_file.unlink()
except OSError:
# Best-effort cleanup; never fail the mine over PID bookkeeping.
pass
def _compute_topic_tunnels_for_wing(wing: str) -> int:
"""Drop tunnels between ``wing`` and every other wing that shares
confirmed topics, honoring the ``topic_tunnel_min_count`` config knob.
Returns the number of tunnels created or refreshed. Zero means no
overlap found (or the registry has no ``topics_by_wing`` map yet).
"""
from .config import MempalaceConfig
from .palace_graph import topic_tunnels_for_wing
topics_map = get_topics_by_wing()
if not topics_map or wing not in topics_map:
return 0
cfg = MempalaceConfig()
min_count = cfg.topic_tunnel_min_count
created = topic_tunnels_for_wing(wing, topics_map, min_count=min_count)
return len(created)
# =============================================================================
@@ -847,18 +1290,24 @@ def status(palace_path: str):
print(" Run: mempalace init <dir> then mempalace mine <dir>")
return
# Count by wing and room
# Count by wing and room — paginate to avoid SQLite "too many SQL
# variables" error on large palaces (see #802, #850).
total = col.count()
r = col.get(limit=total, include=["metadatas"]) if total else {"metadatas": []}
metas = r["metadatas"]
wing_rooms = defaultdict(lambda: defaultdict(int))
for m in metas:
m = m or {}
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
wing_rooms: dict = defaultdict(lambda: defaultdict(int))
batch_size = 5000
offset = 0
while offset < total:
r = col.get(limit=batch_size, offset=offset, include=["metadatas"])
batch = r["metadatas"]
if not batch:
break
for m in batch:
m = m or {}
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
offset += len(batch)
print(f"\n{'=' * 55}")
print(f" MemPalace Status — {len(metas)} drawers")
print(f" MemPalace Status — {total} drawers")
print(f"{'=' * 55}\n")
for wing, rooms in sorted(wing_rooms.items()):
print(f" WING: {wing}")
+75 -2
View File
@@ -8,6 +8,7 @@ Supported:
- ChatGPT conversations.json
- Claude Code JSONL (with tool_use/tool_result block capture)
- OpenAI Codex CLI JSONL
- Gemini CLI JSONL (~/.gemini/tmp/<project_hash>/chats/session-*.jsonl)
- Slack JSON export
- Plain text (pass through for paragraph chunking)
@@ -117,14 +118,14 @@ def normalize(filepath: str) -> str:
try:
file_size = os.path.getsize(filepath)
except OSError as e:
raise IOError(f"Could not read {filepath}: {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", errors="replace") as f:
content = f.read()
except OSError as e:
raise IOError(f"Could not read {filepath}: {e}")
raise IOError(f"Could not read {filepath}: {e}") from e
if not content.strip():
return content
@@ -157,6 +158,10 @@ def _try_normalize_json(content: str) -> Optional[str]:
if normalized:
return normalized
normalized = _try_gemini_jsonl(content)
if normalized:
return normalized
try:
data = json.loads(content)
except json.JSONDecodeError:
@@ -280,6 +285,74 @@ def _try_codex_jsonl(content: str) -> Optional[str]:
return None
def _try_gemini_jsonl(content: str) -> Optional[str]:
"""Gemini CLI sessions (~/.gemini/tmp/<project_hash>/chats/session-*.jsonl).
Schema (per google-gemini/gemini-cli#15292): a session_metadata record
on the first line, then a stream of ``{"type": "user", "content":
[{"text": "..."}]}`` and ``{"type": "gemini", "content": [...]}``
records, with optional ``message_update`` records carrying token
counts only.
Detection requires a ``session_metadata`` record so this parser does
not false-positive against Claude Code or Codex JSONL passed through
the dispatch chain. Any ``user``/``gemini`` lines that appear before
``session_metadata`` are discarded — they are treated as preamble
noise, not conversational turns. ``message_update`` entries are
skipped — they have no message text. Multiple text blocks within a
single message's content array are concatenated in order, separated
by newlines.
"""
lines = [line.strip() for line in content.strip().split("\n") if line.strip()]
messages = []
has_session_metadata = 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_metadata":
has_session_metadata = True
continue
# Discard everything (including user/gemini turns) until the
# session_metadata sentinel has been seen.
if not has_session_metadata:
continue
if entry_type not in ("user", "gemini"):
# Skips message_update, system events, anything else.
continue
content_blocks = entry.get("content", [])
if not isinstance(content_blocks, list):
continue
parts = []
for block in content_blocks:
if not isinstance(block, dict):
continue
text = block.get("text", "")
if isinstance(text, str) and text.strip():
parts.append(text)
if not parts:
continue
joined = "\n".join(parts)
if entry_type == "user":
messages.append(("user", joined))
else: # "gemini"
messages.append(("assistant", joined))
if len(messages) >= 2 and has_session_metadata:
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):
+224 -3
View File
@@ -6,11 +6,17 @@ Consolidates collection access patterns used by both miners and the MCP server.
import contextlib
import hashlib
import logging
import os
import re
import sys
import threading
from typing import Optional
from .backends.chroma import ChromaBackend
logger = logging.getLogger("mempalace_mcp")
SKIP_DIRS = {
".git",
"node_modules",
@@ -52,10 +58,14 @@ NORMALIZE_VERSION = 2
def get_collection(
palace_path: str,
collection_name: str = "mempalace_drawers",
collection_name: Optional[str] = None,
create: bool = True,
):
"""Get the palace collection through the backend layer."""
if collection_name is None:
from .config import get_configured_collection_name
collection_name = get_configured_collection_name()
return _DEFAULT_BACKEND.get_collection(
palace_path,
collection_name=collection_name,
@@ -228,7 +238,7 @@ def purge_file_closets(closets_col, source_file: str) -> None:
try:
closets_col.delete(where={"source_file": source_file})
except Exception:
pass
logger.debug("Closet purge failed for %s", source_file, exc_info=True)
def upsert_closet_lines(closets_col, closet_id_base, lines, metadata):
@@ -306,10 +316,221 @@ def mine_lock(source_file: str):
fcntl.flock(lf, fcntl.LOCK_UN)
except Exception:
pass
logger.debug("Mine-lock release failed", exc_info=True)
lf.close()
class MineAlreadyRunning(RuntimeError):
"""Raised when another `mempalace mine` already holds the per-palace lock."""
# 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
# 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).
#
# 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()
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)
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
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 _mark_held(lock_key: str) -> None:
_holder_state().add(lock_key)
def _mark_released(lock_key: str) -> None:
_holder_state().discard(lock_key)
def _format_lock_holder(content: str) -> str:
"""Render a lock-file body as 'PID N (cmdline)' for diagnostic messages."""
parts = content.split(maxsplit=1)
if not parts or not parts[0].isdigit():
return "another writer (identity not recorded)"
pid = parts[0]
if len(parts) > 1 and parts[1].strip():
return f"PID {pid} ({parts[1].strip()})"
return f"PID {pid}"
# Byte 0 of the lock file is reserved as the OS lock sentinel.
# Holder identity is written from byte 1 onward so contenders can read
# the identity without colliding with byte 0 (Windows msvcrt.locking
# blocks both reads and writes on the locked byte).
_LOCK_SENTINEL_BYTES = 1
def _read_lock_holder(lock_file) -> str:
"""Read the prior holder's identity from the lock-file body, best-effort."""
try:
lock_file.seek(_LOCK_SENTINEL_BYTES)
content = lock_file.read().strip()
except OSError:
return "another writer (identity not recorded)"
if not content:
return "another writer (identity not recorded)"
return _format_lock_holder(content)
def _write_lock_holder(lock_file) -> None:
"""Record this process's identity in the lock-file body. Best-effort.
Writes from byte 1 onward; byte 0 is the lock sentinel and must not
be touched after acquire (truncating it on Windows can interact
badly with the active byte-range lock).
"""
try:
ident = f"{os.getpid()} {' '.join(sys.argv[:3])}".strip()
lock_file.seek(_LOCK_SENTINEL_BYTES)
lock_file.truncate(_LOCK_SENTINEL_BYTES + len(ident.encode("utf-8")))
lock_file.write(ident)
lock_file.flush()
except OSError:
pass
@contextlib.contextmanager
def mine_palace_lock(palace_path: str):
"""Per-palace non-blocking lock around the full `mine` pipeline.
The per-file `mine_lock` only protects delete+insert interleave for a
single source; it does not prevent N copies of `mempalace mine <dir>`
from being spawned concurrently by hooks. When that happens, each copy
drives ChromaDB HNSW inserts in parallel against the same palace,
which (combined with chromadb's multi-threaded ParallelFor) can
corrupt the HNSW graph and produce sparse link_lists.bin blowups.
The lock file is keyed by sha256(palace_path) so mines against
*different* palaces can still run in parallel — we only serialize
writes into the same palace, which is the correctness boundary.
The key is derived from a fully normalized form of the path:
`realpath` resolves symlinks and `..` segments, and `normcase` folds
case on Windows (which has a case-insensitive filesystem). Without
normcase, `C:\\Palace` and `c:\\palace` would hash to different keys
on Windows and let two concurrent mines touch the same on-disk palace.
Non-blocking: if another `mine` is already writing to this palace,
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
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.
"""
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
resolved = os.path.realpath(os.path.expanduser(palace_path))
lock_key_source = os.path.normcase(resolved)
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.
yield
return
# Ensure the file exists, then open r+ so we can both read the prior
# holder's identity (for failure diagnostics) and write our own. "w"
# truncates and erases the prior holder. "a+" puts the position at EOF,
# which on Windows breaks ``msvcrt.locking`` (it locks 1 byte at the
# *current* position, so two contenders end up locking different bytes
# and silently both acquire — observed as Windows-CI lock test
# failures during #1264 development).
if not os.path.exists(lock_path):
# Touch atomically: O_CREAT|O_EXCL would fail if a concurrent
# contender just created it, which is fine — we proceed to open.
try:
fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY, 0o600)
os.close(fd)
except FileExistsError:
pass
lf = open(lock_path, "r+")
acquired = False
try:
# Lock byte 0 explicitly. msvcrt.locking is byte-position dependent;
# fcntl.flock is whole-file but the seek is harmless there.
lf.seek(0)
if os.name == "nt":
import msvcrt
try:
msvcrt.locking(lf.fileno(), msvcrt.LK_NBLCK, 1)
acquired = True
except OSError as exc:
holder = _read_lock_holder(lf)
raise MineAlreadyRunning(
f"palace {resolved} is held by {holder}; "
"wait for it to finish or stop the holder before retrying"
) from exc
else:
import fcntl
try:
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
except BlockingIOError as exc:
holder = _read_lock_holder(lf)
raise MineAlreadyRunning(
f"palace {resolved} is held by {holder}; "
"wait for it to finish or stop the holder before retrying"
) from exc
# Record our own identity for any later contender's diagnostic message.
_write_lock_holder(lf)
_mark_held(palace_key)
try:
yield
finally:
_mark_released(palace_key)
finally:
if acquired:
try:
if os.name == "nt":
import msvcrt
# Match the lock region: byte 0.
lf.seek(0)
msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(lf, fcntl.LOCK_UN)
except Exception:
pass
lf.close()
# Backward-compatible alias (previous patch iteration used a single global
# lock). Kept so third-party callers that imported it continue to work; new
# code should use `mine_palace_lock(palace_path)` for per-palace scoping.
mine_global_lock = mine_palace_lock
def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool:
"""Check if a file has already been filed in the palace.
+289 -9
View File
@@ -15,16 +15,60 @@ Enables queries like:
No external graph DB needed — built from ChromaDB metadata.
"""
# PEP 604 (``str | None``) needs 3.10+ at runtime; the project still
# supports 3.9, so defer annotation evaluation to keep the union syntax
# working on the older interpreter.
from __future__ import annotations
import hashlib
import json
import logging
import os
import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timezone
from .config import MempalaceConfig
from .config import MempalaceConfig, normalize_wing_name
from .palace import get_collection as _get_palace_collection
from .palace import mine_lock
logger = logging.getLogger("mempalace_graph")
def _normalize_wing(wing: str | None) -> str | None:
"""Normalize a wing name for consistent lookup.
``init`` stores wing names with hyphens and spaces replaced by underscores
(e.g. ``mempalace_public``). Callers that pass the raw directory name
(``mempalace-public``) would silently miss. This helper aligns the lookup
key with the stored metadata.
"""
if wing is None:
return None
wing = wing.strip()
if not wing:
return None
return normalize_wing_name(wing)
# Module-level graph cache with TTL and write-invalidation.
# Warm cache serves build_graph() in O(1); invalidate_graph_cache() clears on writes.
_graph_cache_lock = threading.Lock()
_graph_cache_nodes = None
_graph_cache_edges = None
_graph_cache_time = 0.0
_GRAPH_CACHE_TTL = 60.0 # seconds — graph changes less often than metadata
def invalidate_graph_cache():
"""Clear the graph cache. Called from mcp_server.py on writes."""
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
with _graph_cache_lock:
_graph_cache_nodes = None
_graph_cache_edges = None
_graph_cache_time = 0.0
def _get_collection(config=None):
config = config or MempalaceConfig()
@@ -42,10 +86,25 @@ def build_graph(col=None, config=None):
"""
Build the palace graph from ChromaDB metadata.
Returns cached result if fresh (within TTL). Cache is invalidated
on writes via invalidate_graph_cache(). Thread-safe via _graph_cache_lock.
Note: warm cache ignores ``col`` and ``config`` arguments — this is
intentional for the MCP server's single-palace use case. Callers
switching collections should call ``invalidate_graph_cache()`` first.
Returns:
nodes: dict of {room: {wings: set, halls: set, count: int}}
edges: list of {room, wing_a, wing_b, hall} — one per tunnel crossing
"""
global _graph_cache_nodes, _graph_cache_edges, _graph_cache_time
now = time.time()
# NOTE: warm cache ignores col/config args — intentional for the MCP server's
# single-palace use case. Callers switching collections must invalidate first.
with _graph_cache_lock:
if _graph_cache_nodes is not None and (now - _graph_cache_time) < _GRAPH_CACHE_TTL:
return _graph_cache_nodes, _graph_cache_edges
if col is None:
col = _get_collection(config)
if not col:
@@ -58,6 +117,16 @@ def build_graph(col=None, config=None):
while offset < total:
batch = col.get(limit=1000, offset=offset, include=["metadatas"])
for meta in batch["metadatas"]:
# ChromaDB can return ``None`` for drawers without metadata
# (legacy data, partial writes — upstream #1020 territory).
# Skip these silently rather than crash the whole graph
# build — a single None drawer shouldn't take down /stats
# or any caller of build_graph for the entire palace. Caught
# 2026-04-25 by palace-daemon's verify-routes.sh smoke test
# against the canonical 151K palace. Closes the same gap as
# upstream #999 / fork PR #1094 in a different read path.
if meta is None:
continue
room = meta.get("room", "")
wing = meta.get("wing", "")
hall = meta.get("hall", "")
@@ -101,6 +170,14 @@ def build_graph(col=None, config=None):
"dates": sorted(data["dates"])[-5:] if data["dates"] else [],
}
# Only cache non-empty graphs so new data is picked up immediately
# when the palace is first populated.
if nodes:
with _graph_cache_lock:
_graph_cache_nodes = nodes
_graph_cache_edges = edges
_graph_cache_time = time.time()
return nodes, edges
@@ -173,15 +250,18 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
"""
nodes, edges = build_graph(col, config)
norm_a = _normalize_wing(wing_a)
norm_b = _normalize_wing(wing_b)
tunnels = []
for room, data in nodes.items():
wings = data["wings"]
if len(wings) < 2:
continue
if wing_a and wing_a not in wings:
if norm_a and norm_a not in wings:
continue
if wing_b and wing_b not in wings:
if norm_b and norm_b not in wings:
continue
tunnels.append(
@@ -194,6 +274,15 @@ def find_tunnels(wing_a: str = None, wing_b: str = None, col=None, config=None):
}
)
if not tunnels and (wing_a or wing_b):
logger.warning(
"No tunnels found for wing filter(s): wing_a=%r (normalized=%r), wing_b=%r (normalized=%r)",
wing_a,
norm_a,
wing_b,
norm_b,
)
tunnels.sort(key=lambda x: -x["count"])
return tunnels[:50]
@@ -271,8 +360,20 @@ def _save_tunnels(tunnels):
Writes to ``tunnels.json.tmp`` then ``os.replace``s it into place, so
a crash mid-write can never leave a partial/empty tunnels.json that
silently wipes every tunnel on next read.
Also restricts the parent directory to 0o700 and the file to 0o600 —
tunnels reveal cross-wing connections (which projects/people/rooms
the user has explicitly linked) and should not be world-readable on
shared Linux/multi-user systems. Matches the file-permission pattern
established by #814 for the other sensitive palace files.
"""
os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True)
parent = os.path.dirname(_TUNNEL_FILE)
os.makedirs(parent, exist_ok=True)
try:
os.chmod(parent, 0o700)
except (OSError, NotImplementedError):
# Windows / unsupported filesystems — tolerate.
pass
tmp_path = _TUNNEL_FILE + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(tunnels, f, indent=2)
@@ -283,6 +384,10 @@ def _save_tunnels(tunnels):
# Not all filesystems (or Windows file handles) support fsync — tolerate.
pass
os.replace(tmp_path, _TUNNEL_FILE)
try:
os.chmod(_TUNNEL_FILE, 0o600)
except (OSError, NotImplementedError):
pass
def _endpoint_key(wing: str, room: str) -> str:
@@ -320,6 +425,7 @@ def create_tunnel(
label: str = "",
source_drawer_id: str = None,
target_drawer_id: str = None,
kind: str = "explicit",
):
"""Create an explicit (symmetric) tunnel between two locations in the palace.
@@ -340,6 +446,11 @@ def create_tunnel(
label: Description of the connection.
source_drawer_id: Optional specific drawer ID.
target_drawer_id: Optional specific drawer ID.
kind: Tunnel category — ``"explicit"`` (default, user-created link
between real rooms) or ``"topic"`` (auto-generated cross-wing
topical link where rooms are synthetic ``topic:<name>``
identifiers). Preserved on the stored dict so readers can
distinguish real-room traversals from topic connections.
Returns:
The stored tunnel dict.
@@ -352,6 +463,9 @@ def create_tunnel(
target_wing = _require_name(target_wing, "target_wing")
target_room = _require_name(target_room, "target_room")
source_wing = _normalize_wing(source_wing)
target_wing = _normalize_wing(target_wing)
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
tunnel = {
@@ -359,6 +473,7 @@ def create_tunnel(
"source": {"wing": source_wing, "room": source_room},
"target": {"wing": target_wing, "room": target_room},
"label": label,
"kind": kind,
"created_at": datetime.now(timezone.utc).isoformat(),
}
if source_drawer_id:
@@ -391,9 +506,14 @@ def list_tunnels(wing: str = None):
Returns tunnels where ``wing`` appears as either source or target
(tunnels are symmetric, so either endpoint is a valid filter match).
"""
norm_wing = _normalize_wing(wing)
tunnels = _load_tunnels()
if wing:
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
if norm_wing:
tunnels = [
t
for t in tunnels
if t["source"]["wing"] == norm_wing or t["target"]["wing"] == norm_wing
]
return tunnels
@@ -412,6 +532,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
Given a location (wing/room), finds all tunnels leading from or to it,
and optionally fetches the connected drawer content.
"""
norm_wing = _normalize_wing(wing) or wing
tunnels = _load_tunnels()
connections = []
@@ -419,7 +540,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
src = t["source"]
tgt = t["target"]
if src["wing"] == wing and src["room"] == room:
if src["wing"] == norm_wing and src["room"] == room:
connections.append(
{
"direction": "outgoing",
@@ -430,7 +551,7 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
"tunnel_id": t["id"],
}
)
elif tgt["wing"] == wing and tgt["room"] == room:
elif tgt["wing"] == norm_wing and tgt["room"] == room:
connections.append(
{
"direction": "incoming",
@@ -442,6 +563,9 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
}
)
if not connections:
logger.warning("No explicit tunnels found for %s/%s", wing, room)
# If we have a collection, fetch drawer content for connected items
if col and connections:
drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")]
@@ -454,6 +578,162 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
if did and did in drawer_map:
c["drawer_preview"] = drawer_map[did][:300]
except Exception:
pass
logger.debug("Drawer preview hydration failed", exc_info=True)
return connections
# =============================================================================
# TOPIC TUNNELS — auto-link wings that share confirmed TOPIC labels
# =============================================================================
# When two wings have one or more confirmed topics in common (e.g. both
# discuss "Angular" or "OpenAPI"), drop a symmetric tunnel between them.
# Topics come from the LLM-refined ``TOPIC`` bucket in the per-project
# ``entities.json`` and are persisted by wing in
# ``~/.mempalace/known_entities.json`` under ``topics_by_wing``.
#
# Tunnels are created via the existing ``create_tunnel`` API so they share
# storage and dedup with explicit tunnels. The room is a synthetic
# ``topic:<original-casing>`` identifier — the ``topic:`` prefix namespaces
# these tunnels away from literal folder-derived rooms so a wing with an
# auto-detected "Angular" folder room and a "shared topic: Angular" tunnel
# remain distinct at ``follow_tunnels`` / ``list_tunnels`` time. The prefix
# is also visible to any LLM scanning the tunnel list. The ``kind: "topic"``
# field on the stored dict gives callers a machine-readable discriminator.
TOPIC_ROOM_PREFIX = "topic:"
def _normalize_topic(name: str) -> str:
"""Lowercase + strip topics for case-insensitive overlap detection."""
return str(name).strip().lower()
def topic_room(name: str) -> str:
"""Return the synthetic room identifier for a topic tunnel.
Prefixing avoids collisions with literal folder-derived rooms of the
same name (e.g. a wing that has both an "Angular" folder room and an
"Angular" topic tunnel).
"""
return f"{TOPIC_ROOM_PREFIX}{name}"
def compute_topic_tunnels(
topics_by_wing: dict,
min_count: int = 1,
label_prefix: str = "shared topic",
) -> list[dict]:
"""Create tunnels for every pair of wings that share >= ``min_count`` topics.
Args:
topics_by_wing: ``{wing_name: [topic_name, ...]}`` mapping. Topic
names are compared case-insensitively; the first observed
casing is used for the tunnel room name.
min_count: minimum number of overlapping topics required to drop
any tunnel between a wing pair. ``1`` means a single shared
topic is enough; bumping to e.g. ``2`` requires multiple
overlaps and filters out coincidental single-topic links.
label_prefix: human-readable string prefixed to the tunnel label.
Returns:
List of tunnel dicts as returned by ``create_tunnel`` — one per
(wing_a, wing_b, topic) triple that crossed the threshold. A
wing-pair below ``min_count`` produces no tunnels at all (not
even for its single shared topic).
No-op semantics:
- empty/None ``topics_by_wing`` returns ``[]``.
- wings whose topic list is empty are skipped.
- ``min_count <= 0`` is clamped to 1.
"""
if not topics_by_wing:
return []
min_count = max(1, int(min_count))
# Build a normalized-topic -> first-seen casing map per wing so we
# preserve display casing while still doing case-insensitive overlap.
wing_topics: dict[str, dict[str, str]] = {}
for wing, names in topics_by_wing.items():
if not isinstance(wing, str) or not wing.strip():
continue
if not isinstance(names, (list, tuple)):
continue
bucket: dict[str, str] = {}
for n in names:
if not isinstance(n, str):
continue
key = _normalize_topic(n)
if not key:
continue
bucket.setdefault(key, n.strip())
if bucket:
wing_topics[wing.strip()] = bucket
wings = sorted(wing_topics.keys())
created: list[dict] = []
for i, wa in enumerate(wings):
topics_a = wing_topics[wa]
for wb in wings[i + 1 :]:
topics_b = wing_topics[wb]
shared_keys = set(topics_a.keys()) & set(topics_b.keys())
if len(shared_keys) < min_count:
continue
# Stable sort for deterministic tunnel ordering across runs.
for key in sorted(shared_keys):
# Prefer the casing from whichever wing sorts first — both
# are valid; this just keeps the displayed room consistent.
topic_name = topics_a[key] if topics_a[key] else topics_b[key]
room = topic_room(topic_name)
tunnel = create_tunnel(
source_wing=wa,
source_room=room,
target_wing=wb,
target_room=room,
label=f"{label_prefix}: {topic_name}",
kind="topic",
)
created.append(tunnel)
return created
def topic_tunnels_for_wing(
wing: str,
topics_by_wing: dict,
min_count: int = 1,
label_prefix: str = "shared topic",
) -> list[dict]:
"""Compute topic tunnels involving a single wing.
Used by the miner to incrementally update tunnels for the wing that
just finished mining without recomputing pairs that don't involve it.
Returns the list of tunnels created or refreshed.
"""
if not topics_by_wing or not isinstance(wing, str) or not wing.strip():
return []
wing = wing.strip()
own = topics_by_wing.get(wing)
if not isinstance(own, (list, tuple)) or not own:
return []
# Restrict the pair-wise computation to (wing, other) pairs only by
# building a 2-wing slice for each other wing. Reusing
# ``compute_topic_tunnels`` keeps the threshold and casing logic in
# one place.
created: list[dict] = []
for other, other_topics in topics_by_wing.items():
if not isinstance(other, str) or not other.strip() or other == wing:
continue
if not isinstance(other_topics, (list, tuple)) or not other_topics:
continue
slice_map = {wing: list(own), other: list(other_topics)}
created.extend(
compute_topic_tunnels(
slice_map,
min_count=min_count,
label_prefix=label_prefix,
)
)
return created
+735
View File
@@ -0,0 +1,735 @@
"""
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`.
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
(notes, research, writing).
Public:
scan(root) -> (projects, people)
to_detected_dict(projects, people) -> {people: [...], projects: [...], uncertain: []}
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
try:
import tomllib # Python 3.11+
except ImportError: # pragma: no cover
try:
import tomli as tomllib # Python 3.9/3.10 backport
except ImportError:
tomllib = None # type: ignore
SKIP_DIRS = {
".git",
"node_modules",
"__pycache__",
".venv",
"venv",
"env",
"dist",
"build",
".next",
"coverage",
".terraform",
"vendor",
"target",
".mempalace",
".cache",
".pytest_cache",
".mypy_cache",
".ruff_cache",
}
MAX_DEPTH = 6
MAX_COMMITS_PER_REPO = 1000
GIT_TIMEOUT = 10
# ==================== DATACLASSES ====================
@dataclass
class ProjectInfo:
name: str
repo_root: Path
manifest: Optional[str] = None
has_git: bool = False
total_commits: int = 0
user_commits: int = 0
is_mine: bool = False
@property
def confidence(self) -> float:
if self.is_mine:
return 0.99
if self.has_git and self.total_commits > 0:
return 0.7
return 0.85 # manifest-only, no git
def to_signal(self) -> str:
parts: list[str] = []
if self.manifest:
parts.append(self.manifest)
if self.has_git:
if self.is_mine and self.user_commits:
parts.append(f"{self.user_commits} of your commits")
elif self.user_commits:
parts.append(f"{self.user_commits}/{self.total_commits} yours")
else:
parts.append(f"{self.total_commits} commits (none by you)")
return ", ".join(parts) or "repo"
@dataclass
class PersonInfo:
name: str
total_commits: int = 0
emails: set[str] = field(default_factory=set)
repos: set[str] = field(default_factory=set)
@property
def confidence(self) -> float:
if self.total_commits >= 100 or len(self.repos) >= 3:
return 0.99
if self.total_commits >= 20:
return 0.85
return 0.65
def to_signal(self) -> str:
r = len(self.repos)
return f"{self.total_commits} commit{'s' if self.total_commits != 1 else ''} across {r} repo{'s' if r != 1 else ''}"
# ==================== MANIFEST PARSING ====================
def _parse_package_json(path: Path) -> Optional[str]:
try:
data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
except (json.JSONDecodeError, OSError):
return None
name = data.get("name")
return name if isinstance(name, str) and name else None
def _parse_toml(path: Path) -> dict:
if tomllib is None:
return {}
try:
with open(path, "rb") as f:
return tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return {}
def _parse_pyproject(path: Path) -> Optional[str]:
data = _parse_toml(path)
name = data.get("project", {}).get("name")
if isinstance(name, str) and name:
return name
name = data.get("tool", {}).get("poetry", {}).get("name")
return name if isinstance(name, str) and name else None
def _parse_cargo(path: Path) -> Optional[str]:
data = _parse_toml(path)
name = data.get("package", {}).get("name")
return name if isinstance(name, str) and name else None
def _parse_gomod(path: Path) -> Optional[str]:
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if line.startswith("module "):
mod = line.split(None, 1)[1].strip()
return mod.split("/")[-1] or None
except OSError:
return None
return None
MANIFEST_PRIORITY = {
"pyproject.toml": 0,
"package.json": 1,
"Cargo.toml": 2,
"go.mod": 3,
}
# Sentinel so unknown manifests always sort after the known manifest types above.
UNKNOWN_MANIFEST_PRIORITY = max(MANIFEST_PRIORITY.values()) + 1
MANIFEST_PARSERS = {
"package.json": _parse_package_json,
"pyproject.toml": _parse_pyproject,
"Cargo.toml": _parse_cargo,
"go.mod": _parse_gomod,
}
# ==================== GIT HELPERS ====================
def _run_git(cwd: Path, *args: str, timeout: int = GIT_TIMEOUT) -> str:
try:
r = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
return r.stdout if r.returncode == 0 else ""
except (OSError, subprocess.SubprocessError):
return ""
def _git_user_identity(repo: Path) -> tuple[str, str]:
"""Return (name, email) for this repo, falling back to global config."""
name = _run_git(repo, "config", "user.name", timeout=2).strip()
email = _run_git(repo, "config", "user.email", timeout=2).strip()
return name, email
def _global_git_identity() -> tuple[str, str]:
try:
n = subprocess.run(
["git", "config", "--global", "user.name"],
capture_output=True,
text=True,
timeout=2,
check=False,
).stdout.strip()
e = subprocess.run(
["git", "config", "--global", "user.email"],
capture_output=True,
text=True,
timeout=2,
check=False,
).stdout.strip()
return n, e
except (OSError, subprocess.SubprocessError):
return "", ""
def _git_authors(repo: Path) -> list[tuple[str, str]]:
out = _run_git(
repo,
"log",
f"--max-count={MAX_COMMITS_PER_REPO}",
"--format=%aN|%aE",
)
result = []
for line in out.splitlines():
if "|" in line:
name, email = line.split("|", 1)
result.append((name.strip(), email.strip()))
return result
# ==================== BOT / NAME FILTERING ====================
_BOT_NAME_PATTERNS = [
r"\[bot\]",
r"^dependabot",
r"^renovate",
r"^github-actions",
r"^actions-user",
r"-bot$",
r"\bbot$", # catches "PR Bot", "Release Bot", etc. Not "robot" (no \b)
r"^bot-",
r"^snyk",
r"^greenkeeper",
r"^semantic-release",
r"^allcontributors",
r"-autoroll$",
r"^auto-format",
r"^pre-commit-ci",
]
_BOT_EMAIL_PATTERNS = [
# `@users.noreply.github.com` is GitHub's privacy-protected human email —
# do NOT filter it. Real bots identify themselves via the display name
# (usually containing "[bot]"), which is caught by _BOT_NAME_PATTERNS.
r"bot@",
r"-bot@",
r"\[bot\]@",
]
_BOT_RE_NAMES = [re.compile(p) for p in _BOT_NAME_PATTERNS]
_BOT_RE_EMAILS = [re.compile(p) for p in _BOT_EMAIL_PATTERNS]
def _is_bot(name: str, email: str) -> bool:
ln, le = name.lower(), email.lower()
return any(rx.search(ln) for rx in _BOT_RE_NAMES) or any(rx.search(le) for rx in _BOT_RE_EMAILS)
def _looks_like_real_name(name: str) -> bool:
"""Heuristic: a human's name has a space and at least two title-cased parts.
Filters out handles (lowercase, digits, one-token usernames).
"""
if not name or " " not in name:
return False
parts = name.split()
if len(parts) < 2:
return False
# First and last parts must start with an uppercase letter
return parts[0][:1].isupper() and parts[-1][:1].isupper()
# ==================== DIRECTORY WALK ====================
def _walk(root: Path, max_depth: int = MAX_DEPTH):
for dirpath, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS and not d.startswith(".")]
rel = Path(dirpath).relative_to(root)
depth = 0 if rel == Path(".") else len(rel.parts)
if depth > max_depth:
dirs.clear()
continue
yield Path(dirpath), dirs, files
def _has_git_marker(path: Path) -> bool:
git_path = path / ".git"
return git_path.is_dir() or git_path.is_file()
def _manifest_sort_key(entry: tuple[str, str, Path], repo_root: Path) -> tuple[int, int, str]:
"""Sort manifests by shallowest path first, then known manifest priority,
then lexicographic path for deterministic tie-breaking.
"""
manifest_file, _project_name, manifest_dir = entry
try:
rel = manifest_dir.relative_to(repo_root)
depth = len(rel.parts)
rel_str = rel.as_posix()
except ValueError:
depth = MAX_DEPTH + 1
rel_str = manifest_dir.as_posix()
return (depth, MANIFEST_PRIORITY.get(manifest_file, UNKNOWN_MANIFEST_PRIORITY), rel_str)
def find_git_repos(root: Path, max_depth: int = MAX_DEPTH) -> list[Path]:
"""Return git repo roots under `root` (including root itself if it's a repo)."""
root = root.resolve()
repos: list[Path] = []
if _has_git_marker(root):
# Root is a repo — still walk for nested repos (submodules, etc.)
repos.append(root)
for dirpath, dirs, _ in _walk(root, max_depth):
if dirpath == root:
continue
if _has_git_marker(dirpath):
repos.append(dirpath)
dirs.clear() # don't descend into this repo's contents from here
return repos
def _collect_manifest_names(repo_root: Path) -> list[tuple[str, str, Path]]:
"""Return (manifest_filename, project_name, dirpath) within a repo.
Does not descend into nested git repos.
"""
found: list[tuple[str, str, Path]] = []
for dirpath, dirs, files in _walk(repo_root):
if dirpath != repo_root and _has_git_marker(dirpath):
dirs.clear()
continue
for fname in files:
parser = MANIFEST_PARSERS.get(fname)
if not parser:
continue
name = parser(dirpath / fname)
if name:
found.append((fname, name, dirpath))
return sorted(found, key=lambda entry: _manifest_sort_key(entry, repo_root))
# ==================== MAIN SCAN ====================
class _UnionFind:
"""Minimal union-find for (name, email) identity resolution."""
def __init__(self) -> None:
self.parent: dict = {}
def find(self, x):
if x not in self.parent:
self.parent[x] = x
return x
root = x
while self.parent[root] != root:
root = self.parent[root]
while self.parent[x] != root:
self.parent[x], x = root, self.parent[x]
return root
def union(self, a, b) -> None:
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[ra] = rb
def _dedupe_people(
all_commits: list[tuple[str, str, str]],
) -> dict[str, PersonInfo]:
"""Group commits by identity. Two commits are the same person if they
share a name OR an email. Display name = most frequent non-bot variant.
``all_commits`` is a list of (name, email, repo_str) triples from every repo.
"""
uf = _UnionFind()
for name, email, _repo in all_commits:
uf.union(("name", name), ("email", email) if email else ("name", name))
# Aggregate by component root
component_commits: dict = {}
for name, email, repo in all_commits:
key = uf.find(("name", name))
entry = component_commits.setdefault(
key, {"name_counts": {}, "emails": set(), "repos": set(), "total": 0}
)
entry["name_counts"][name] = entry["name_counts"].get(name, 0) + 1
if email:
entry["emails"].add(email)
entry["repos"].add(repo)
entry["total"] += 1
# Pick display name per component: the most-frequent variant that looks
# like a real name; fall back to most-frequent overall.
people: dict[str, PersonInfo] = {}
for _key, entry in component_commits.items():
candidates = sorted(entry["name_counts"].items(), key=lambda x: -x[1])
display = next(
(n for n, _ in candidates if _looks_like_real_name(n)),
candidates[0][0],
)
if not _looks_like_real_name(display):
continue # Skip handles and single-token names
# If we already have this display (rare — distinct components with the
# same chosen display), merge into the existing entry.
existing = people.get(display)
if existing:
existing.total_commits += entry["total"]
existing.emails.update(entry["emails"])
existing.repos.update(entry["repos"])
else:
people[display] = PersonInfo(
name=display,
total_commits=entry["total"],
emails=set(entry["emails"]),
repos=set(entry["repos"]),
)
return people
def scan(root: str | os.PathLike) -> tuple[list[ProjectInfo], list[PersonInfo]]:
"""Scan `root` for projects and people. Returns (projects, people) sorted."""
root_path = Path(root).expanduser().resolve()
if not root_path.is_dir():
return [], []
repos = find_git_repos(root_path)
# Identify current user from first repo's git config, fall back to global
me_name, me_email = "", ""
if repos:
me_name, me_email = _git_user_identity(repos[0])
if not me_name and not me_email:
me_name, me_email = _global_git_identity()
projects: dict[str, ProjectInfo] = {}
all_commits: list[tuple[str, str, str]] = []
for repo in repos:
manifests = _collect_manifest_names(repo)
if manifests:
manifest_file, proj_name, _ = manifests[0]
else:
manifest_file, proj_name = None, repo.name
authors = _git_authors(repo)
non_bot_authors = [(name, email) for name, email in authors if not _is_bot(name, email)]
total_commits = len(non_bot_authors)
user_commits = 0
author_counts: dict[str, int] = {}
for name, email in non_bot_authors:
author_counts[name] = author_counts.get(name, 0) + 1
all_commits.append((name, email, str(repo)))
if (me_name and name == me_name) or (me_email and email == me_email):
user_commits += 1
is_mine = False
if user_commits > 0:
sorted_authors = sorted(author_counts.items(), key=lambda x: -x[1])
top5 = {n for n, _ in sorted_authors[:5]}
if me_name and me_name in top5:
is_mine = True
elif total_commits and user_commits / total_commits >= 0.10:
is_mine = True
elif user_commits >= 20:
is_mine = True
proj = ProjectInfo(
name=proj_name,
repo_root=repo,
manifest=manifest_file,
has_git=True,
total_commits=total_commits,
user_commits=user_commits,
is_mine=is_mine,
)
existing = projects.get(proj_name)
if existing is None or proj.user_commits > existing.user_commits:
projects[proj_name] = proj
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:
if proj_name in projects:
continue
projects[proj_name] = ProjectInfo(
name=proj_name,
repo_root=root_path,
manifest=manifest_file,
has_git=False,
)
project_list = sorted(
projects.values(),
key=lambda p: (not p.is_mine, -p.user_commits, -p.total_commits, p.name),
)
people_list = sorted(people.values(), key=lambda p: -p.total_commits)
return project_list, people_list
# ==================== ADAPTER ====================
def to_detected_dict(
projects: list[ProjectInfo],
people: list[PersonInfo],
project_cap: int = 15,
people_cap: int = 15,
) -> dict:
"""Convert scan results into the dict shape produced by entity_detector.detect_entities."""
proj_entries = [
{
"name": p.name,
"type": "project",
"confidence": round(p.confidence, 2),
"frequency": p.user_commits or p.total_commits,
"signals": [p.to_signal()],
}
for p in projects[:project_cap]
]
people_entries = [
{
"name": p.name,
"type": "person",
"confidence": round(p.confidence, 2),
"frequency": p.total_commits,
"signals": [p.to_signal()],
}
for p in people[:people_cap]
]
return {
"people": people_entries,
"projects": proj_entries,
"topics": [],
"uncertain": [],
}
# ==================== MERGE WITH REGEX DETECTOR ====================
def _merge_detected(primary: dict, secondary: dict, drop_secondary_uncertain: bool = False) -> dict:
"""Merge two detected dicts. Primary entries win on name conflict.
Dedup is case-insensitive so "mempalace" (manifest name) absorbs "MemPalace"
(docs/prose reference) instead of surfacing both.
If ``drop_secondary_uncertain`` is True, the secondary's uncertain bucket is
dropped entirely — useful when the primary signal is strong (real repo
found) and we'd rather not ask the user to adjudicate prose-regex noise.
"""
seen = {e["name"].lower() for cat in primary.values() for e in cat}
merged = {k: list(v) for k, v in primary.items()}
for cat_key in ("people", "projects", "topics", "uncertain"):
if cat_key == "uncertain" and drop_secondary_uncertain:
continue
for e in secondary.get(cat_key, []):
if e["name"].lower() in seen:
continue
merged.setdefault(cat_key, []).append(e)
seen.add(e["name"].lower())
return merged
def discover_entities(
project_dir: str | os.PathLike,
languages: tuple = ("en",),
prose_file_cap: int = 10,
project_cap: int = 15,
people_cap: int = 15,
llm_provider: object = None,
show_progress: bool = True,
corpus_origin: dict | None = None,
) -> dict:
"""Top-level entity discovery: real signals first, prose detection second.
Returns the same dict shape as ``entity_detector.detect_entities`` so it
plugs into ``confirm_entities`` unchanged.
Order of signal preference:
1. Package manifests (package.json, pyproject.toml, Cargo.toml, go.mod)
→ 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)
4. Regex entity detection on prose files → supplementary names only
mentioned in docs/notes (not code)
5. Optional LLM refinement pass — reclassifies ambiguous candidates
using the caller-supplied provider
6. Optional corpus-origin persona filter — when the corpus is
identified as AI-dialogue, candidates whose name matches an
agent_persona_name are moved to an ``agent_personas`` bucket
instead of being reported as people.
Passing ``llm_provider`` enables phase-2 refinement. The caller is
responsible for constructing the provider (``llm_client.get_provider``)
and confirming availability. Refinement is blocking-interactive:
progress prints to stderr; Ctrl-C returns partial results.
Passing ``corpus_origin`` enables corpus-origin persona reclassification.
The expected shape is the dict written by ``mempalace init`` to
``<palace>/.mempalace/origin.json`` (see ``corpus_origin.py``).
"""
projects, people = scan(project_dir)
# If the target is a Claude Code conversations root, extract per-project
# entries from there too. Same ProjectInfo shape, so dedup logic works.
from mempalace.convo_scanner import is_claude_projects_root, scan_claude_projects
root_path = Path(project_dir).expanduser().resolve()
if is_claude_projects_root(root_path):
convo_projects = scan_claude_projects(root_path)
# Dedup by name against the git-manifest list, preferring entries
# with more user_commits as signal strength. Keyed case-insensitively
# so a `pyproject.toml` name like `mempalace` and a Claude Code
# `cwd` variant like `MemPalace` collapse into one entry — matches
# the case-insensitive dedup used in `_merge_detected` and
# `miner.add_to_known_entities`.
by_name: dict[str, ProjectInfo] = {p.name.lower(): p for p in projects}
for cp in convo_projects:
key = cp.name.lower()
existing = by_name.get(key)
if existing is None or cp.user_commits > existing.user_commits:
by_name[key] = cp
projects = sorted(
by_name.values(),
key=lambda p: (not p.is_mine, -p.user_commits, -p.total_commits, p.name),
)
real_signal = to_detected_dict(projects, people, project_cap=project_cap, people_cap=people_cap)
# Secondary pass: prose-only extraction catches names mentioned in docs
# that never made a commit (e.g. a stakeholder or family member in notes).
from mempalace.entity_detector import detect_entities, scan_for_detection
prose_files = scan_for_detection(str(project_dir), max_files=prose_file_cap)
prose_detected = (
detect_entities(prose_files, languages=languages)
if prose_files
else {"people": [], "projects": [], "topics": [], "uncertain": []}
)
# Without LLM refinement, suppress regex "uncertain" noise when real
# manifest/git signal exists. With LLM refinement enabled, keep those
# candidates so the model can promote real entities or drop common words.
has_real_signal = bool(projects) or bool(people)
merged = _merge_detected(
real_signal,
prose_detected,
drop_secondary_uncertain=has_real_signal and llm_provider is None,
)
# Optional LLM refinement pass (when an llm_provider was supplied).
if llm_provider is not None:
from mempalace.llm_refine import collect_corpus_text, refine_entities
corpus = collect_corpus_text(str(project_dir))
result = refine_entities(
merged,
corpus,
llm_provider,
show_progress=show_progress,
allow_project_promotions=not has_real_signal,
corpus_origin=corpus_origin,
)
if show_progress:
status_bits = []
if result.cancelled:
status_bits.append("cancelled")
if result.reclassified:
status_bits.append(f"reclassified {result.reclassified}")
if result.dropped:
status_bits.append(f"dropped {result.dropped}")
if result.errors:
status_bits.append(f"{len(result.errors)} batch error(s)")
if status_bits:
import sys as _sys
print(f" LLM refine: {', '.join(status_bits)}", file=_sys.stderr)
merged = result.merged
# Corpus-origin persona reclassification — applied last so it sweeps
# candidates contributed by every upstream source (manifests, git authors,
# prose, LLM refinement). Idempotent: no corpus_origin → exact v3.3.3 shape.
if corpus_origin is not None:
from mempalace.entity_detector import _apply_corpus_origin
merged = _apply_corpus_origin(merged, corpus_origin)
return merged
# ==================== CLI ====================
if __name__ == "__main__":
import sys
target = sys.argv[1] if len(sys.argv) > 1 else "."
projs, ppl = scan(target)
print(f"=== PROJECTS ({len(projs)}) ===")
for p in projs[:30]:
mark = "★" if p.is_mine else " "
print(f" {mark} {p.name:35} conf={p.confidence:.2f} {p.to_signal()}")
print()
print(f"=== PEOPLE ({len(ppl)}) ===")
for p in ppl[:30]:
print(f" {p.name:30} conf={p.confidence:.2f} {p.to_signal()}")
+1183 -39
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -202,7 +202,7 @@ def detect_rooms_from_files(project_dir: str) -> list:
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
for root, dirs, filenames in os.walk(project_path):
for _root, dirs, filenames in os.walk(project_path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for filename in filenames:
name_lower = filename.lower().replace("-", "_").replace(" ", "_")
@@ -303,8 +303,10 @@ def save_config(project_dir: str, project_name: str, rooms: list):
def detect_rooms_local(project_dir: str, yes: bool = False):
"""Main entry point for local setup."""
from .config import normalize_wing_name
project_path = Path(project_dir).expanduser().resolve()
project_name = project_path.name.lower().replace(" ", "_").replace("-", "_")
project_name = normalize_wing_name(project_path.name)
if not project_path.exists():
print(f"ERROR: Directory not found: {project_dir}")
+508 -15
View File
@@ -11,7 +11,9 @@ hide drawers the direct path would have found.
import logging
import math
import os
import re
import sqlite3
from pathlib import Path
from .palace import get_closets_collection, get_collection
@@ -46,7 +48,14 @@ def _first_or_empty(results, key: str) -> list:
def _tokenize(text: str) -> list:
"""Lowercase + strip to alphanumeric tokens of length ≥ 2."""
"""Lowercase + strip to alphanumeric tokens of length ≥ 2.
Tolerates ``None`` documents — Chroma can return ``None`` in the
``documents`` field for drawers without text content, which would
otherwise raise ``AttributeError`` mid-rerank.
"""
if not text:
return []
return _TOKEN_RE.findall(text.lower())
@@ -125,6 +134,11 @@ def _hybrid_rank(
themselves. Since the absolute scale is unbounded, BM25 is min-max
normalized within the candidate set so weights are commensurable.
Candidates with ``distance=None`` are treated as vector-unknown
(no vector signal available) and scored on BM25 contribution alone.
Used by candidate-union mode to merge BM25-only candidates that the
vector index didn't surface.
Mutates each result dict to add ``bm25_score`` and reorders the list
in place. Returns the same list for convenience.
"""
@@ -138,7 +152,11 @@ def _hybrid_rank(
scored = []
for r, raw, norm in zip(results, bm25_raw, bm25_norm):
vec_sim = max(0.0, 1.0 - r.get("distance", 1.0))
distance = r.get("distance")
if distance is None:
vec_sim = 0.0
else:
vec_sim = max(0.0, 1.0 - distance)
r["bm25_score"] = round(raw, 3)
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
@@ -227,7 +245,7 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
total_drawers = len(all_meta.ids) if all_meta.ids else None
except Exception:
pass
logger.debug("total_drawers lookup failed for %s", src, exc_info=True)
return {
"text": combined_text,
@@ -236,6 +254,42 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
}
def _warn_if_legacy_metric(col) -> None:
"""Print a one-line notice if the palace was created without
``hnsw:space=cosine``.
ChromaDB's default is L2 (Euclidean), under which cosine-based
similarity interpretation falls apart — distances routinely exceed
1.0 and the display ``max(0, 1 - dist)`` floors every result to 0.
Legacy palaces (mined before this metadata was consistently set)
need ``mempalace repair`` to rebuild with the correct metric.
The warning fires only for palaces that clearly have the wrong
metric; palaces with no metadata table at all (empty dict) also
fall under this check since that is the signal of a pre-metadata
palace.
"""
try:
meta = getattr(col, "metadata", None)
except Exception:
return
if not isinstance(meta, dict):
return
space = meta.get("hnsw:space")
if space == "cosine":
return
# Either missing or set to something else — both are suspect.
import sys as _sys
detail = f"hnsw:space={space!r}" if space else "no hnsw:space metadata"
print(
f"\n NOTICE: this palace was created without cosine distance ({detail}).\n"
" Semantic similarity scores will not be meaningful.\n"
" Run `mempalace repair` to rebuild the index with the correct metric.",
file=_sys.stderr,
)
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
"""
Search the palace. Returns verbatim drawer content.
@@ -243,10 +297,14 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
"""
try:
col = get_collection(palace_path, create=False)
except Exception:
except Exception as e:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
raise SearchError(f"No palace found at {palace_path}")
raise SearchError(f"No palace found at {palace_path}") from e
# Alert the user if this palace predates hnsw:space=cosine being set on
# creation — their similarity scores will be junk until they run repair.
_warn_if_legacy_metric(col)
where = build_where_filter(wing, room)
@@ -273,6 +331,20 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
print(f'\n No results found for: "{query}"')
return
# Pure-cosine retrieval on the CLI path was missing lexical matches:
# a drawer whose text contains every query term can still score distance
# >= 1.0 against the natural-language query when the drawer is a
# mechanical artifact (directory listing, diff, log fragment) that
# embeds as file-tree noise rather than as prose about its subject.
# The MCP tool path already hybridizes BM25 with vector sim via
# `_hybrid_rank`; do the same here so CLI results match what agents
# see via `mempalace_search`.
hits = [
{"text": doc or "", "distance": float(dist), "metadata": meta or {}}
for doc, meta, dist in zip(docs, metas, dists)
]
hits = _hybrid_rank(hits, query)
print(f"\n{'=' * 60}")
print(f' Results for: "{query}"')
if wing:
@@ -281,19 +353,20 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
print(f" Room: {room}")
print(f"{'=' * 60}\n")
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
similarity = round(max(0.0, 1 - dist), 3)
meta = meta or {}
for i, hit in enumerate(hits, 1):
vec_sim = round(max(0.0, 1 - hit["distance"]), 3)
bm25 = hit.get("bm25_score", 0.0)
meta = hit["metadata"]
source = Path(meta.get("source_file", "?")).name
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
print(f" [{i}] {wing_name} / {room_name}")
print(f" Source: {source}")
print(f" Match: {similarity}")
print(f" Match: cosine={vec_sim} bm25={bm25}")
print()
# Print the verbatim text, indented
for line in doc.strip().split("\n"):
for line in hit["text"].strip().split("\n"):
print(f" {line}")
print()
print(f" {'─' * 56}")
@@ -301,6 +374,356 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
print()
def _bm25_only_via_sqlite(
query: str,
palace_path: str,
wing: str = None,
room: str = None,
n_results: int = 5,
max_candidates: int = 500,
_include_internal: bool = False,
collection_name: str = None,
) -> dict:
"""BM25-only search reading drawers directly from chroma.sqlite3.
Used when HNSW is diverged or unloadable (#1222). Bypasses chromadb's
Python client entirely so a corrupt vector segment can't segfault the
MCP server. Routes through chromadb's own FTS5 trigram index
(``embedding_fulltext_search``) for candidate selection, then re-ranks
with the same Okapi-BM25 used in :func:`_hybrid_rank` so the result
shape matches the vector path.
The query is split into ≥3-char trigram-tokens and OR-joined for the
FTS5 MATCH — chromadb writes the index with ``tokenize='trigram'``,
so single-character tokens never match. When no usable token survives
(e.g. "is a"), candidate selection falls back to the most-recent
``max_candidates`` rows so we still return *something* rather than
nothing.
"""
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
return {
"error": "No palace found",
"hint": "Run: mempalace init <dir> && mempalace mine <dir>",
}
if collection_name is None:
from .config import get_configured_collection_name
collection_name = get_configured_collection_name()
def _metadata_filter_sql(row_id_expr: str) -> tuple[str, list[str]]:
clauses = []
params = []
for key, value in (("wing", wing), ("room", room)):
if not value:
continue
clauses.append(
f"""
AND EXISTS (
SELECT 1
FROM embedding_metadata mf
WHERE mf.id = {row_id_expr}
AND mf.key = ?
AND COALESCE(
mf.string_value,
CAST(mf.int_value AS TEXT),
CAST(mf.float_value AS TEXT),
CAST(mf.bool_value AS TEXT)
) = ?
)
"""
)
params.extend([key, value])
return "".join(clauses), params
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error as e:
return {"error": f"sqlite open failed: {e}"}
try:
# FTS5 MATCH expects whitespace-separated tokens. Drop tokens
# shorter than 3 chars (trigram tokenizer can't match them).
tokens = [t for t in _tokenize(query) if len(t) >= 3]
candidate_ids: list[int] = []
use_recency_fallback = not tokens
if tokens:
fts_query = " OR ".join(tokens)
filter_sql, filter_params = _metadata_filter_sql("embedding_fulltext_search.rowid")
try:
rows = conn.execute(
f"""
SELECT embedding_fulltext_search.rowid
FROM embedding_fulltext_search
JOIN embeddings e ON e.id = embedding_fulltext_search.rowid
JOIN segments s ON e.segment_id = s.id
JOIN collections c ON s.collection = c.id
WHERE embedding_fulltext_search MATCH ?
AND c.name = ?
{filter_sql}
LIMIT ?
""",
(fts_query, collection_name, *filter_params, max_candidates),
).fetchall()
candidate_ids = [r[0] for r in rows]
except sqlite3.Error:
# FTS5 tokenizer mismatch or syntax error — fall through
# to the recency-window selector below.
logger.debug("FTS5 MATCH failed; using recency fallback", exc_info=True)
use_recency_fallback = True
if not candidate_ids and use_recency_fallback:
# No usable FTS tokens, or FTS itself failed — pull the most
# recent rows for the drawers segment so we can BM25-rank
# something rather than return empty-handed. A clean FTS miss
# must stay empty, especially after wing/room filtering, because
# recency fallback would return unrelated scoped drawers.
# Wrapped in try/except because the schema may differ on legacy
# palaces (older chromadb without ``created_at``, missing
# ``segments`` rows after partial restore, etc.); on schema
# mismatch we fall back to ordering by primary-key id and finally
# to an empty result rather than letting search raise.
try:
filter_sql, filter_params = _metadata_filter_sql("e.id")
rows = conn.execute(
f"""
SELECT e.id
FROM embeddings e
JOIN segments s ON e.segment_id = s.id
JOIN collections c ON s.collection = c.id
WHERE c.name = ?
{filter_sql}
ORDER BY e.created_at DESC
LIMIT ?
""",
(collection_name, *filter_params, max_candidates),
).fetchall()
candidate_ids = [r[0] for r in rows]
except sqlite3.Error:
logger.debug(
"recency-window query failed; trying id-ordered fallback",
exc_info=True,
)
try:
filter_sql, filter_params = _metadata_filter_sql("e.id")
rows = conn.execute(
f"""
SELECT e.id
FROM embeddings e
JOIN segments s ON e.segment_id = s.id
JOIN collections c ON s.collection = c.id
WHERE c.name = ?
{filter_sql}
ORDER BY e.id DESC
LIMIT ?
""",
(collection_name, *filter_params, max_candidates),
).fetchall()
candidate_ids = [r[0] for r in rows]
except sqlite3.Error:
logger.debug("id-ordered fallback also failed", exc_info=True)
candidate_ids = []
if not candidate_ids:
return {
"query": query,
"filters": {"wing": wing, "room": room},
"total_before_filter": 0,
"results": [],
"fallback": "bm25_only_via_sqlite",
}
placeholders = ",".join(["?"] * len(candidate_ids))
meta_rows = conn.execute(
f"""
SELECT id, key, string_value, int_value
FROM embedding_metadata
WHERE id IN ({placeholders})
""",
candidate_ids,
).fetchall()
finally:
conn.close()
# Group metadata rows into per-drawer dicts.
drawers: dict[int, dict] = {}
for emb_id, key, sval, ival in meta_rows:
d = drawers.setdefault(emb_id, {"_id": emb_id, "metadata": {}, "text": ""})
if key == "chroma:document":
d["text"] = sval or ""
else:
d["metadata"][key] = sval if sval is not None else ival
# Apply wing/room filters in Python (FTS5 candidates may include
# entries from other wings).
candidates = []
for d in drawers.values():
meta = d["metadata"]
if wing and meta.get("wing") != wing:
continue
if room and meta.get("room") != room:
continue
full_source = meta.get("source_file", "") or ""
candidates.append(
{
"text": d["text"],
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(full_source).name if full_source else "?",
"created_at": meta.get("filed_at", "unknown"),
# No vector distance available in BM25-only mode.
"similarity": None,
"distance": None,
"matched_via": "bm25_sqlite",
# Internal: full path + chunk_index let callers (notably
# candidate_strategy="union") dedupe at chunk granularity
# rather than basename — two files in different directories
# may share a basename, and one source_file is split across
# multiple chunks. Stripped before this helper returns.
"_source_file_full": full_source,
"_chunk_index": meta.get("chunk_index"),
}
)
# Local BM25 over the candidate set.
docs = [c["text"] for c in candidates]
bm25_raw = _bm25_scores(query, docs)
max_bm25 = max(bm25_raw) if bm25_raw else 0.0
for c, raw in zip(candidates, bm25_raw):
c["bm25_score"] = round(raw, 3)
c["_score"] = (raw / max_bm25) if max_bm25 > 0 else 0.0
candidates.sort(key=lambda c: c["_score"], reverse=True)
hits = candidates[:n_results]
for h in hits:
h.pop("_score", None)
# Strip internal fields by default so the public BM25-only fallback
# response stays clean. Callers that need chunk-precise dedup
# (notably the union-merge path) opt in via _include_internal.
if not _include_internal:
h.pop("_source_file_full", None)
h.pop("_chunk_index", None)
return {
"query": query,
"filters": {"wing": wing, "room": room},
"total_before_filter": len(candidates),
"results": hits,
"fallback": "bm25_only_via_sqlite",
"fallback_reason": "vector_search_disabled",
}
def _merge_bm25_union_candidates(
hits: list,
query: str,
palace_path: str,
wing: str,
room: str,
n_results: int,
max_distance: float = 0.0,
) -> None:
"""Append top-K BM25-only candidates from sqlite into ``hits`` in place.
Used by ``search_memories(..., candidate_strategy="union")`` to widen
the rerank pool's *source* (not just its size) — vector-only candidate
selection skips docs whose embeddings are far from the query even when
BM25 signal is strong.
Dedup is chunk-precise: the key is ``(_source_file_full, _chunk_index)``
so two files sharing a basename in different directories don't collide,
and a vector hit on chunk N of a file doesn't block BM25 from
contributing chunk M of the same file. Falls back to ``source_file``
only when full-path/chunk metadata is absent.
BM25-only additions carry ``distance=None`` so ``_hybrid_rank`` scores
them on BM25 contribution alone.
When ``max_distance > 0.0`` (a strict vector-distance threshold is
set), BM25-only candidates are skipped entirely — they have no vector
distance to satisfy the threshold, and silently injecting them would
break the existing ``max_distance`` guarantee that hybrid results lie
within the requested vector-distance bound.
"""
if max_distance > 0.0:
return
try:
bm25_extra = _bm25_only_via_sqlite(
query,
palace_path,
wing=wing,
room=room,
n_results=n_results * 3,
_include_internal=True,
).get("results", [])
except Exception:
logger.debug("candidate_strategy=union: BM25 fetch failed", exc_info=True)
return
def _dedup_key(entry: dict):
full = entry.get("_source_file_full")
ci = entry.get("_chunk_index")
if full and ci is not None:
return (full, ci)
# Fall back to basename only when richer metadata is missing —
# avoids silently dropping candidates on legacy data while still
# giving chunk-precise dedup whenever the metadata is present.
return entry.get("source_file")
seen = {_dedup_key(h) for h in hits}
for bh in bm25_extra:
key = _dedup_key(bh)
if not key or key == "?" or key in seen:
continue
bh["distance"] = None
bh["effective_distance"] = None
bh["closet_boost"] = 0.0
hits.append(bh)
seen.add(key)
# Strategy dispatch — keeps search_memories' branch count under the
# project's complexity ceiling (C901 max-complexity=25). New strategies
# register here.
_CANDIDATE_MERGERS = {
"vector": None, # default no-op
"union": _merge_bm25_union_candidates,
}
def _validate_candidate_strategy(strategy: str) -> None:
"""Raise ``ValueError`` for unknown strategies.
Called eagerly at the top of ``search_memories`` so invalid values
fail consistently regardless of whether the call routes through the
vector path, the BM25-only fallback, or returns an early error dict.
"""
if strategy not in _CANDIDATE_MERGERS:
raise ValueError(
f"candidate_strategy must be one of {tuple(_CANDIDATE_MERGERS)}, got {strategy!r}"
)
def _apply_candidate_strategy(
strategy: str,
hits: list,
query: str,
palace_path: str,
wing: str,
room: str,
n_results: int,
max_distance: float = 0.0,
) -> None:
"""Dispatch to the registered merger for ``strategy``.
Strategy validity is assumed (``_validate_candidate_strategy`` runs
earlier); ``"vector"`` is a no-op.
"""
merger = _CANDIDATE_MERGERS[strategy]
if merger is not None:
merger(hits, query, palace_path, wing, room, n_results, max_distance=max_distance)
def search_memories(
query: str,
palace_path: str,
@@ -308,6 +731,9 @@ def search_memories(
room: str = None,
n_results: int = 5,
max_distance: float = 0.0,
vector_disabled: bool = False,
candidate_strategy: str = "vector",
collection_name: str = None,
) -> dict:
"""Programmatic search — returns a dict instead of printing.
@@ -323,9 +749,46 @@ def search_memories(
cosine distance (hnsw:space=cosine) — 0 = identical, 2 = opposite.
Results with distance > this value are filtered out. A value of
0.0 disables filtering. Typical useful range: 0.3–1.0.
vector_disabled: When True, route to the sqlite-only BM25 fallback
(#1222). Set by the MCP server when the HNSW capacity probe
detects a divergence that would segfault chromadb on segment
load.
candidate_strategy: How candidates for the hybrid re-rank are gathered.
* ``"vector"`` (default) — preserves historical behavior: top
``n_results * 3`` rows from the vector index are the rerank pool.
Cheap; works well when query and target docs agree in the
embedding space.
* ``"union"`` — also pull top ``n_results * 3`` BM25 candidates
from the sqlite FTS5 index and merge them into the rerank pool
(deduped by source_file). Catches docs with strong BM25 signal
that are vector-distant from the query (e.g. terminology guides
looked up by narrative-shaped queries; policy clauses surfaced
by scenario descriptions). Adds one sqlite open + FTS5 MATCH
per query; perf cost is small but unmeasured at corpus scale.
Opt in until the cost is characterized.
When ``max_distance > 0.0`` is also set, BM25-only candidates
are skipped — they have no vector distance and would silently
violate the requested distance threshold.
"""
# Validate the strategy eagerly so invalid values fail the same way
# regardless of whether the call routes through the vector path or
# the BM25-only fallback below.
_validate_candidate_strategy(candidate_strategy)
if vector_disabled:
return _bm25_only_via_sqlite(
query,
palace_path,
wing=wing,
room=room,
n_results=n_results,
collection_name=collection_name,
)
try:
drawers_col = get_collection(palace_path, create=False)
drawers_col = get_collection(palace_path, collection_name=collection_name, create=False)
except Exception as e:
logger.error("No palace found at %s: %s", palace_path, e)
return {
@@ -378,7 +841,8 @@ def search_memories(
if source and source not in closet_boost_by_source:
closet_boost_by_source[source] = (rank, cdist, cdoc[:200])
except Exception:
pass # no closets yet — hybrid degrades to pure drawer search
# No closets yet — hybrid degrades to pure drawer search.
logger.debug("Closet collection unavailable; using drawer-only search", exc_info=True)
# Rank-based boost. The ordinal signal ("which closet matched best") is
# more reliable than absolute distance on narrative content, where
@@ -392,6 +856,8 @@ def search_memories(
_first_or_empty(drawer_results, "metadatas"),
_first_or_empty(drawer_results, "distances"),
):
meta = meta or {}
doc = doc or ""
# Filter on raw distance before rounding to avoid precision loss.
if max_distance > 0.0 and dist > max_distance:
continue
@@ -408,7 +874,12 @@ def search_memories(
matched_via = "drawer+closet"
closet_preview = c_preview
effective_dist = dist - boost
# Clamp to the valid cosine-distance range [0, 2]. When a strong
# closet boost (up to 0.40) exceeds the raw distance, the subtraction
# can go negative — which (a) yields ``similarity > 1.0`` downstream
# and (b) makes the sort key land *below* ordinary positive distances,
# inverting the ranking so the best hybrid matches sort last.
effective_dist = max(0.0, min(2.0, dist - boost))
entry = {
"text": doc,
"wing": meta.get("wing", "unknown"),
@@ -453,6 +924,7 @@ def search_memories(
include=["documents", "metadatas"],
)
except Exception:
logger.debug("Neighbor fetch failed for %s", full_source, exc_info=True)
continue
docs = source_drawers.documents
metas_ = source_drawers.metadatas
@@ -490,8 +962,29 @@ def search_memories(
h["drawer_index"] = best_idx
h["total_drawers"] = len(ordered_docs)
# BM25 hybrid re-rank within the final candidate set.
hits = _hybrid_rank(hits, query)
# Candidate strategy hook: optionally widen the rerank pool's *source*
# before ranking. Default ("vector") is a no-op; "union" merges top-K
# BM25 candidates from sqlite. See `_apply_candidate_strategy`.
# ``max_distance`` is forwarded so union mode can refuse to inject
# BM25-only (distance=None) candidates that would silently bypass the
# caller's strict distance threshold.
_apply_candidate_strategy(
candidate_strategy,
hits,
query,
palace_path,
wing,
room,
n_results,
max_distance=max_distance,
)
# BM25 hybrid re-rank within the final candidate set, then trim back
# to the requested size. Without the trim, ``candidate_strategy="union"``
# would return up to 4× ``n_results`` (vector hits + BM25 union pool),
# breaking the existing ``search_memories`` size contract that the MCP
# ``limit`` parameter is built on.
hits = _hybrid_rank(hits, query)[:n_results]
for h in hits:
h.pop("_sort_key", None)
h.pop("_source_file_full", None)
+321
View File
@@ -0,0 +1,321 @@
"""
sync.py — Gitignore-aware drawer prune (#1252).
Removes drawers whose source files are now gitignored, deleted, or moved
out of the project. Reuses the same GitignoreMatcher infrastructure that
the miner uses on the way in, so the same rules that block ingest also
drive the corresponding cleanup.
Usage:
from mempalace.sync import sync_palace
report = sync_palace(palace_path, project_dirs=["/repo"], dry_run=True)
"""
import logging
from collections import defaultdict
from pathlib import Path
from typing import Callable, Optional, TypedDict
from .miner import is_gitignored, load_gitignore_matcher
from .palace import (
MineAlreadyRunning,
get_closets_collection,
get_collection,
mine_palace_lock,
)
logger = logging.getLogger(__name__)
_BATCH = 1000
class SyncReport(TypedDict):
scanned: int
kept: int
gitignored: int
missing: int
no_source: int
out_of_scope: int
removed_drawers: int
removed_closets: int
dry_run: bool
by_source: dict[str, int]
def _resolve_project_root(source_file: Path, project_roots: list) -> Optional[Path]:
"""Return the longest project_root that source_file lives under.
Assumes ``project_roots`` is sorted by path-length descending so the
first match is the longest (deepest) prefix.
"""
for root in project_roots:
try:
source_file.relative_to(root)
return root
except ValueError:
continue
return None
def _ancestor_matchers(source_file: Path, root: Path, matcher_cache: dict) -> list:
"""Build the ancestor-chain matcher list, root → file's parent.
Callers are expected to invoke this only after `_resolve_project_root`
confirms `source_file` lives under `root`. The defensive try/except
keeps the function safe if a future caller skips that check.
"""
matchers: list = []
try:
parts = source_file.relative_to(root).parts
except ValueError:
return matchers
cursor = root
matcher = load_gitignore_matcher(cursor, matcher_cache)
if matcher is not None:
matchers.append(matcher)
for part in parts[:-1]:
cursor = cursor / part
matcher = load_gitignore_matcher(cursor, matcher_cache)
if matcher is not None:
matchers.append(matcher)
return matchers
def _is_registry_row(meta: dict, drawer_id: str) -> bool:
"""Convo miner sentinels track 'have I seen this transcript' — preserve them.
Deleting a `_reg_*` sentinel makes the next mine pass re-chunk and re-embed
the entire transcript even though its content has not changed.
"""
if (meta or {}).get("room") == "_registry":
return True
if (meta or {}).get("ingest_mode") == "registry":
return True
if drawer_id and drawer_id.startswith("_reg_"):
return True
return False
def _classify_drawer(
meta: dict, matcher_cache: dict, project_roots: list, drawer_id: str = ""
) -> str:
"""Classify a drawer by its source_file metadata.
Returns one of: kept, gitignored, missing, no_source, out_of_scope.
"""
# Defensive: main loop filters registry rows; this guards direct callers.
if _is_registry_row(meta, drawer_id):
return "kept"
source_file = (meta or {}).get("source_file")
if not source_file:
return "no_source"
src = Path(source_file)
if not src.is_absolute():
return "no_source"
src = src.resolve(strict=False)
root = _resolve_project_root(src, project_roots)
if root is None:
return "out_of_scope"
if not src.exists():
return "missing"
matchers = _ancestor_matchers(src, root, matcher_cache)
if matchers and is_gitignored(src, matchers, is_dir=False):
return "gitignored"
return "kept"
def _iter_drawer_metadata(col, wing: Optional[str]):
"""Yield (id, metadata) tuples from the drawers collection in batches."""
offset = 0
where = {"wing": wing} if wing else None
while True:
kwargs = {"include": ["metadatas"], "limit": _BATCH, "offset": offset}
if where:
kwargs["where"] = where
batch = col.get(**kwargs)
ids = batch.get("ids") or []
metas = batch.get("metadatas") or []
if not ids:
return
for drawer_id, meta in zip(ids, metas):
yield drawer_id, meta
if len(ids) < _BATCH:
return
offset += len(ids)
def _auto_detect_project_roots(col, wing: Optional[str]) -> list:
"""Walk drawer metadata once collecting candidate project roots.
A path is a project root if any ancestor up to filesystem root holds
a `.git` directory or a `.gitignore` file. The deepest such ancestor
wins, so nested-but-still-tracked subprojects are honoured.
`Path.parents` iterates deepest-first, so the first hit IS deepest.
Dedupes on ``source_file`` string so a 200-chunk file costs one disk
walk, not 200.
"""
roots: set = set()
seen_sources: set = set()
for _, meta in _iter_drawer_metadata(col, wing):
source_file = (meta or {}).get("source_file")
if not source_file or source_file in seen_sources:
continue
seen_sources.add(source_file)
src = Path(source_file)
if not src.is_absolute():
continue
for parent in src.parents:
if (parent / ".git").exists() or (parent / ".gitignore").is_file():
roots.add(parent.resolve(strict=False))
break
return sorted(roots, key=lambda p: (-len(str(p)), str(p)))
def _normalize_project_dirs(project_dirs) -> list:
"""Resolve and sort project dirs so deepest-prefix wins on first match."""
resolved = [Path(p).resolve(strict=False) for p in project_dirs]
return sorted(resolved, key=lambda p: (-len(str(p)), str(p)))
def _delete_in_batches(col, ids: list, batch_size: int, wal_log: Optional[Callable]):
"""Delete drawer IDs in batches, optionally logging each batch to WAL."""
deleted = 0
for i in range(0, len(ids), batch_size):
chunk = ids[i : i + batch_size]
col.delete(ids=chunk)
deleted += len(chunk)
if wal_log is not None:
wal_log(
"sync_prune",
{"first_id": chunk[0]},
{"removed_count": len(chunk)},
)
return deleted
def sync_palace(
palace_path: str,
project_dirs: Optional[list] = None,
wing: Optional[str] = None,
dry_run: bool = True,
batch_size: int = _BATCH,
wal_log: Optional[Callable] = None,
) -> SyncReport:
"""Prune drawers whose source files are gitignored, missing, or moved.
Returns a SyncReport with bucket counts. Dry-run by default; pass
dry_run=False to actually delete drawers and matching closets.
Holds ``mine_palace_lock`` for the whole call so the classify pass and
the apply branch see the same drawer snapshot. Raises
``MineAlreadyRunning`` if another mine is in progress on this palace.
On apply (``dry_run=False``), at least one of ``wing`` or
``project_dirs`` must be set so a caller cannot accidentally prune
every wing in a multi-project palace via auto-detected roots.
"""
if not dry_run and not wing and not project_dirs:
raise ValueError(
"sync apply requires explicit wing= or project_dirs= so it cannot "
"auto-prune every wing in a multi-project palace; pass --wing or "
"a project directory"
)
if project_dirs is not None and not project_dirs:
raise ValueError(
"project_dirs was provided but is empty; pass at least one project "
"root or pass project_dirs=None to auto-detect from drawer metadata"
)
counts = {
"scanned": 0,
"kept": 0,
"gitignored": 0,
"missing": 0,
"no_source": 0,
"out_of_scope": 0,
}
by_source: dict = defaultdict(int)
removable_ids: list = []
removable_sources: set = set()
with mine_palace_lock(palace_path):
col = get_collection(palace_path, create=False)
if project_dirs is not None:
roots = _normalize_project_dirs(project_dirs)
else:
roots = _auto_detect_project_roots(col, wing)
matcher_cache: dict = {}
# Same source_file → same verdict holds because mine_palace_lock
# blocks concurrent writers and the loop is synchronous.
classification_cache: dict = {}
for drawer_id, meta in _iter_drawer_metadata(col, wing):
counts["scanned"] += 1
meta = meta or {}
source_file = meta.get("source_file")
if _is_registry_row(meta, drawer_id):
bucket = "kept"
elif source_file and source_file in classification_cache:
bucket = classification_cache[source_file]
else:
bucket = _classify_drawer(meta, matcher_cache, roots, drawer_id)
if source_file:
classification_cache[source_file] = bucket
counts[bucket] += 1
if bucket in ("gitignored", "missing"):
removable_ids.append(drawer_id)
if source_file:
removable_sources.add(source_file)
by_source[source_file] += 1
report: SyncReport = {
**counts,
"removed_drawers": 0,
"removed_closets": 0,
"dry_run": dry_run,
"by_source": dict(by_source),
}
if dry_run or not removable_ids:
return report
report["removed_drawers"] = _delete_in_batches(col, removable_ids, batch_size, wal_log)
closets_col = None
try:
closets_col = get_closets_collection(palace_path, create=False)
except Exception as exc:
logger.warning("Closet purge skipped (collection unavailable): %s", exc)
closets_removed = 0
if closets_col is not None and removable_sources:
closet_ids = (
closets_col.get(
where={"source_file": {"$in": list(removable_sources)}},
include=[],
).get("ids")
or []
)
if closet_ids:
closets_col.delete(ids=closet_ids)
closets_removed = len(closet_ids)
report["removed_closets"] = closets_removed
return report
__all__ = [
"MineAlreadyRunning",
"SyncReport",
"sync_palace",
]
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""
__version__ = "3.3.2"
__version__ = "3.3.5"
+1
View File
@@ -0,0 +1 @@
OpenArena owner claim verification for MemPalace/mempalace: 09AE2C2E66CC4B5CBD7D
+11 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.3.2"
version = "3.3.5"
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"
@@ -29,6 +29,7 @@ classifiers = [
dependencies = [
"chromadb>=1.5.4,<2",
"pyyaml>=6.0,<7",
"tomli>=2.0.0; python_version < '3.11'",
]
[project.urls]
@@ -38,6 +39,7 @@ Repository = "https://github.com/MemPalace/mempalace"
[project.scripts]
mempalace = "mempalace.cli:main"
mempalace-mcp = "mempalace.mcp_server:main"
[project.entry-points."mempalace.backends"]
chroma = "mempalace.backends.chroma:ChromaBackend"
@@ -51,6 +53,14 @@ chroma = "mempalace.backends.chroma:ChromaBackend"
[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
spellcheck = ["autocorrect>=2.0"]
# Hardware acceleration for the ONNX embedding model. Install exactly one:
# pip install mempalace[gpu] — NVIDIA CUDA
# pip install mempalace[dml] — DirectML (Windows AMD/Intel/NVIDIA)
# pip install mempalace[coreml] — macOS Neural Engine
# After install, set MEMPALACE_EMBEDDING_DEVICE=cuda|dml|coreml (or "auto").
gpu = ["onnxruntime-gpu>=1.16"]
dml = ["onnxruntime-directml>=1.16"]
coreml = ["onnxruntime>=1.16"]
[dependency-groups]
dev = ["pytest>=7.0", "pytest-cov>=4.0", "ruff>=0.4.0", "psutil>=5.9"]
+2 -1
View File
@@ -40,8 +40,9 @@ def _patch_mcp_config(monkeypatch, palace_path, tmp_path):
import mempalace.mcp_server as mcp_mod
kg = KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3"))
monkeypatch.setattr(mcp_mod, "_config", cfg)
monkeypatch.setattr(mcp_mod, "_kg", KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3")))
monkeypatch.setattr(mcp_mod, "_get_kg", lambda: kg)
def _get_rss_mb():
+2 -1
View File
@@ -84,8 +84,9 @@ class TestToolStatusMemoryProfile:
cfg = MempalaceConfig(config_dir=str(tmp_path / "cfg"))
monkeypatch.setattr(cfg, "_file_config", {"palace_path": palace_path})
kg = KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3"))
monkeypatch.setattr(mcp_mod, "_config", cfg)
monkeypatch.setattr(mcp_mod, "_kg", KnowledgeGraph(db_path=str(tmp_path / "kg.sqlite3")))
monkeypatch.setattr(mcp_mod, "_get_kg", lambda: kg)
from mempalace.mcp_server import tool_status
+31 -4
View File
@@ -36,14 +36,41 @@ from mempalace.knowledge_graph import KnowledgeGraph # noqa: E402
@pytest.fixture(autouse=True)
def _reset_mcp_cache():
"""Reset the MCP server's cached ChromaDB client/collection between tests."""
"""Reset cached MCP state between tests without importing mcp_server.
If mempalace.mcp_server is already imported, close/clear its KG cache and
Chroma client cache. If it has not been imported, leave it unloaded so
fork/spawn-based tests do not inherit extra Chroma/SQLite state.
"""
def _clear_cache():
try:
from mempalace import mcp_server
import sys
mcp_server._client_cache = None
mcp_server._collection_cache = None
mcp_server = sys.modules.get("mempalace.mcp_server")
if mcp_server is not None:
for kg in list(getattr(mcp_server, "_kg_by_path", {}).values()):
close = getattr(kg, "close", None)
if close is not None:
try:
close()
except Exception:
pass
if hasattr(mcp_server, "_kg_by_path"):
mcp_server._kg_by_path.clear()
mcp_server._client_cache = None
mcp_server._collection_cache = None
except AttributeError:
pass
try:
# Reset the per-process quarantine gate so tests don't leak
# state through ChromaBackend._quarantined_paths.
from mempalace.backends.chroma import ChromaBackend
ChromaBackend._quarantined_paths.clear()
except (ImportError, AttributeError):
pass
+845 -32
View File
@@ -1,5 +1,9 @@
import os
import pickle
import shutil
import sqlite3
from contextlib import closing
from pathlib import Path
import chromadb
import pytest
@@ -15,7 +19,11 @@ from mempalace.backends import (
from mempalace.backends.chroma import (
ChromaBackend,
ChromaCollection,
_HNSW_MISSING_METADATA_DATA_FLOOR,
_fix_blob_seq_ids,
_pin_hnsw_threads,
_segment_appears_healthy,
quarantine_invalid_hnsw_metadata,
quarantine_stale_hnsw,
)
@@ -204,6 +212,52 @@ def test_query_empty_preserves_embeddings_outer_shape_when_requested():
assert not_requested.embeddings is None
def test_chroma_close_palace_releases_sqlite_lock_for_reopen(tmp_path):
"""close_palace must release chromadb's rust-side SQLite file lock so
a fresh PersistentClient on the same path after shutil.rmtree can
write without hitting SQLITE_READONLY_DBMOVED."""
backend = ChromaBackend()
palace_path = tmp_path / "palace-a"
ref = PalaceRef(id=str(palace_path), local_path=str(palace_path))
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
col.upsert(documents=["hello"], ids=["a"], metadatas=[{"k": "v"}])
backend.close_palace(ref)
shutil.rmtree(palace_path)
col = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
col.upsert(documents=["world"], ids=["b"], metadatas=[{"k": "v2"}])
assert col.count() == 1
def test_chroma_close_releases_all_cached_clients(tmp_path):
"""close() must release every cached client's SQLite file lock so any
of their palace paths can be reopened by a fresh backend in the same
process."""
backend = ChromaBackend()
palace_a = tmp_path / "palace-a"
palace_b = tmp_path / "palace-b"
ref_a = PalaceRef(id=str(palace_a), local_path=str(palace_a))
ref_b = PalaceRef(id=str(palace_b), local_path=str(palace_b))
for ref in (ref_a, ref_b):
backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True).upsert(
documents=["x"], ids=["x"], metadatas=[{"k": "v"}]
)
backend.close()
for path in (palace_a, palace_b):
shutil.rmtree(path)
ref = PalaceRef(id=str(path), local_path=str(path))
fresh = ChromaBackend()
col = fresh.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
col.upsert(documents=["y"], ids=["y"], metadatas=[{"k": "v2"}])
assert col.count() == 1
fresh.close()
def test_chroma_cache_invalidates_when_db_file_missing(tmp_path):
"""A palace rebuild that removes chroma.sqlite3 must drop the stale cache.
@@ -334,45 +388,98 @@ def test_chroma_backend_creates_collection_with_cosine_distance(tmp_path):
assert col.metadata.get("hnsw:space") == "cosine"
def test_chroma_backend_sets_hnsw_bloat_guard_on_creation(tmp_path):
"""The HNSW guard from #344 must land on freshly-created collection metadata.
Without batch_size + sync_threshold, mining ~10K+ drawers triggers the
resize+persist drift that bloats link_lists.bin into hundreds of GB sparse
and segfaults `status` / `search` / `repair`. The guard belongs at
collection-creation time so every fresh palace gets it without needing
a runtime retrofit. Asserting both keys land on the persisted metadata
also covers the #1161 "config silently dropped" concern at CI time.
"""
palace_path = tmp_path / "palace"
ChromaBackend().get_collection(
str(palace_path),
collection_name="mempalace_drawers",
create=True,
)
client = chromadb.PersistentClient(path=str(palace_path))
col = client.get_collection("mempalace_drawers")
assert col.metadata.get("hnsw:batch_size") == 50_000
assert col.metadata.get("hnsw:sync_threshold") == 50_000
def test_chroma_backend_create_collection_sets_hnsw_bloat_guard(tmp_path):
"""Same guard must apply via the legacy create_collection() path."""
palace_path = tmp_path / "palace"
ChromaBackend().create_collection(str(palace_path), "mempalace_drawers")
client = chromadb.PersistentClient(path=str(palace_path))
col = client.get_collection("mempalace_drawers")
assert col.metadata.get("hnsw:batch_size") == 50_000
assert col.metadata.get("hnsw:sync_threshold") == 50_000
def test_get_collection_create_true_is_idempotent(tmp_path):
"""Calling get_collection(create=True) twice on the same name must not crash.
ChromaDB 1.5.x's Rust bindings SIGSEGV when get_or_create_collection is
called with metadata that differs from the stored collection metadata. The
fix splits the call into get_collection -> fallback create_collection so the
metadata-comparison codepath in chromadb_rust_bindings is never reached for
existing collections. Regression guard for issue #1089.
"""
palace = str(tmp_path / "palace")
backend = ChromaBackend()
backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
col2 = backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
assert isinstance(col2, ChromaCollection)
def test_get_collection_create_true_preserves_existing_metadata(tmp_path):
"""Existing collection metadata is not overwritten when reopened with create=True."""
palace = str(tmp_path / "palace")
backend = ChromaBackend()
backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
col = backend.get_collection(palace, collection_name="mempalace_drawers", create=True)
assert col._collection.metadata["hnsw:space"] == "cosine"
assert col._collection.metadata.get("hnsw:batch_size") == 50_000
def test_fix_blob_seq_ids_converts_blobs_to_integers(tmp_path):
"""Simulate a ChromaDB 0.6.x database with BLOB seq_ids and verify repair."""
db_path = tmp_path / "chroma.sqlite3"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
conn.execute("CREATE TABLE max_seq_id (rowid INTEGER PRIMARY KEY, seq_id)")
# Insert BLOB seq_ids like ChromaDB 0.6.x would
blob_42 = (42).to_bytes(8, byteorder="big")
blob_99 = (99).to_bytes(8, byteorder="big")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (blob_42,))
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (blob_99,))
conn.commit()
conn.close()
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
# Insert BLOB seq_id like ChromaDB 0.6.x would
blob_42 = (42).to_bytes(8, byteorder="big")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (blob_42,))
conn.commit()
_fix_blob_seq_ids(str(tmp_path))
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
assert row == (42, "integer")
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
assert row == (99, "integer")
conn.close()
with closing(sqlite3.connect(str(db_path))) as conn:
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
assert row == (42, "integer")
def test_fix_blob_seq_ids_noop_without_blobs(tmp_path):
"""No error when seq_ids are already integers."""
db_path = tmp_path / "chroma.sqlite3"
conn = sqlite3.connect(str(db_path))
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
conn.commit()
conn.close()
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
conn.commit()
_fix_blob_seq_ids(str(tmp_path))
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
assert row == (42, "integer")
conn.close()
with closing(sqlite3.connect(str(db_path))) as conn:
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
assert row == (42, "integer")
def test_fix_blob_seq_ids_noop_without_database(tmp_path):
@@ -380,39 +487,276 @@ def test_fix_blob_seq_ids_noop_without_database(tmp_path):
_fix_blob_seq_ids(str(tmp_path)) # should not raise
def test_fix_blob_seq_ids_does_not_touch_max_seq_id(tmp_path):
"""chromadb 1.5.x owns max_seq_id; the shim must not interpret its BLOBs.
Regression guard for the 2026-04-20 incident: the old shim ran
int.from_bytes(..., 'big') over chromadb 1.5.x's native
b'\\x11\\x11' + ASCII-digit BLOB, producing a ~1.23e18 integer that
silently suppressed every subsequent embeddings_queue write.
"""
db_path = tmp_path / "chroma.sqlite3"
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
conn.execute("CREATE TABLE max_seq_id (rowid INTEGER PRIMARY KEY, seq_id)")
sysdb10_blob = b"\x11\x11502607"
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (sysdb10_blob,))
conn.commit()
_fix_blob_seq_ids(str(tmp_path))
with closing(sqlite3.connect(str(db_path))) as conn:
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
assert row == (sysdb10_blob, "blob")
def test_fix_blob_seq_ids_skips_sysdb10_prefix_in_embeddings(tmp_path):
"""Defense-in-depth: sysdb-10 prefix in embeddings.seq_id is skipped."""
db_path = tmp_path / "chroma.sqlite3"
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
sysdb10_blob = b"\x11\x11502607"
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (sysdb10_blob,))
conn.commit()
_fix_blob_seq_ids(str(tmp_path))
with closing(sqlite3.connect(str(db_path))) as conn:
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM embeddings").fetchone()
# Still a BLOB — not converted to 1.23e18.
assert row == (sysdb10_blob, "blob")
def test_fix_blob_seq_ids_still_converts_legacy_blobs_in_embeddings(tmp_path):
"""Regression guard: pure big-endian u64 BLOBs still convert for genuine 0.6.x."""
db_path = tmp_path / "chroma.sqlite3"
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", ((42).to_bytes(8, "big"),))
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", (b"\x11\x11502607",))
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", ((7).to_bytes(8, "big"),))
conn.commit()
_fix_blob_seq_ids(str(tmp_path))
with closing(sqlite3.connect(str(db_path))) as conn:
rows = conn.execute(
"SELECT seq_id, typeof(seq_id) FROM embeddings ORDER BY rowid"
).fetchall()
assert rows[0] == (42, "integer")
assert rows[1] == (b"\x11\x11502607", "blob") # sysdb-10 row left alone
assert rows[2] == (7, "integer")
def test_fix_blob_seq_ids_writes_marker_after_blob_path(tmp_path):
"""The .blob_seq_ids_migrated marker is written after a successful BLOB → INTEGER conversion."""
from mempalace.backends.chroma import _BLOB_FIX_MARKER
db_path = tmp_path / "chroma.sqlite3"
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id)")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (?)", ((42).to_bytes(8, "big"),))
conn.commit()
marker = tmp_path / _BLOB_FIX_MARKER
assert not marker.exists()
_fix_blob_seq_ids(str(tmp_path))
assert marker.is_file(), "marker must be written after a successful migration"
def test_fix_blob_seq_ids_writes_marker_when_already_integer(tmp_path):
"""The marker is written even when the migration is a no-op (already INTEGER).
The point of the marker is to skip the sqlite3 open on subsequent calls,
not to record that a conversion happened. So a clean palace gets the
marker on first run too — next ``_fix_blob_seq_ids`` call short-circuits
before touching the sqlite3 file.
"""
from mempalace.backends.chroma import _BLOB_FIX_MARKER
db_path = tmp_path / "chroma.sqlite3"
with closing(sqlite3.connect(str(db_path))) as conn:
conn.execute("CREATE TABLE embeddings (rowid INTEGER PRIMARY KEY, seq_id INTEGER)")
conn.execute("INSERT INTO embeddings (seq_id) VALUES (42)")
conn.commit()
marker = tmp_path / _BLOB_FIX_MARKER
assert not marker.exists()
_fix_blob_seq_ids(str(tmp_path))
assert marker.is_file(), "marker must be written even when no BLOBs found"
def test_fix_blob_seq_ids_skips_sqlite_when_marker_present(tmp_path):
"""When the marker exists, ``_fix_blob_seq_ids`` does not open sqlite3.
This is the load-bearing property of the marker — opening Python's
sqlite3 against a live ChromaDB 1.5.x WAL DB corrupts the next
PersistentClient call (#1090). Once a palace has been migrated, we
never want to open it again, even read-only.
"""
from unittest.mock import patch
from mempalace.backends.chroma import _BLOB_FIX_MARKER
# Pre-create the marker so the function should short-circuit.
db_path = tmp_path / "chroma.sqlite3"
db_path.write_bytes(b"sentinel") # presence required for the function to proceed
(tmp_path / _BLOB_FIX_MARKER).touch()
with patch("mempalace.backends.chroma.sqlite3.connect") as mock_connect:
_fix_blob_seq_ids(str(tmp_path))
mock_connect.assert_not_called()
# ── quarantine_stale_hnsw ─────────────────────────────────────────────────
def _make_palace_with_segment(tmp_path, hnsw_mtime, sqlite_mtime):
"""Helper: build a palace dir with one HNSW segment + sqlite at given mtimes."""
# Marker bytes for the chromadb segment metadata file. A complete
# write begins with PROTO opcode (0x80) and ends with STOP opcode
# (0x2e); _segment_appears_healthy sniffs these bytes without parsing
# the file.
_HEALTHY_META = b"\x80\x04" + b"\x00" * 32 + b"\x2e"
_CORRUPT_META = b"\x00" * 64
def _make_palace_with_segment(tmp_path, hnsw_mtime, sqlite_mtime, meta_bytes=_HEALTHY_META):
"""Helper: build a palace dir with one HNSW segment + sqlite at given
mtimes. ``meta_bytes`` controls whether the segment looks healthy
(default), corrupt (``_CORRUPT_META``), or has no metadata file at
all (``None``)."""
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").write_text("")
seg = palace / "abcd-1234-5678"
seg.mkdir()
(seg / "data_level0.bin").write_text("")
if meta_bytes is not None:
(seg / "index_metadata.pickle").write_bytes(meta_bytes)
os.utime(seg / "data_level0.bin", (hnsw_mtime, hnsw_mtime))
os.utime(palace / "chroma.sqlite3", (sqlite_mtime, sqlite_mtime))
return palace, seg
def test_quarantine_stale_hnsw_renames_drifted_segment(tmp_path):
"""Segment whose data_level0.bin is 2h older than sqlite gets renamed."""
def test_quarantine_stale_hnsw_renames_corrupt_segment(tmp_path):
"""Segment with stale mtime AND a malformed metadata file gets renamed."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(tmp_path, hnsw_mtime=now - 7200, sqlite_mtime=now)
palace, seg = _make_palace_with_segment(
tmp_path,
hnsw_mtime=now - 7200,
sqlite_mtime=now,
meta_bytes=_CORRUPT_META,
)
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert len(moved) == 1
assert ".drift-" in moved[0]
assert not seg.exists()
# the renamed directory still exists and contains the original file
renamed = list(palace.iterdir())
drift_dirs = [p for p in renamed if ".drift-" in p.name]
assert len(drift_dirs) == 1
assert (drift_dirs[0] / "data_level0.bin").exists()
def test_quarantine_stale_hnsw_leaves_healthy_segment_with_drift_alone(tmp_path):
"""Segment with stale mtime but a complete metadata file is NOT
renamed — this is the chromadb-1.5.x async-flush steady state, not
corruption. Production case at 06:24 PDT 2026-04-26: cold-start
quarantine renamed three healthy segments after a clean shutdown,
leaving 151K-drawer palace with vector_ranked=0."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(
tmp_path,
hnsw_mtime=now - 7200,
sqlite_mtime=now,
meta_bytes=_HEALTHY_META,
)
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert moved == []
assert seg.exists()
def test_quarantine_stale_hnsw_leaves_empty_segment_without_metadata_alone(tmp_path):
"""Missing metadata is okay only when the segment has no meaningful data yet."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(
tmp_path,
hnsw_mtime=now - 7200,
sqlite_mtime=now,
meta_bytes=None,
)
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert moved == []
assert seg.exists()
def test_segment_without_metadata_but_with_nontrivial_data_is_unhealthy(tmp_path):
"""Data without index_metadata.pickle is a partial flush, not a fresh segment."""
seg = tmp_path / "abcd-1234-5678"
seg.mkdir()
(seg / "data_level0.bin").write_bytes(b"\0" * (_HNSW_MISSING_METADATA_DATA_FLOOR + 1))
assert not _segment_appears_healthy(str(seg))
def test_segment_without_metadata_and_tiny_data_is_still_treated_as_fresh(tmp_path):
"""Tiny data payloads can occur before metadata has flushed; leave them alone."""
seg = tmp_path / "abcd-1234-5678"
seg.mkdir()
(seg / "data_level0.bin").write_bytes(b"\0" * _HNSW_MISSING_METADATA_DATA_FLOOR)
assert _segment_appears_healthy(str(seg))
def test_quarantine_stale_hnsw_renames_missing_metadata_with_nontrivial_data(tmp_path):
"""Regression for #1274: missing pickle + non-trivial data must quarantine."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(
tmp_path,
hnsw_mtime=now - 7200,
sqlite_mtime=now,
meta_bytes=None,
)
(seg / "data_level0.bin").write_bytes(b"\0" * (_HNSW_MISSING_METADATA_DATA_FLOOR + 1))
os.utime(seg / "data_level0.bin", (now - 7200, now - 7200))
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert len(moved) == 1
assert ".drift-" in moved[0]
assert not seg.exists()
drift_dirs = [p for p in palace.iterdir() if ".drift-" in p.name]
assert len(drift_dirs) == 1
assert (drift_dirs[0] / "data_level0.bin").exists()
def test_quarantine_stale_hnsw_renames_truncated_metadata(tmp_path):
"""Segment with a truncated (under-floor-size) metadata file is
quarantined — shape of a partial-flush during process kill."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(
tmp_path,
hnsw_mtime=now - 7200,
sqlite_mtime=now,
meta_bytes=b"\x80\x04",
)
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert len(moved) == 1
assert ".drift-" in moved[0]
def test_quarantine_stale_hnsw_leaves_fresh_segment_alone(tmp_path):
"""Segment with recent mtime vs sqlite is not touched."""
"""Segment with recent mtime vs sqlite is not touched (mtime gate
short-circuits before integrity gate)."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(tmp_path, hnsw_mtime=now - 10, sqlite_mtime=now)
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
@@ -443,3 +787,472 @@ def test_quarantine_stale_hnsw_skips_already_quarantined(tmp_path):
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert moved == []
assert drift.exists()
# ── make_client cold-start gate ──────────────────────────────────────────
def test_make_client_quarantines_only_on_first_call_per_palace(tmp_path, monkeypatch):
"""Quarantine fires on first ``make_client()`` for a palace, then is
skipped on subsequent calls — prevents runtime thrash where a daemon's
own steady writes bump ``chroma.sqlite3`` faster than HNSW flushes,
making the mtime heuristic falsely trigger every reconnect.
Invalid metadata quarantine shares the same cold-start gate here; the
more aggressive refresh path lives in ``_client()``."""
from mempalace.backends.chroma import ChromaBackend
palace_path = str(tmp_path / "palace")
os.makedirs(palace_path, exist_ok=True)
(Path(palace_path) / "chroma.sqlite3").write_text("")
# Reset the per-process cache so this test is independent of others.
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
calls: list[str] = []
def _spy(path, stale_seconds=300.0):
calls.append(path)
return []
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _spy)
ChromaBackend.make_client(palace_path)
ChromaBackend.make_client(palace_path)
ChromaBackend.make_client(palace_path)
assert calls == [
palace_path
], "quarantine_stale_hnsw should fire once per palace per process, not on every reconnect"
def test_make_client_gates_invalid_metadata_on_first_call(tmp_path, monkeypatch):
"""Invalid metadata quarantine is gated on the first make_client() call."""
from mempalace.backends.chroma import ChromaBackend
palace_path = str(tmp_path / "palace")
os.makedirs(palace_path, exist_ok=True)
(Path(palace_path) / "chroma.sqlite3").write_text("")
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
calls: list[str] = []
def _invalid(path, *args, **kwargs):
calls.append(path)
return []
def _stale(path, stale_seconds=300.0):
return []
monkeypatch.setattr("mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _invalid)
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _stale)
ChromaBackend.make_client(palace_path)
ChromaBackend.make_client(palace_path)
assert calls == [palace_path]
def test_make_client_quarantines_each_palace_independently(tmp_path, monkeypatch):
"""Two distinct palaces each get one quarantine attempt — the gate is
keyed by palace path, not global."""
from mempalace.backends.chroma import ChromaBackend
palace_a = str(tmp_path / "palace_a")
palace_b = str(tmp_path / "palace_b")
for p in (palace_a, palace_b):
os.makedirs(p, exist_ok=True)
(Path(p) / "chroma.sqlite3").write_text("")
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
calls: list[str] = []
def _spy(path, stale_seconds=300.0):
calls.append(path)
return []
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _spy)
ChromaBackend.make_client(palace_a)
ChromaBackend.make_client(palace_b)
ChromaBackend.make_client(palace_a) # already gated
ChromaBackend.make_client(palace_b) # already gated
assert calls == [palace_a, palace_b]
# ── _client() cold-start gate (#1121, #1132, #1263) ──────────────────────
def test_client_quarantines_corrupt_segment_on_first_open(tmp_path, monkeypatch):
"""The instance ``_client()`` path must run ``quarantine_stale_hnsw``
on first open, mirroring the ``make_client()`` static helper. Before
PR #1173's wiring was extended here, CLI mining / search / repair /
status all skipped the quarantine pass and would SIGSEGV on a stale
HNSW segment (#1121, #1132, #1263)."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(
tmp_path,
hnsw_mtime=now - 7200,
sqlite_mtime=now,
meta_bytes=_CORRUPT_META,
)
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
backend = ChromaBackend()
try:
backend._client(str(palace))
finally:
backend.close()
assert not seg.exists(), "_client() should have quarantined the corrupt segment"
drift_dirs = [p for p in palace.iterdir() if ".drift-" in p.name]
assert len(drift_dirs) == 1
def test_client_quarantines_only_on_first_call_per_palace(tmp_path, monkeypatch):
"""Repeated ``_client()`` calls for the same palace re-run quarantine
at most once — the ``_quarantined_paths`` gate prevents runtime
thrash on hot paths (``_client()`` is hit on every backend op)."""
palace_path = str(tmp_path / "palace")
os.makedirs(palace_path, exist_ok=True)
(Path(palace_path) / "chroma.sqlite3").write_text("")
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
calls: list[str] = []
def _spy(path, stale_seconds=300.0):
calls.append(path)
return []
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _spy)
backend = ChromaBackend()
try:
backend._client(palace_path)
backend._client(palace_path)
backend._client(palace_path)
finally:
backend.close()
assert (
calls == [palace_path]
), "quarantine_stale_hnsw should fire once per palace per process from _client(), not on every call"
# ── _pin_hnsw_threads (per-process retrofit, separate from this PR's gate) ──
def test_pin_hnsw_threads_retrofits_legacy_collection(tmp_path):
"""Legacy collections (created without num_threads) get the retrofit applied."""
palace_path = tmp_path / "legacy-palace"
palace_path.mkdir()
client = chromadb.PersistentClient(path=str(palace_path))
col = client.create_collection(
"mempalace_drawers",
metadata={"hnsw:space": "cosine"}, # no num_threads — legacy
)
assert col.configuration_json.get("hnsw", {}).get("num_threads") is None
_pin_hnsw_threads(col)
assert col.configuration_json["hnsw"]["num_threads"] == 1
def test_pin_hnsw_threads_swallows_all_errors():
"""Retrofit never raises even when collection.modify explodes."""
class _ExplodingCollection:
def modify(self, *args, **kwargs):
raise RuntimeError("boom")
_pin_hnsw_threads(_ExplodingCollection()) # must not raise
def test_get_collection_applies_retrofit_on_existing_palace(tmp_path):
"""ChromaBackend.get_collection(create=False) applies the retrofit."""
palace_path = tmp_path / "palace"
palace_path.mkdir()
# Simulate a legacy palace: create collection without num_threads
bootstrap_client = chromadb.PersistentClient(path=str(palace_path))
bootstrap_client.create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
del bootstrap_client # drop reference so a fresh client reopens cleanly
wrapper = ChromaBackend().get_collection(
str(palace_path),
collection_name="mempalace_drawers",
create=False,
)
assert wrapper._collection.configuration_json["hnsw"]["num_threads"] == 1
def test_quarantine_invalid_hnsw_metadata_renames_missing_dimensionality(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
with open(seg / "index_metadata.pickle", "wb") as f:
pickle.dump({"dimensionality": None, "id_to_label": {"a": 1}}, f)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert len(moved) == 1
assert ".corrupt-" in moved[0]
assert not seg.exists()
def test_quarantine_invalid_hnsw_metadata_allows_uninitialized_segment(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
with open(seg / "index_metadata.pickle", "wb") as f:
pickle.dump({"dimensionality": None, "id_to_label": {}}, f)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert moved == []
assert seg.exists()
def test_quarantine_invalid_hnsw_metadata_rejects_non_dict_id_to_label(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
with open(seg / "index_metadata.pickle", "wb") as f:
pickle.dump({"dimensionality": 8, "id_to_label": ["a", "b"]}, f)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert len(moved) == 1
assert ".corrupt-" in moved[0]
assert not seg.exists()
def test_quarantine_invalid_hnsw_metadata_rejects_non_schema_payload(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
with open(seg / "index_metadata.pickle", "wb") as f:
pickle.dump(["not", "a", "metadata", "object"], f)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert len(moved) == 1
assert ".corrupt-" in moved[0]
assert not seg.exists()
def _dangerous_pickle_payload_executed():
raise AssertionError("unsafe pickle payload executed")
class _DangerousPickle:
def __reduce__(self):
return (_dangerous_pickle_payload_executed, ())
def test_quarantine_invalid_hnsw_metadata_rejects_unsafe_pickle(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
with open(seg / "index_metadata.pickle", "wb") as f:
pickle.dump(_DangerousPickle(), f)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert len(moved) == 1
assert ".corrupt-" in moved[0]
assert not seg.exists()
def test_quarantine_invalid_hnsw_metadata_skips_transient_read_errors(tmp_path, monkeypatch):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
meta = seg / "index_metadata.pickle"
meta.write_bytes(b"partial")
monkeypatch.setattr(
"mempalace.backends.chroma._SafePersistentDataUnpickler.load",
lambda path: (_ for _ in ()).throw(EOFError("flush in progress")),
)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert moved == []
assert seg.exists()
def test_quarantine_invalid_hnsw_metadata_skips_truncated_pickle(tmp_path, monkeypatch):
palace = tmp_path / "palace"
palace.mkdir()
seg = palace / "abcd-1234-5678"
seg.mkdir()
meta = seg / "index_metadata.pickle"
meta.write_bytes(b"partial")
monkeypatch.setattr(
"mempalace.backends.chroma._SafePersistentDataUnpickler.load",
lambda path: (_ for _ in ()).throw(pickle.UnpicklingError("pickle data was truncated")),
)
moved = quarantine_invalid_hnsw_metadata(str(palace))
assert moved == []
assert seg.exists()
def test_chroma_backend_preflights_metadata_before_persistent_client(tmp_path, monkeypatch):
palace = tmp_path / "palace"
palace.mkdir()
calls = []
def _record(name):
def inner(path, *args, **kwargs):
calls.append((name, path))
return [] if name != "blob" else None
return inner
monkeypatch.setattr("mempalace.backends.chroma._fix_blob_seq_ids", _record("blob"))
monkeypatch.setattr(
"mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _record("invalid")
)
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _record("stale"))
class DummyClient:
pass
monkeypatch.setattr(
"mempalace.backends.chroma.chromadb.PersistentClient", lambda path: DummyClient()
)
backend = ChromaBackend()
backend._client(str(palace))
assert calls == [
("blob", str(palace)),
("invalid", str(palace)),
("stale", str(palace)),
]
def test_chroma_backend_stale_quarantine_is_cold_start_only_on_refresh(tmp_path, monkeypatch):
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").write_text("")
calls = []
def _record(name):
def inner(path, *args, **kwargs):
calls.append((name, path))
return [] if name != "blob" else None
return inner
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
monkeypatch.setattr("mempalace.backends.chroma._fix_blob_seq_ids", _record("blob"))
monkeypatch.setattr(
"mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _record("invalid")
)
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _record("stale"))
class DummyClient:
pass
monkeypatch.setattr(
"mempalace.backends.chroma.chromadb.PersistentClient", lambda path: DummyClient()
)
backend = ChromaBackend()
stats = iter([(1, 1.0), (1, 1.0), (1, 2.0), (1, 2.0)])
monkeypatch.setattr(backend, "_db_stat", lambda path: next(stats))
backend._client(str(palace))
backend._client(str(palace))
assert calls == [
("blob", str(palace)),
("invalid", str(palace)),
("stale", str(palace)),
("blob", str(palace)),
]
def test_chroma_backend_requarantines_after_inode_replacement(tmp_path, monkeypatch):
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").write_text("")
calls = []
def _record(name):
def inner(path, *args, **kwargs):
calls.append((name, path))
return [] if name != "blob" else None
return inner
monkeypatch.setattr(ChromaBackend, "_quarantined_paths", set())
monkeypatch.setattr("mempalace.backends.chroma._fix_blob_seq_ids", _record("blob"))
monkeypatch.setattr(
"mempalace.backends.chroma.quarantine_invalid_hnsw_metadata", _record("invalid")
)
monkeypatch.setattr("mempalace.backends.chroma.quarantine_stale_hnsw", _record("stale"))
class DummyClient:
pass
monkeypatch.setattr(
"mempalace.backends.chroma.chromadb.PersistentClient", lambda path: DummyClient()
)
backend = ChromaBackend()
stats = iter([(1, 1.0), (1, 1.0), (2, 2.0), (2, 2.0)])
monkeypatch.setattr(backend, "_db_stat", lambda path: next(stats))
backend._client(str(palace))
backend._client(str(palace))
assert calls == [
("blob", str(palace)),
("invalid", str(palace)),
("stale", str(palace)),
("blob", str(palace)),
("invalid", str(palace)),
("stale", str(palace)),
]
def test_palace_get_collection_uses_configured_collection_name(monkeypatch):
from mempalace import palace
captured = {}
def fake_get_collection(palace_path, collection_name=None, create=False):
captured["palace_path"] = palace_path
captured["collection_name"] = collection_name
captured["create"] = create
return object()
monkeypatch.setattr(palace._DEFAULT_BACKEND, "get_collection", fake_get_collection)
monkeypatch.setattr("mempalace.config.get_configured_collection_name", lambda: "custom_drawers")
palace.get_collection("/palace", create=False)
assert captured == {
"palace_path": "/palace",
"collection_name": "custom_drawers",
"create": False,
}
+325
View File
@@ -0,0 +1,325 @@
"""Tests for ChromaCollection's palace-write-lock integration.
Closes the gap left by ``mine_palace_lock`` only protecting the
``mempalace mine`` pipeline: MCP/direct writers that call
``ChromaCollection.add/upsert/update/delete`` must also serialize against
mine and against each other to avoid the multi-threaded HNSW corruption
documented in #974/#965.
Property tested:
* ``ChromaCollection(c, palace_path=p)`` wraps every write with
``mine_palace_lock(p)``.
* Writes raise ``MineAlreadyRunning`` when another holder owns the lock
(instead of silently racing into the underlying chromadb call).
* Re-entrant composition with ``miner.mine()`` does not self-deadlock:
``with mine_palace_lock(p): col.upsert(...)`` runs to completion.
* ``ChromaCollection(c)`` (no palace_path) preserves legacy no-lock
behaviour for tests/callers that build the adapter directly without
going through ``ChromaBackend``.
POSIX-only: ``mine_palace_lock`` uses ``fcntl`` on Unix and ``msvcrt`` on
Windows; the contention semantics differ enough that the cross-process
tests are skipped on Windows runners.
"""
from __future__ import annotations
import multiprocessing
import os
import time
import pytest
from mempalace.backends.chroma import ChromaCollection
from mempalace.palace import MineAlreadyRunning, mine_palace_lock
def _get_mp_context():
"""Same start-method picker as test_palace_locks.py — ``spawn`` everywhere.
``fork`` deadlocks under Python 3.13 when the parent is multi-threaded
(pytest + chromadb + onnxruntime), and macOS forbids fork-without-exec via
CoreFoundation. ``spawn`` is slower (re-imports) but safe.
"""
return multiprocessing.get_context("spawn")
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeChromaCollection:
"""Records calls; never blocks. Stand-in for chromadb.Collection."""
def __init__(self):
self.adds: list[dict] = []
self.upserts: list[dict] = []
self.updates: list[dict] = []
self.deletes: list[dict] = []
def add(self, **kwargs):
self.adds.append(kwargs)
def upsert(self, **kwargs):
self.upserts.append(kwargs)
def update(self, **kwargs):
self.updates.append(kwargs)
def delete(self, **kwargs):
self.deletes.append(kwargs)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hold_lock(palace_path: str, ready_flag: str, release_flag: str) -> int:
"""Acquire ``mine_palace_lock``, signal readiness, wait for release.
Mirrors the helper in ``test_palace_locks.py`` so the contention
semantics match across both test files.
"""
try:
with mine_palace_lock(palace_path):
open(ready_flag, "w").close()
for _ in range(500):
if os.path.exists(release_flag):
return 0
time.sleep(0.01)
return 0
except MineAlreadyRunning:
return 1
# ---------------------------------------------------------------------------
# Tests — opt-in lock wiring
# ---------------------------------------------------------------------------
def test_palace_path_none_skips_lock(tmp_path, monkeypatch):
"""Legacy callers (``ChromaCollection(c)``) keep no-lock behaviour.
A ``ChromaCollection`` built without ``palace_path`` must not touch the
lock infrastructure at all. This guards against regressions where a
test or third-party caller relies on the historical bare-write path.
"""
monkeypatch.setenv("HOME", str(tmp_path))
fake = _FakeChromaCollection()
col = ChromaCollection(fake) # no palace_path -> no lock
# Hold the lock in a child process. Without palace_path, the parent
# write must still succeed (the lock does not gate this caller).
palace = str(tmp_path / "palace")
ready = str(tmp_path / "ready")
release = str(tmp_path / "release")
ctx = _get_mp_context()
holder = ctx.Process(target=_hold_lock, args=(palace, ready, release))
holder.start()
try:
for _ in range(500):
if os.path.exists(ready):
break
time.sleep(0.01)
assert os.path.exists(ready), "holder failed to acquire lock"
col.upsert(documents=["doc"], ids=["id-1"])
assert fake.upserts == [{"documents": ["doc"], "ids": ["id-1"]}]
finally:
open(release, "w").close()
holder.join(timeout=5)
def test_writer_blocks_during_mine(tmp_path, monkeypatch):
"""A held ``mine_palace_lock`` causes ``ChromaCollection`` writes to raise.
This is the property that closes the MCP-bypass gap: when a mine is in
flight, MCP/direct writes raise ``MineAlreadyRunning`` rather than
silently entering chromadb's write path concurrent with mine.
"""
monkeypatch.setenv("HOME", str(tmp_path))
palace = str(tmp_path / "palace")
ready = str(tmp_path / "ready")
release = str(tmp_path / "release")
ctx = _get_mp_context()
holder = ctx.Process(target=_hold_lock, args=(palace, ready, release))
holder.start()
try:
for _ in range(500):
if os.path.exists(ready):
break
time.sleep(0.01)
assert os.path.exists(ready), "holder failed to acquire lock"
fake = _FakeChromaCollection()
col = ChromaCollection(fake, palace_path=palace)
with pytest.raises(MineAlreadyRunning):
col.upsert(documents=["doc"], ids=["id-1"])
with pytest.raises(MineAlreadyRunning):
col.add(documents=["doc"], ids=["id-2"])
with pytest.raises(MineAlreadyRunning):
col.update(ids=["id-3"], documents=["doc"])
with pytest.raises(MineAlreadyRunning):
col.delete(ids=["id-4"])
# The fake must have received NO calls — the lock must gate
# before reaching the underlying chromadb layer.
assert fake.upserts == []
assert fake.adds == []
assert fake.updates == []
assert fake.deletes == []
finally:
open(release, "w").close()
holder.join(timeout=5)
def test_reentrant_inside_mine_passes_through(tmp_path, monkeypatch):
"""``ChromaCollection.upsert`` inside ``mine_palace_lock`` does not deadlock.
``miner.mine()`` already holds ``mine_palace_lock(palace_path)`` for the
full mine pipeline; ``_mine_body`` then calls
``collection.upsert(...)``. With the per-thread re-entrant guard in
``mine_palace_lock``, the inner acquire is a pass-through and the
underlying chromadb call runs immediately.
"""
monkeypatch.setenv("HOME", str(tmp_path))
palace = str(tmp_path / "palace")
fake = _FakeChromaCollection()
col = ChromaCollection(fake, palace_path=palace)
with mine_palace_lock(palace):
# If the re-entrant guard were missing, this would self-deadlock on
# the underlying flock. We rely on pytest-timeout (configured in
# pyproject.toml) to enforce this in CI; the assertion just confirms
# the call landed.
col.upsert(documents=["d"], ids=["i"], metadatas=[{"k": "v"}])
col.add(documents=["d2"], ids=["i2"])
col.update(ids=["i"], documents=["d-updated"])
col.delete(ids=["i2"])
assert len(fake.upserts) == 1
assert len(fake.adds) == 1
assert len(fake.updates) == 1
assert len(fake.deletes) == 1
class _SlowFakeChromaCollection(_FakeChromaCollection):
"""Fake whose write methods hold the caller for ``hold_seconds``.
Used to keep ``mine_palace_lock`` acquired long enough for a sibling
process to contend deterministically.
"""
def __init__(self, hold_seconds: float = 0.3):
super().__init__()
self._hold = hold_seconds
def upsert(self, **kwargs):
time.sleep(self._hold)
super().upsert(**kwargs)
def _slow_writer_target(palace_path, tmp_path_str, pid, result_q):
"""Subprocess target: try a slow upsert, report ok/busy."""
os.environ["HOME"] = tmp_path_str
# Fresh import inside child so HOME monkeypatch routes the lock dir.
from mempalace.backends.chroma import ChromaCollection as _CC
from mempalace.palace import MineAlreadyRunning as _MAR
fake = _SlowFakeChromaCollection(hold_seconds=0.3)
col = _CC(fake, palace_path=palace_path)
try:
col.upsert(documents=[f"d{pid}"], ids=[f"i{pid}"])
result_q.put(("ok", pid))
except _MAR:
result_q.put(("busy", pid))
def test_concurrent_writers_serialize(tmp_path, monkeypatch):
"""Two processes calling ``ChromaCollection.upsert`` against the same
palace must be serialized: at most one enters chromadb at a time, the
other raises ``MineAlreadyRunning``.
This is the property that prevents the parallel HNSW insert race that
drives #974/#965 — under concurrent MCP write fan-out, exactly one
writer reaches chromadb and the rest fail loudly instead of corrupting
the index.
The slow fake holds the lock for 0.3s per writer, large enough for the
second process to contend even on slow CI runners.
"""
monkeypatch.setenv("HOME", str(tmp_path))
palace = str(tmp_path / "palace")
ctx = _get_mp_context()
result_q = ctx.Queue()
p1 = ctx.Process(target=_slow_writer_target, args=(palace, str(tmp_path), 1, result_q))
p2 = ctx.Process(target=_slow_writer_target, args=(palace, str(tmp_path), 2, result_q))
p1.start()
# Tiny stagger so p1 wins the race deterministically; without it the
# OS scheduler can pick either, which is also a valid outcome but
# makes the assertion brittle on slow CI.
time.sleep(0.05)
p2.start()
p1.join(timeout=5)
p2.join(timeout=5)
outcomes = [result_q.get(timeout=1) for _ in range(2)]
statuses = sorted(o[0] for o in outcomes)
assert statuses == ["busy", "ok"], f"expected one ok + one busy, got {outcomes}"
def test_read_path_does_not_acquire_lock(tmp_path, monkeypatch):
"""``query`` / ``get`` / ``count`` must not be gated by the write lock.
Read traffic is the dominant workload (semantic search, MCP get, etc.)
and serializing it against mine would tank latency for no correctness
benefit. This test pins that property: with another process holding
the write lock, reads must still complete instantly.
"""
monkeypatch.setenv("HOME", str(tmp_path))
palace = str(tmp_path / "palace")
ready = str(tmp_path / "ready")
release = str(tmp_path / "release")
ctx = _get_mp_context()
holder = ctx.Process(target=_hold_lock, args=(palace, ready, release))
holder.start()
try:
for _ in range(500):
if os.path.exists(ready):
break
time.sleep(0.01)
assert os.path.exists(ready), "holder failed to acquire lock"
# _FakeChromaCollection doesn't implement query/get/count; we only
# need to confirm the wrapper does not call into mine_palace_lock
# for reads, which we assert by observing the wrapped methods are
# NOT in ChromaCollection's _write_lock path. A direct check via
# source inspection is more honest than mocking the entire chroma
# surface here.
import inspect
from mempalace.backends.chroma import ChromaCollection as _CC
for write_attr in ("add", "upsert", "update", "delete"):
src = inspect.getsource(getattr(_CC, write_attr))
assert "_write_lock" in src, f"{write_attr} should acquire write lock"
for read_attr in ("query", "get", "count"):
method = getattr(_CC, read_attr, None)
if method is None:
continue
src = inspect.getsource(method)
assert (
"_write_lock" not in src
), f"{read_attr} must NOT acquire the write lock (read path)"
finally:
open(release, "w").close()
holder.join(timeout=5)
+192
View File
@@ -0,0 +1,192 @@
"""Execution tests for Claude plugin hook wrapper scripts."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
PLUGIN_HOOKS_DIR = REPO_ROOT / ".claude-plugin" / "hooks"
BASH = shutil.which("bash")
pytestmark = pytest.mark.skipif(
BASH is None,
reason="bash required for Claude plugin hook wrapper tests",
)
SCRIPT_CASES = [
("mempal-stop-hook.sh", "stop"),
("mempal-precompact-hook.sh", "precompact"),
]
def _shell_path(path: Path) -> str:
return path.as_posix()
def _write_executable(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
path.chmod(0o755)
def _make_bin_dir(tmp_path: Path, executables: dict[str, str]) -> Path:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
for name, content in executables.items():
_write_executable(bin_dir / name, content)
return bin_dir
def _capture_stdin_to(output_path: Path) -> str:
return (
'stdin_payload=""\n'
'while IFS= read -r line || [ -n "$line" ]; do\n'
' stdin_payload="${stdin_payload}${line}"\n'
"done\n"
f'printf \'%s\' "$stdin_payload" > "{_shell_path(output_path)}"\n'
)
def _run_hook(
script_name: str,
payload: str,
bin_dir: Path,
) -> subprocess.CompletedProcess[str]:
assert BASH is not None
env = os.environ.copy()
env["PATH"] = str(bin_dir)
return subprocess.run(
[BASH, _shell_path(PLUGIN_HOOKS_DIR / script_name)],
input=payload,
text=True,
capture_output=True,
cwd=REPO_ROOT,
env=env,
)
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
def test_plugin_hook_wrapper_prefers_mempalace_cli(
tmp_path: Path, script_name: str, hook_name: str
) -> None:
args_file = tmp_path / "args.txt"
stdin_file = tmp_path / "stdin.json"
bin_dir = _make_bin_dir(
tmp_path,
{
"mempalace": (
"#!/bin/sh\n"
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
f"{_capture_stdin_to(stdin_file)}"
"printf '{}\\n'\n"
),
"python": "#!/bin/sh\nexit 99\n",
"python3": "#!/bin/sh\nexit 99\n",
},
)
payload = '{"session_id":"abc123"}'
result = _run_hook(script_name, payload, bin_dir)
assert result.returncode == 0
assert result.stdout == "{}\n"
assert (
args_file.read_text(encoding="utf-8")
== f"hook run --hook {hook_name} --harness claude-code"
)
assert stdin_file.read_text(encoding="utf-8") == payload
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
@pytest.mark.parametrize("python_name", ["python3", "python"])
def test_plugin_hook_wrapper_falls_back_to_importable_python(
tmp_path: Path, script_name: str, hook_name: str, python_name: str
) -> None:
args_file = tmp_path / "args.txt"
stdin_file = tmp_path / "stdin.json"
python_stub = (
"#!/bin/sh\n"
'if [ "$1" = "-c" ]; then\n'
" exit 0\n"
"fi\n"
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
f"{_capture_stdin_to(stdin_file)}"
"printf '{}\\n'\n"
)
bin_dir = _make_bin_dir(tmp_path, {python_name: python_stub})
payload = '{"session_id":"xyz789"}'
result = _run_hook(script_name, payload, bin_dir)
assert result.returncode == 0
assert result.stdout == "{}\n"
assert (
args_file.read_text(encoding="utf-8")
== f"-m mempalace hook run --hook {hook_name} --harness claude-code"
)
assert stdin_file.read_text(encoding="utf-8") == payload
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
def test_plugin_hook_wrapper_errors_cleanly_when_no_runner_exists(
tmp_path: Path, script_name: str, hook_name: str
) -> None:
bin_dir = _make_bin_dir(tmp_path, {})
payload = '{"session_id":"no-runner"}'
result = _run_hook(script_name, payload, bin_dir)
assert result.returncode != 0
assert result.stdout == ""
assert "could not find a runnable mempalace command or module" in result.stderr
@pytest.mark.parametrize(("script_name", "hook_name"), SCRIPT_CASES)
def test_plugin_hook_wrapper_falls_back_to_python_when_python3_cannot_import(
tmp_path: Path, script_name: str, hook_name: str
) -> None:
args_file = tmp_path / "args.txt"
stdin_file = tmp_path / "stdin.json"
bad_python3_used = tmp_path / "bad_python3_used.txt"
bin_dir = _make_bin_dir(
tmp_path,
{
"python3": (
"#!/bin/sh\n"
'if [ "$1" = "-c" ]; then\n'
" exit 1\n"
"fi\n"
f"printf 'used' > \"{_shell_path(bad_python3_used)}\"\n"
"echo 'No module named mempalace' >&2\n"
"exit 1\n"
),
"python": (
"#!/bin/sh\n"
'if [ "$1" = "-c" ]; then\n'
" exit 0\n"
"fi\n"
f'printf \'%s\' "$*" > "{_shell_path(args_file)}"\n'
f"{_capture_stdin_to(stdin_file)}"
"printf '{}\\n'\n"
),
},
)
payload = '{"session_id":"fallback"}'
result = _run_hook(script_name, payload, bin_dir)
assert result.returncode == 0
assert result.stdout == "{}\n"
assert (
args_file.read_text(encoding="utf-8")
== f"-m mempalace hook run --hook {hook_name} --harness claude-code"
)
assert stdin_file.read_text(encoding="utf-8") == payload
assert not bad_python3_used.exists()
+627 -9
View File
@@ -1,9 +1,11 @@
"""Tests for mempalace.cli — the main CLI dispatcher."""
import argparse
import shlex
import sqlite3
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, call, patch
import pytest
@@ -108,6 +110,7 @@ def test_cmd_init_no_entities(mock_config_cls, tmp_path):
with (
patch("mempalace.entity_detector.scan_for_detection", return_value=[]),
patch("mempalace.room_detector_local.detect_rooms_local") as mock_rooms,
patch("mempalace.cli._maybe_run_mine_after_init"),
):
cmd_init(args)
mock_rooms.assert_called_once_with(project_dir=str(tmp_path), yes=True)
@@ -125,11 +128,109 @@ def test_cmd_init_with_entities(mock_config_cls, tmp_path):
patch("mempalace.entity_detector.detect_entities", return_value=detected),
patch("mempalace.entity_detector.confirm_entities", return_value=confirmed),
patch("mempalace.room_detector_local.detect_rooms_local"),
# Pass 0 (corpus_origin) needs real file IO; this test mocks
# builtins.open globally for the entities.json write, which would
# break Pass 0's file-reading path. Patch Pass 0 out — a separate
# suite (tests/test_corpus_origin_integration.py) covers it directly.
patch("mempalace.cli._run_pass_zero", return_value=None),
patch("builtins.open", MagicMock()),
patch("mempalace.cli._maybe_run_mine_after_init"),
):
cmd_init(args)
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_init_normalizes_wing_name_for_topics_registry(mock_config_cls, tmp_path):
"""Regression for #1194: hyphenated dir names must be normalized to the
same slug ``mempalace.yaml`` uses, otherwise ``topics_by_wing`` keys
miss the miner's lookup at mine time and tunnels are silently dropped.
"""
project = tmp_path / "my-cool-app"
project.mkdir()
fake_files = [project / "a.txt"]
detected = {
"people": [{"name": "Alice"}],
"projects": [],
"topics": [{"name": "Bun"}],
"uncertain": [],
}
confirmed = {"people": ["Alice"], "projects": [], "topics": ["Bun"]}
args = argparse.Namespace(dir=str(project), yes=True)
with (
patch("mempalace.entity_detector.scan_for_detection", return_value=fake_files),
patch("mempalace.entity_detector.detect_entities", return_value=detected),
patch("mempalace.entity_detector.confirm_entities", return_value=confirmed),
patch("mempalace.miner.add_to_known_entities") as mock_register,
patch("mempalace.room_detector_local.detect_rooms_local"),
patch("builtins.open", MagicMock()),
patch("mempalace.cli._maybe_run_mine_after_init"),
# Pass-zero corpus-origin detection runs unconditionally inside
# cmd_init now (#1221 / #1223). It accesses MempalaceConfig fields
# that don't survive MagicMock stringification, so stub it out —
# this test only cares about the wing-slug write to the registry.
patch("mempalace.cli._run_pass_zero", return_value=None),
):
mock_register.return_value = "/tmp/known_entities.json"
cmd_init(args)
mock_register.assert_called_once()
assert mock_register.call_args.kwargs["wing"] == "my_cool_app"
def test_cmd_init_honors_palace_flag(tmp_path, monkeypatch):
"""Regression for #1313: ``cmd_init`` must honor ``--palace`` instead of
silently writing to ``~/.mempalace``. Mirrors the env-var pattern used
by ``cmd_mine`` / ``cmd_status`` / ``mcp_server`` so every downstream
read of ``cfg.palace_path`` (Pass 0, ``cfg.init()``, post-init mine)
routes to the user-specified location.
"""
project = tmp_path / "project"
project.mkdir()
palace = tmp_path / "custom_palace"
# Make sure no leftover env var from another test leaks in — we want to
# verify that --palace ALONE drives the resolution. Prime monkeypatch's
# undo list with setenv first so that the env var ``cmd_init`` writes
# below is rolled back at teardown (``delenv(raising=False)`` on a
# missing key registers no undo entry, which would leak into the next
# test).
monkeypatch.setenv("MEMPALACE_PALACE_PATH", "")
monkeypatch.setenv("MEMPAL_PALACE_PATH", "")
monkeypatch.delenv("MEMPALACE_PALACE_PATH")
monkeypatch.delenv("MEMPAL_PALACE_PATH")
args = argparse.Namespace(
dir=str(project),
palace=str(palace),
yes=True,
auto_mine=False,
)
captured = {}
def fake_pass_zero(project_dir, palace_dir, llm_provider):
# Capture the palace_dir Pass 0 sees — this is the smoking-gun
# value for the bug. Pre-fix it was always ~/.mempalace.
captured["pass_zero_palace_dir"] = palace_dir
return None
with (
patch("mempalace.entity_detector.scan_for_detection", return_value=[]),
patch("mempalace.room_detector_local.detect_rooms_local"),
patch("mempalace.cli._run_pass_zero", side_effect=fake_pass_zero),
patch("mempalace.cli._maybe_run_mine_after_init"),
):
cmd_init(args)
expected = str(palace)
# Pass 0 must have been handed the --palace location, not ~/.mempalace.
assert captured["pass_zero_palace_dir"] == expected
# And the env var must point at the custom palace so any downstream
# ``cfg.palace_path`` read in this process resolves correctly too.
import os
assert os.environ.get("MEMPALACE_PALACE_PATH") == os.path.abspath(expected)
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_init_with_entities_zero_total(mock_config_cls, tmp_path, capsys):
"""When entities detected but total is 0, prints 'No entities' message."""
@@ -140,12 +241,238 @@ def test_cmd_init_with_entities_zero_total(mock_config_cls, tmp_path, capsys):
patch("mempalace.entity_detector.scan_for_detection", return_value=fake_files),
patch("mempalace.entity_detector.detect_entities", return_value=detected),
patch("mempalace.room_detector_local.detect_rooms_local"),
patch("mempalace.cli._maybe_run_mine_after_init"),
):
cmd_init(args)
out = capsys.readouterr().out
assert "No entities detected" in out
# ── _maybe_run_mine_after_init (init → mine prompt, #1181) ─────────────
def _init_args(tmp_path, *, yes=False, auto_mine=False):
return argparse.Namespace(dir=str(tmp_path), yes=yes, auto_mine=auto_mine)
def _fake_cfg(tmp_path):
cfg = MagicMock()
cfg.palace_path = str(tmp_path / "palace")
return cfg
def _fake_scanned(tmp_path, n=3):
"""Build n real Path objects with stat()-able sizes for the scan estimate."""
paths = []
for i in range(n):
p = tmp_path / f"f{i}.txt"
p.write_text("x" * 1024) # 1 KB each
paths.append(p)
return paths
def test_maybe_run_mine_prompt_accepted_runs_mine(tmp_path):
"""Empty / 'y' / 'yes' on the prompt triggers mine() in-process."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
scanned = _fake_scanned(tmp_path, n=3)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=scanned),
patch("builtins.input", return_value=""),
):
_maybe_run_mine_after_init(args, cfg)
mock_mine.assert_called_once_with(
project_dir=str(tmp_path),
palace_path=cfg.palace_path,
files=scanned,
)
def test_maybe_run_mine_prompt_yes_accepted_runs_mine(tmp_path):
"""Explicit 'y' answer also runs mine()."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", return_value="Y"),
):
_maybe_run_mine_after_init(args, cfg)
mock_mine.assert_called_once()
def test_maybe_run_mine_prompt_declined_prints_hint(tmp_path, capsys):
"""'n' answer skips mine() and prints the resume hint."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", return_value="n"),
):
_maybe_run_mine_after_init(args, cfg)
mock_mine.assert_not_called()
out = capsys.readouterr().out
# shlex.quote is a no-op on POSIX-safe paths but wraps Windows paths
# (which contain backslashes) in single quotes, so the assertion has
# to mirror what the production code actually emits.
assert f"mempalace mine {shlex.quote(str(tmp_path))}" in out
assert "Skipped" in out
def test_maybe_run_mine_yes_alone_still_prompts(tmp_path):
"""`--yes` is scoped to entity auto-accept and MUST still prompt for mine.
Regression guard for the flag-overload review feedback on #1183: extending
`--yes` to also auto-mine would silently change behaviour for scripted
callers and turn a fast command into a minutes-long ChromaDB write.
"""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=True, auto_mine=False)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", return_value="n") as mock_input,
):
_maybe_run_mine_after_init(args, cfg)
mock_input.assert_called_once() # the prompt MUST fire
mock_mine.assert_not_called()
def test_maybe_run_mine_auto_mine_skips_prompt(tmp_path):
"""`--auto-mine` runs mine() automatically without calling input()."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=True)
cfg = _fake_cfg(tmp_path)
scanned = _fake_scanned(tmp_path, n=2)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=scanned),
patch("builtins.input", side_effect=AssertionError("input() must not be called")),
):
_maybe_run_mine_after_init(args, cfg)
mock_mine.assert_called_once_with(
project_dir=str(tmp_path),
palace_path=cfg.palace_path,
files=scanned,
)
def test_maybe_run_mine_yes_and_auto_mine_fully_noninteractive(tmp_path):
"""`--yes --auto-mine` together: never call input(), always mine."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=True, auto_mine=True)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", side_effect=AssertionError("input() must not be called")),
):
_maybe_run_mine_after_init(args, cfg)
mock_mine.assert_called_once()
def test_maybe_run_mine_decline_quotes_path_with_spaces(tmp_path, capsys):
"""The resume hint must shell-quote the project dir so paths with
spaces / metacharacters produce a copy-paste-safe command."""
from mempalace.cli import _maybe_run_mine_after_init
spaced_dir = tmp_path / "my project dir"
spaced_dir.mkdir()
args = argparse.Namespace(dir=str(spaced_dir), yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine"),
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", return_value="n"),
):
_maybe_run_mine_after_init(args, cfg)
out = capsys.readouterr().out
# shlex.quote wraps paths with spaces (and Windows backslashes) in
# single quotes — the assertion must use the same shlex form so the
# test passes on every platform's tmp_path layout.
assert f"mempalace mine {shlex.quote(str(spaced_dir))}" in out
# Bare unquoted form must NOT appear — that's the bug we're guarding.
assert f"mempalace mine {spaced_dir} " not in out
assert f"mempalace mine {spaced_dir}`" not in out
def test_maybe_run_mine_eof_on_stdin_treated_as_decline(tmp_path, capsys):
"""Piped / non-interactive stdin (EOFError) declines without crashing."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine") as mock_mine,
patch("mempalace.miner.scan_project", return_value=[]),
patch("builtins.input", side_effect=EOFError),
):
_maybe_run_mine_after_init(args, cfg)
mock_mine.assert_not_called()
assert "Skipped" in capsys.readouterr().out
def test_maybe_run_mine_failure_surfaces_via_exit(tmp_path, capsys):
"""Mine errors are not swallowed — they exit non-zero with an error line."""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=True)
cfg = _fake_cfg(tmp_path)
with (
patch("mempalace.miner.mine", side_effect=RuntimeError("boom")),
patch("mempalace.miner.scan_project", return_value=[]),
):
with pytest.raises(SystemExit) as exc_info:
_maybe_run_mine_after_init(args, cfg)
assert exc_info.value.code == 1
err = capsys.readouterr().err
assert "boom" in err
def test_maybe_run_mine_estimate_appears_before_prompt(tmp_path, capsys):
"""The file-count + size estimate line MUST render BEFORE the prompt.
Required by the spec: hitting Enter on a default-Y prompt with no size
info is a footgun on a real corpus where mine takes minutes. The user
must see scope before being asked to confirm.
"""
from mempalace.cli import _maybe_run_mine_after_init
args = _init_args(tmp_path, yes=False, auto_mine=False)
cfg = _fake_cfg(tmp_path)
scanned = _fake_scanned(tmp_path, n=4) # 4 files * 1 KB each
captured_when_prompted = {}
def fake_input(prompt):
# Snapshot what stdout looked like at the moment the prompt fires.
captured_when_prompted["stdout"] = capsys.readouterr().out
return "n"
with (
patch("mempalace.miner.mine"),
patch("mempalace.miner.scan_project", return_value=scanned),
patch("builtins.input", side_effect=fake_input),
):
_maybe_run_mine_after_init(args, cfg)
pre_prompt = captured_when_prompted["stdout"]
assert "4 files" in pre_prompt, f"file count missing from pre-prompt output: {pre_prompt!r}"
assert "MB" in pre_prompt, f"size estimate missing from pre-prompt output: {pre_prompt!r}"
assert "would be mined" in pre_prompt
# ── cmd_mine ───────────────────────────────────────────────────────────
@@ -228,6 +555,45 @@ 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_exits_nonzero_on_lock_holder(mock_config_cls, capsys):
"""Regression #1264: lock contention must exit non-zero with a clear message.
Before this fix the CLI silently returned 0 when another writer held
the palace lock — operators using nohup/scripts had no way to detect
the contention. The new behavior raises MineAlreadyRunning out of
miner.mine() and cmd_mine catches it, printing the holder identity
to stderr and exiting non-zero.
"""
from mempalace.palace import MineAlreadyRunning
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",
)
with patch(
"mempalace.miner.mine",
side_effect=MineAlreadyRunning(
"palace /fake/palace is held by PID 12345 (mempalace mcp_server); wait for it to finish"
),
):
with pytest.raises(SystemExit) as excinfo:
cmd_mine(args)
assert excinfo.value.code == 1
captured = capsys.readouterr()
assert "PID 12345" in captured.err
assert "mcp_server" in captured.err
# ── cmd_wakeup ─────────────────────────────────────────────────────────
@@ -334,9 +700,9 @@ def test_mcp_command_prints_setup_guidance(monkeypatch, capsys):
captured = capsys.readouterr()
assert "MemPalace MCP quick setup:" in captured.out
assert "claude mcp add mempalace -- python -m mempalace.mcp_server" in captured.out
assert "claude mcp add mempalace -- mempalace-mcp" in captured.out
assert "\nOptional custom palace:\n" in captured.out
assert "python -m mempalace.mcp_server --palace /path/to/palace" in captured.out
assert "mempalace-mcp --palace /path/to/palace" in captured.out
assert "[--palace /path/to/palace]" not in captured.out
assert captured.err == ""
@@ -349,7 +715,7 @@ def test_mcp_command_uses_custom_palace_path_when_provided(monkeypatch, capsys):
captured = capsys.readouterr()
expanded = str(Path("~/tmp/my palace").expanduser())
assert "python -m mempalace.mcp_server --palace" in captured.out
assert "mempalace-mcp --palace" in captured.out
assert expanded in captured.out
assert "Optional custom palace:" not in captured.out
assert "[--palace /path/to/palace]" not in captured.out
@@ -448,8 +814,9 @@ def test_cmd_repair_requires_palace_database(mock_config_cls, tmp_path, capsys):
def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
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_backend = MagicMock()
mock_backend.get_collection.side_effect = Exception("corrupt db")
@@ -463,8 +830,9 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
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.return_value = 0
@@ -479,8 +847,9 @@ def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
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, yes=True)
mock_col = MagicMock()
mock_col.count.return_value = 2
@@ -489,21 +858,107 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
"documents": ["doc1", "doc2"],
"metadatas": [{"wing": "a"}, {"wing": "b"}],
}
mock_temp_col = MagicMock()
mock_temp_col.count.return_value = 2
mock_new_col = MagicMock()
mock_new_col.count.return_value = 2
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
mock_backend.create_collection.side_effect = [mock_temp_col, mock_new_col]
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_repair(args)
out = capsys.readouterr().out
assert "Repair complete" in out
assert "2 drawers rebuilt" in out
assert mock_backend.delete_collection.call_args_list == [
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
call(str(palace_dir), "mempalace_drawers"),
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
]
mock_temp_col.upsert.assert_called_once()
mock_new_col.upsert.assert_called_once()
mock_new_col.add.assert_not_called()
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_uses_configured_collection(mock_config_cls, tmp_path, capsys):
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 = "custom_drawers"
args = argparse.Namespace(palace=None, yes=True)
mock_col = MagicMock()
mock_col.count.return_value = 2
mock_col.get.return_value = {
"ids": ["id1", "id2"],
"documents": ["doc1", "doc2"],
"metadatas": [{"wing": "a"}, {"wing": "b"}],
}
mock_temp_col = MagicMock()
mock_temp_col.count.return_value = 2
mock_new_col = MagicMock()
mock_new_col.count.return_value = 2
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
mock_backend.create_collection.side_effect = [mock_temp_col, mock_new_col]
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_repair(args)
out = capsys.readouterr().out
assert "Repair complete" in out
mock_backend.get_collection.assert_called_once_with(str(palace_dir), "custom_drawers")
assert mock_backend.create_collection.call_args_list == [
call(str(palace_dir), "custom_drawers__repair_tmp"),
call(str(palace_dir), "custom_drawers"),
]
assert mock_backend.delete_collection.call_args_list == [
call(str(palace_dir), "custom_drawers__repair_tmp"),
call(str(palace_dir), "custom_drawers"),
call(str(palace_dir), "custom_drawers__repair_tmp"),
]
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_restores_backup_on_live_rebuild_failure(mock_config_cls, tmp_path, capsys):
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, yes=True)
mock_col = MagicMock()
mock_col.count.return_value = 2
mock_col.get.return_value = {
"ids": ["id1", "id2"],
"documents": ["doc1", "doc2"],
"metadatas": [{"wing": "a"}, {"wing": "b"}],
}
mock_temp_col = MagicMock()
mock_temp_col.count.return_value = 2
mock_backend = _mock_backend_for(col=mock_col)
mock_backend.create_collection.side_effect = [mock_temp_col, RuntimeError("live build failed")]
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
with pytest.raises(SystemExit) as excinfo:
cmd_repair(args)
out = capsys.readouterr().out
assert excinfo.value.code == 1
assert "Repair failed" in out
assert "restoring from backup" in out
mock_backend.close_palace.assert_called_once_with(str(palace_dir))
assert mock_backend.delete_collection.call_args_list == [
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
call(str(palace_dir), "mempalace_drawers"),
call(str(palace_dir), "mempalace_drawers__repair_tmp"),
]
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsys):
palace_dir = tmp_path / "palace"
palace_dir.mkdir()
(palace_dir / "chroma.sqlite3").write_text("db")
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.return_value = 1
@@ -618,7 +1073,7 @@ def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys):
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_compress_stores_results(mock_config_cls, capsys):
"""Non-dry-run compress stores to mempalace_compressed collection."""
"""Non-dry-run compress stores to mempalace_closets collection (#1244)."""
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
mock_col = MagicMock()
@@ -656,6 +1111,53 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
assert "Stored" in out
assert "Total:" in out
mock_comp_col.upsert.assert_called_once()
# Verify the compress output goes to the closets collection so that
# palace.get_closets_collection() / searcher can read it back (#1244).
(call_args, _kwargs) = mock_backend.get_or_create_collection.call_args
assert (
call_args[1] == "mempalace_closets"
), f"compress should write to mempalace_closets, got {call_args[1]!r}"
assert "mempalace_closets" in out
def test_cmd_compress_output_readable_via_get_closets_collection(tmp_path, capsys):
"""End-to-end: cmd_compress output must be readable via the same code
path palace.py uses (`get_closets_collection`). Regression for #1244."""
from mempalace.backends.chroma import ChromaBackend
from mempalace.palace import get_closets_collection, get_collection
palace_path = str(tmp_path / "palace")
# Seed a drawer in the palace so cmd_compress has something to compress.
drawers = get_collection(palace_path, "mempalace_drawers", create=True)
drawers.upsert(
ids=["drawer-1"],
documents=["The quick brown fox jumps over the lazy dog."],
metadatas=[{"wing": "test", "room": "demo", "source_file": "fox.txt"}],
)
args = argparse.Namespace(palace=palace_path, wing=None, dry_run=False, config=None)
with patch("mempalace.cli.MempalaceConfig") as mock_config_cls:
mock_config_cls.return_value.palace_path = palace_path
# Use a real ChromaBackend so the write actually lands on disk and
# the read-side helper can find it.
with patch("mempalace.backends.chroma.ChromaBackend", side_effect=ChromaBackend):
cmd_compress(args)
out = capsys.readouterr().out
assert "Stored" in out
# Now read via the *same* code path palace.py / searcher uses.
closets = get_closets_collection(palace_path, create=False)
got = closets.get(ids=["drawer-1"], include=["documents", "metadatas"])
assert got["ids"] == ["drawer-1"], (
"compressed drawer not found in mempalace_closets — "
"cmd_compress wrote to the wrong collection (#1244)"
)
assert got["documents"] and got["documents"][0], "empty compressed doc"
meta = got["metadatas"][0]
assert meta.get("wing") == "test"
assert "compression_ratio" in meta
def test_cmd_repair_trailing_slash_does_not_recurse():
@@ -669,3 +1171,119 @@ def test_cmd_repair_trailing_slash_does_not_recurse():
palace_path = os.path.expanduser(args.palace).rstrip(os.sep)
backup_path = palace_path + ".backup"
assert not backup_path.startswith(palace_path + os.sep)
# ── stdio reconfigure on Windows ─────────────────────────────────────
class _ReconfigurableStringIO:
def __init__(self):
self.reconfigure_calls = []
def reconfigure(self, **kwargs):
self.reconfigure_calls.append(kwargs)
def test_reconfigures_stdio_to_utf8_on_windows():
"""Windows `mempalace` CLI must decode/encode stdio as UTF-8.
Without this, piped non-ASCII input (`mempalace search ... < q.txt`)
or piped non-ASCII output (`mempalace search "..." > out.txt`) is
mojibaked through the system ANSI codepage on non-Latin Windows
locales (cp1252/cp1251/cp950).
"""
from mempalace.cli import _reconfigure_stdio_utf8_on_windows
stdin = _ReconfigurableStringIO()
stdout = _ReconfigurableStringIO()
stderr = _ReconfigurableStringIO()
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdin", stdin),
patch.object(sys, "stdout", stdout),
patch.object(sys, "stderr", stderr),
):
_reconfigure_stdio_utf8_on_windows()
# Per-stream errors policy: stdin survives bad bytes via
# surrogateescape so a redirected non-UTF-8 file does not crash
# the read; stdout/stderr use replace so a drawer carrying a
# round-tripped surrogate half does not crash mid-print.
assert stdin.reconfigure_calls == [{"encoding": "utf-8", "errors": "surrogateescape"}]
assert stdout.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}]
assert stderr.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}]
def test_reconfigure_stdio_is_noop_off_windows():
"""Linux/macOS already default to UTF-8 stdio -- helper must not touch streams."""
from mempalace.cli import _reconfigure_stdio_utf8_on_windows
stdin = _ReconfigurableStringIO()
with (
patch.object(sys, "platform", "linux"),
patch.object(sys, "stdin", stdin),
):
_reconfigure_stdio_utf8_on_windows()
assert stdin.reconfigure_calls == []
# ── cmd_repair: from-sqlite mode exit codes ──────────────────────────
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_from_sqlite_validation_refusal_exits_nonzero(mock_config_cls, tmp_path, capsys):
"""When ``rebuild_from_sqlite`` returns ``{}`` for a validation
refusal (missing source DB, in-place without --archive-existing,
refusing to overwrite an existing dest), the CLI must surface a
non-zero exit so unattended scripts and CI distinguish "invalid
inputs" from "successful recovery that found zero rows."
Catches: a regression where the CLI treats the validation-refusal
sentinel as success, leaving CI green on a no-op repair that should
have alerted an operator.
"""
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),
mode="from-sqlite",
source=None,
archive_existing=False,
yes=True,
)
with patch("mempalace.repair.rebuild_from_sqlite", return_value={}):
with pytest.raises(SystemExit) as excinfo:
cmd_repair(args)
assert excinfo.value.code == 1
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_from_sqlite_success_does_not_exit(mock_config_cls, tmp_path):
"""A successful from-sqlite rebuild — even one that finds zero rows
in a legitimately empty source palace — must NOT call ``sys.exit``.
A populated counts dict (with ``0`` values) is the success signal;
only the empty dict ``{}`` is reserved for validation refusal.
Catches: a regression where ``if not counts`` is replaced by
``if not sum(counts.values())`` or similar, conflating "empty source"
with "validation refused" and breaking idempotent recovery scripts.
"""
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),
mode="from-sqlite",
source=None,
archive_existing=False,
yes=True,
)
# Zero rows but per-collection keys present → success, no exit.
fake_counts = {"mempalace_drawers": 0, "mempalace_closets": 0}
with patch("mempalace.repair.rebuild_from_sqlite", return_value=fake_counts):
# Should return cleanly; no SystemExit raised.
cmd_repair(args)
+202 -2
View File
@@ -196,10 +196,34 @@ class TestCallLLM:
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c")
with (
patch("urllib.request.urlopen", side_effect=fake_urlopen),
patch("mempalace.closet_llm.time.sleep"),
):
parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c")
assert parsed is None
def test_retries_on_json_decode_error(self):
cfg = self._make_cfg()
call_count = {"n": 0}
def fake_urlopen(req, timeout=None):
call_count["n"] += 1
return _FakeResp(
{
"choices": [{"message": {"content": "not json at all"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
}
)
with (
patch("urllib.request.urlopen", side_effect=fake_urlopen),
patch("mempalace.closet_llm.time.sleep"),
):
parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c")
assert parsed is None
assert call_count["n"] == 3
# ── regenerate_closets error paths ───────────────────────────────────────
@@ -296,6 +320,182 @@ class TestRegenerateClosets:
assert meta.get("generated_by", "").startswith("llm:")
assert meta.get("normalize_version") == NORMALIZE_VERSION
def test_regen_paginates_drawer_fetch(self, tmp_path):
"""Regression for #1073: drawers_col.get must be paginated at
batch_size=5000. A single get(limit=total, ...) on a palace with
more than SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) drawers
blows up inside chromadb. Matches the miner.status pattern
introduced in #851 (see #802, #850, #1073)."""
from mempalace import closet_llm as closet_llm_mod
palace = str(tmp_path / "palace")
# Build a fake collection: 12_000 drawers across 3 source files,
# enough to force 3 batches of batch_size=5000 (5000 + 5000 + 2000).
n_drawers = 12_000
ids = [f"d{i:05d}" for i in range(n_drawers)]
docs = [f"doc body {i}" for i in range(n_drawers)]
metas = [
{
"wing": "w",
"room": "r",
"source_file": f"/src/file_{i % 3}.md",
"entities": "",
}
for i in range(n_drawers)
]
get_calls: list = []
class FakeDrawersCol:
def count(self):
return n_drawers
def get(self, limit=None, offset=0, include=None, **kwargs):
get_calls.append({"limit": limit, "offset": offset, "include": include})
end = min(offset + (limit or n_drawers), n_drawers)
return {
"ids": ids[offset:end],
"documents": docs[offset:end],
"metadatas": metas[offset:end],
}
class FakeClosetsCol:
"""Accept the purge + upsert calls the success path makes."""
def get(self, *a, **kw):
return {"ids": [], "documents": [], "metadatas": []}
def delete(self, *a, **kw):
return None
def upsert(self, *a, **kw):
return None
fake_drawers = FakeDrawersCol()
fake_closets = FakeClosetsCol()
def fake_urlopen(req, timeout=None):
return _FakeResp(
{
"choices": [
{"message": {"content": '{"topics":["t1"],"quotes":[],"summary":""}'}}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
}
)
cfg = LLMConfig(endpoint="http://local/v1", model="m")
with (
patch.object(closet_llm_mod, "get_collection", return_value=fake_drawers),
patch.object(closet_llm_mod, "get_closets_collection", return_value=fake_closets),
patch.object(closet_llm_mod, "purge_file_closets", return_value=None),
patch.object(closet_llm_mod, "upsert_closet_lines", return_value=None),
patch("urllib.request.urlopen", side_effect=fake_urlopen),
):
result = regenerate_closets(palace, cfg=cfg, dry_run=True)
# Three paginated calls: (limit=5000, offset=0), (5000, 5000), (5000, 10000).
assert len(get_calls) == 3, f"expected 3 batched fetches, got {len(get_calls)}"
for call in get_calls:
assert (
call["limit"] == 5000
), f"batch must be 5000 — got {call['limit']} (would risk SQLITE_MAX_VARIABLE_NUMBER)"
# include must still request both documents and metadatas
assert "documents" in call["include"]
assert "metadatas" in call["include"]
assert [c["offset"] for c in get_calls] == [0, 5000, 10_000]
# by_source aggregation must be preserved exactly across batches:
# 12_000 drawers, 3 source files → 4_000 drawers each.
# dry_run=True short-circuits LLM calls but still walks by_source.
assert result.get("processed", 0) == 0 # dry_run
# Verify no single call tried to pull more than batch_size.
assert max(c["limit"] for c in get_calls) <= 5000
def test_regen_by_source_aggregates_across_batches(self, tmp_path):
"""Pagination must not change the by_source grouping — drawers for
the same source_file split across batches still land in one group."""
from mempalace import closet_llm as closet_llm_mod
palace = str(tmp_path / "palace")
# 7_500 drawers, alternating between two source files → forces
# splits across the 5000/2500 boundary. Each source ends up with
# 3_750 drawers after regrouping.
n_drawers = 7_500
ids = [f"d{i:05d}" for i in range(n_drawers)]
docs = [f"body-{i}" for i in range(n_drawers)]
metas = [
{
"wing": "w",
"room": "r",
"source_file": f"/src/file_{i % 2}.md",
"entities": "",
}
for i in range(n_drawers)
]
captured_sources: dict = {}
class FakeDrawersCol:
def count(self):
return n_drawers
def get(self, limit=None, offset=0, include=None, **kwargs):
end = min(offset + (limit or n_drawers), n_drawers)
return {
"ids": ids[offset:end],
"documents": docs[offset:end],
"metadatas": metas[offset:end],
}
class FakeClosetsCol:
def get(self, *a, **kw):
return {"ids": [], "documents": [], "metadatas": []}
def delete(self, *a, **kw):
return None
def upsert(self, *a, **kw):
return None
# Hook _call_llm to inspect what regenerate_closets aggregated
# per source before the HTTP boundary.
real_call_llm = closet_llm_mod._call_llm
def spying_call_llm(cfg, source_file, wing, room, content):
captured_sources[source_file] = content
return (
{"topics": ["t"], "quotes": [], "summary": ""},
{"prompt_tokens": 1, "completion_tokens": 1},
)
cfg = LLMConfig(endpoint="http://local/v1", model="m")
with (
patch.object(closet_llm_mod, "get_collection", return_value=FakeDrawersCol()),
patch.object(closet_llm_mod, "get_closets_collection", return_value=FakeClosetsCol()),
patch.object(closet_llm_mod, "purge_file_closets", return_value=None),
patch.object(closet_llm_mod, "upsert_closet_lines", return_value=None),
patch.object(closet_llm_mod, "_call_llm", side_effect=spying_call_llm),
):
regenerate_closets(palace, cfg=cfg)
# Both sources survived the pagination boundary.
assert set(captured_sources.keys()) == {"/src/file_0.md", "/src/file_1.md"}
# Each source accumulated exactly 3_750 drawer bodies, concatenated
# with the "\n\n" separator the regenerate path uses.
for source, content in captured_sources.items():
assert content.count("\n\n") == 3_749, (
f"{source}: expected 3_750 chunks joined (3_749 separators), "
f"got {content.count(chr(10) + chr(10)) + 1}"
)
# Silence unused-var lint.
assert real_call_llm is not None
def test_regen_uses_basename_not_split_slash(self, tmp_path, monkeypatch):
"""Regression: the old closet_id base used ``source.split('/')[-1]``
which silently degrades on Windows paths (``C:\\proj\\a.md`` →
+79
View File
@@ -23,6 +23,7 @@ Coverage map:
cross-diary collisions, force=True purges leftover closets.
"""
import hashlib
import json
import multiprocessing
import os
@@ -605,6 +606,84 @@ class TestDiaryIngest:
result = ingest_diaries(str(diary_dir), str(palace_dir))
assert result["days_updated"] == 0
def test_ingest_detects_same_size_content_edit(self, tmp_path):
# Regression #925: the prior skip-check compared byte length only, so
# any in-place edit preserving total length (typo fix "teh"→"the",
# word swap, character reorder) was silently dropped. Content-hash
# check must catch the change AND rebuild the searchable closet so
# the index does not stay stale while the drawer updates.
diary_dir = tmp_path / "diaries"
diary_dir.mkdir()
diary_file = diary_dir / "2026-04-13.md"
# Original has the typo "Teh"; the edit fixes it to "The" — same length.
original = "# 2026-04-13\n\n## 10:00 — Test\n\nTeh elaborate jakarta postgres bug.\n"
edited = "# 2026-04-13\n\n## 10:00 — Test\n\nThe elaborate jakarta postgres bug.\n"
assert len(original) == len(edited), "test setup: edited content must be same length"
diary_file.write_text(original)
palace_dir = tmp_path / "palace"
from mempalace.diary_ingest import ingest_diaries
ingest_diaries(str(diary_dir), str(palace_dir), force=True)
diary_file.write_text(edited)
result = ingest_diaries(str(diary_dir), str(palace_dir))
assert result["days_updated"] == 1, "same-size content edit must trigger re-ingest"
# Drawer must hold the corrected text.
drawers = get_collection(str(palace_dir)).get(where={"source_file": str(diary_file)})
joined_drawers = "\n".join(drawers["documents"])
assert "The elaborate" in joined_drawers
assert "Teh elaborate" not in joined_drawers, "drawer still holds pre-edit content"
# And the closet (search index) must reflect the edit too — not just the
# drawer. Otherwise searches would surface stale text.
closets = get_closets_collection(str(palace_dir)).get(
where={"source_file": str(diary_file)}
)
joined_closets = "\n".join(closets["documents"])
assert "Teh elaborate" not in joined_closets, "closet index still holds stale content"
def test_legacy_state_backfills_content_hash(self, tmp_path):
# Upgraded users can carry legacy state entries without ``content_hash``.
# Same-size skip is preserved for that one run, but the hash must be
# recorded so the strict check engages on subsequent runs.
diary_dir = tmp_path / "diaries"
diary_dir.mkdir()
diary_file = diary_dir / "2026-04-13.md"
# Write explicit UTF-8 so the round-trip matches how diary_ingest reads.
# Windows' default text-mode encoding is cp1252; without this the em
# dash would round-trip lossy and the hash assertion below would fail.
text = "# 2026-04-13\n\n## 10:00 — Test\n\nUnchanged body content here.\n"
diary_file.write_text(text, encoding="utf-8")
palace_dir = tmp_path / "palace"
from mempalace.diary_ingest import _state_file_for, ingest_diaries
# Simulate a legacy state file: only size + entry_count, no content_hash.
state_file = _state_file_for(str(palace_dir), diary_dir.resolve())
state_file.parent.mkdir(parents=True, exist_ok=True)
state_file.write_text(
json.dumps(
{
f"diary|{diary_file.name}": {
"size": len(text),
"entry_count": 1,
"ingested_at": "2026-04-12T00:00:00+00:00",
}
}
)
)
# Run with no force — size matches, so this should skip ingest.
result = ingest_diaries(str(diary_dir), str(palace_dir))
assert result["days_updated"] == 0
# Hash must have been backfilled into state for the next run's strict check.
persisted = json.loads(state_file.read_text())
entry = persisted[f"diary|{diary_file.name}"]
assert "content_hash" in entry, "legacy skip path must record the hash"
assert entry["content_hash"] == hashlib.sha256(text.encode("utf-8")).hexdigest()
def test_state_file_lives_outside_diary_dir(self, tmp_path):
# Regression: the original implementation wrote
# ``.diary_ingest_state.json`` *inside* the user's diary directory,
+87
View File
@@ -0,0 +1,87 @@
"""Invariant tests: every ChromaDB collection-creation path must set
``hnsw:space=cosine``.
Reason: ChromaDB's default HNSW distance is L2 (Euclidean). Under L2,
the searcher's ``max(0, 1 - distance)`` similarity formula systematically
floors to 0 because L2 distances on normalized 384-dim vectors routinely
exceed 1.0 — users then see flat ``Match: 0.0`` across every result and
have no signal that their palace is broken.
This test file locks the invariant so a future refactor that drops the
``metadata={"hnsw:space": "cosine"}`` parameter from any creation path
gets caught at test time rather than silently degrading search quality.
"""
from mempalace.backends.chroma import ChromaBackend
from mempalace.palace import get_collection
EXPECTED_METRIC = "cosine"
def _assert_cosine(col, where: str) -> None:
meta = col.metadata if hasattr(col, "metadata") else col._collection.metadata
assert isinstance(meta, dict), f"{where}: expected metadata dict, got {meta!r}"
assert meta.get("hnsw:space") == EXPECTED_METRIC, (
f"{where}: expected hnsw:space={EXPECTED_METRIC!r}, got {meta!r}. "
"A collection without cosine metric will silently break the "
"similarity formula used by the searcher."
)
def test_legacy_get_or_create_collection_sets_cosine(tmp_path):
backend = ChromaBackend()
col = backend.get_or_create_collection(str(tmp_path), "mempalace_drawers")
_assert_cosine(col, "legacy get_or_create_collection")
def test_legacy_create_collection_sets_cosine(tmp_path):
backend = ChromaBackend()
col = backend.create_collection(str(tmp_path), "mempalace_drawers")
_assert_cosine(col, "legacy create_collection")
def test_new_get_collection_with_create_sets_cosine(tmp_path):
"""RFC 001 typed surface — ``get_collection(..., create=True)`` is the
path the miner + init flow take. Must also set cosine."""
backend = ChromaBackend()
col = backend.get_collection(str(tmp_path), "mempalace_drawers", create=True)
_assert_cosine(col, "get_collection(create=True)")
def test_palace_module_get_collection_sets_cosine(tmp_path):
"""The public ``mempalace.palace.get_collection`` is what most callers
use. Must produce cosine palaces."""
col = get_collection(str(tmp_path), "mempalace_drawers", create=True)
_assert_cosine(col, "palace.get_collection(create=True)")
def test_reopening_cosine_palace_preserves_metric(tmp_path):
"""Opening a previously-created cosine palace (create=False) must
still expose the cosine metadata — catches any regression where
reopening drops or overwrites metadata."""
backend = ChromaBackend()
backend.create_collection(str(tmp_path), "mempalace_drawers")
# Fresh backend simulates a process restart
backend2 = ChromaBackend()
col = backend2.get_collection(str(tmp_path), "mempalace_drawers", create=False)
_assert_cosine(col, "re-opened palace")
def test_fresh_palace_via_full_stack_gets_cosine(tmp_path):
"""End-to-end: build a palace with the public API the way a new user
would, confirm the resulting collection uses cosine distance.
Uses the ``tmp_path`` fixture rather than ``tempfile.TemporaryDirectory``
so ChromaDB's persistent SQLite file handles aren't asked to release
during the test body — pytest cleans the path at session end, by which
point the process is exiting and Windows' file-lock contention is
moot. Matches the cleanup strategy used by the rest of this file and
the project's 80% Windows coverage note in CLAUDE.md.
"""
col = get_collection(str(tmp_path), "mempalace_drawers", create=True)
_assert_cosine(col, "full-stack new palace")
# And the closets collection too
closets = get_collection(str(tmp_path), "mempalace_closets", create=True)
_assert_cosine(closets, "full-stack new closets")
+233 -5
View File
@@ -3,7 +3,14 @@ import json
import tempfile
import pytest
from mempalace.config import MempalaceConfig, sanitize_kg_value, sanitize_name
from mempalace.config import (
MempalaceConfig,
normalize_wing_name,
sanitize_iso_date,
sanitize_iso_temporal,
sanitize_kg_value,
sanitize_name,
)
def test_default_config():
@@ -20,11 +27,87 @@ def test_config_from_file():
assert cfg.palace_path == "/custom/palace"
def test_env_override():
os.environ["MEMPALACE_PALACE_PATH"] = "/env/palace"
def test_embedding_device_defaults_to_auto(monkeypatch):
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.palace_path == "/env/palace"
del os.environ["MEMPALACE_PALACE_PATH"]
assert cfg.embedding_device == "auto"
def test_embedding_device_from_config_is_normalized(tmp_path, monkeypatch):
monkeypatch.delenv("MEMPALACE_EMBEDDING_DEVICE", raising=False)
with open(tmp_path / "config.json", "w") as f:
json.dump({"embedding_device": " CUDA "}, f)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_device == "cuda"
def test_embedding_device_env_overrides_config(tmp_path, monkeypatch):
with open(tmp_path / "config.json", "w") as f:
json.dump({"embedding_device": "cpu"}, f)
monkeypatch.setenv("MEMPALACE_EMBEDDING_DEVICE", " CoreML ")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.embedding_device == "coreml"
def test_env_override():
raw = "/env/palace"
os.environ["MEMPALACE_PALACE_PATH"] = raw
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
# palace_path normalizes with abspath + expanduser to match the
# --palace CLI code path. On Unix that's a no-op for "/env/palace";
# on Windows abspath prepends the current drive letter.
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
finally:
del os.environ["MEMPALACE_PALACE_PATH"]
def test_env_path_expanduser():
# Tilde must be expanded to match the --palace CLI code path. We don't
# assert "~" is absent from the final string because Windows 8.3 short
# paths (e.g. C:\Users\RUNNER~1\...) legitimately contain tildes — the
# equality check is authoritative.
raw = os.path.join("~", "mempalace-test")
os.environ["MEMPALACE_PALACE_PATH"] = raw
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
assert cfg.palace_path.endswith("mempalace-test")
finally:
del os.environ["MEMPALACE_PALACE_PATH"]
def test_env_path_abspath_collapses_traversal():
# Build a raw path with a .. segment using the platform separator so
# the assertion is portable (Windows uses \, POSIX uses /).
raw = os.path.join(tempfile.gettempdir(), "palace", "..", "mempalace-test")
expected = os.path.abspath(os.path.expanduser(raw))
os.environ["MEMPALACE_PALACE_PATH"] = raw
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
# .. segments must be collapsed, not preserved literally.
assert ".." not in cfg.palace_path
assert cfg.palace_path == expected
finally:
del os.environ["MEMPALACE_PALACE_PATH"]
def test_env_path_legacy_alias_normalized():
# Legacy MEMPAL_PALACE_PATH gets the same normalization treatment as
# MEMPALACE_PALACE_PATH. We don't assert "~" is absent from the final
# string because Windows 8.3 short paths (e.g. C:\Users\RUNNER~1\...)
# legitimately contain tildes — the equality check below is authoritative.
os.environ.pop("MEMPALACE_PALACE_PATH", None)
raw = os.path.join("~", "legacy-alias", "..", "mempalace-test")
os.environ["MEMPAL_PALACE_PATH"] = raw
try:
cfg = MempalaceConfig(config_dir=tempfile.mkdtemp())
assert ".." not in cfg.palace_path
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
finally:
del os.environ["MEMPAL_PALACE_PATH"]
def test_init():
@@ -34,6 +117,25 @@ def test_init():
assert os.path.exists(os.path.join(tmpdir, "config.json"))
# --- normalize_wing_name ---
def test_normalize_wing_name_hyphen():
assert normalize_wing_name("mempal-private") == "mempal_private"
def test_normalize_wing_name_space():
assert normalize_wing_name("My Project") == "my_project"
def test_normalize_wing_name_already_clean():
assert normalize_wing_name("memorymark") == "memorymark"
def test_normalize_wing_name_mixed():
assert normalize_wing_name("My-Cool App") == "my_cool_app"
# --- sanitize_name ---
@@ -117,3 +219,129 @@ def test_kg_value_rejects_null_bytes():
def test_kg_value_rejects_over_length():
with pytest.raises(ValueError):
sanitize_kg_value("a" * 129)
# --- sanitize_iso_date ---
def test_iso_date_rejects_year_only():
# Partial dates re-introduce silent empty result sets via lexicographic
# TEXT comparison in KG queries (e.g. "2026-01-01" <= "2026" is False).
with pytest.raises(ValueError):
sanitize_iso_date("2026")
def test_iso_date_rejects_year_month():
with pytest.raises(ValueError):
sanitize_iso_date("2026-03")
def test_iso_date_accepts_full_date():
assert sanitize_iso_date("2026-03-15") == "2026-03-15"
def test_iso_date_passes_through_none():
assert sanitize_iso_date(None) is None
def test_iso_date_passes_through_empty_string():
assert sanitize_iso_date("") == ""
def test_iso_date_strips_whitespace():
assert sanitize_iso_date(" 2026-03-15 ") == "2026-03-15"
def test_iso_date_rejects_natural_language():
with pytest.raises(ValueError):
sanitize_iso_date("March 2026")
def test_iso_date_rejects_abbreviated_month():
with pytest.raises(ValueError):
sanitize_iso_date("Jan 2025")
def test_iso_date_rejects_us_format():
with pytest.raises(ValueError):
sanitize_iso_date("03/15/2026")
def test_iso_date_rejects_invalid_month():
with pytest.raises(ValueError):
sanitize_iso_date("2026-13")
def test_iso_date_rejects_invalid_day():
with pytest.raises(ValueError):
sanitize_iso_date("2026-02-32")
def test_iso_date_rejects_non_string():
with pytest.raises(ValueError):
sanitize_iso_date(20260315)
def test_iso_date_error_names_field():
with pytest.raises(ValueError, match="valid_from"):
sanitize_iso_date("yesterday", "valid_from")
def test_iso_temporal_accepts_full_date():
assert sanitize_iso_temporal("2026-05-06") == "2026-05-06"
def test_iso_temporal_accepts_canonical_utc_datetime():
assert sanitize_iso_temporal("2026-05-06T14:23:00Z") == "2026-05-06T14:23:00Z"
def test_iso_temporal_strips_datetime_whitespace():
assert sanitize_iso_temporal(" 2026-05-06T14:23:00Z ") == "2026-05-06T14:23:00Z"
def test_iso_date_backward_compatible_wrapper_accepts_datetime():
assert sanitize_iso_date("2026-05-06T14:23:00Z") == "2026-05-06T14:23:00Z"
def test_iso_temporal_rejects_datetime_without_seconds():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-05-06T14:23")
def test_iso_temporal_rejects_naive_datetime():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-05-06T14:23:00")
def test_iso_temporal_rejects_fractional_seconds():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-05-06T14:23:00.123Z")
def test_iso_temporal_rejects_timezone_offset():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-05-06T14:23:00+02:00")
def test_iso_temporal_rejects_space_separator():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-05-06 14:23:00")
def test_iso_temporal_rejects_invalid_datetime_hour():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-05-06T24:00:00Z")
def test_iso_temporal_rejects_invalid_calendar_date():
with pytest.raises(ValueError):
sanitize_iso_temporal("2026-02-31")
def test_iso_temporal_error_names_field():
with pytest.raises(ValueError, match="as_of"):
sanitize_iso_temporal("2026-05-06T14:23", "as_of")
def test_iso_temporal_normalizes_plus_zero_offset_to_z():
assert sanitize_iso_temporal("2026-05-06T14:23:00+00:00") == "2026-05-06T14:23:00Z"
+6 -2
View File
@@ -63,10 +63,14 @@ def test_save_people_map(tmp_path):
def test_env_mempal_palace_path(tmp_path):
"""MEMPAL_PALACE_PATH (legacy) should also work."""
os.environ.pop("MEMPALACE_PALACE_PATH", None)
os.environ["MEMPAL_PALACE_PATH"] = "/legacy/path"
raw = "/legacy/path"
os.environ["MEMPAL_PALACE_PATH"] = raw
try:
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.palace_path == "/legacy/path"
# palace_path is normalized via abspath + expanduser — compare
# against the normalized form so the test is portable between
# POSIX (no-op) and Windows (prepends current drive letter).
assert cfg.palace_path == os.path.abspath(os.path.expanduser(raw))
finally:
del os.environ["MEMPAL_PALACE_PATH"]
+36
View File
@@ -1,6 +1,9 @@
"""Unit tests for convo_miner pure functions (no chromadb needed)."""
import contextlib
from mempalace.convo_miner import (
_file_chunks_locked,
chunk_exchanges,
detect_convo_room,
scan_convos,
@@ -111,3 +114,36 @@ class TestScanConvos:
def test_scan_empty_dir(self, tmp_path):
files = scan_convos(str(tmp_path))
assert files == []
class TestFileChunksLocked:
def test_uses_bounded_upsert_batches(self, monkeypatch):
import mempalace.convo_miner as convo_miner
class FakeCol:
def __init__(self):
self.batch_sizes = []
def delete(self, *args, **kwargs):
pass
def upsert(self, documents, ids, metadatas):
self.batch_sizes.append(len(documents))
chunks = [{"content": f"chunk {i} " * 20, "chunk_index": i} for i in range(5)]
col = FakeCol()
monkeypatch.setattr(convo_miner, "DRAWER_UPSERT_BATCH_SIZE", 2)
monkeypatch.setattr(
convo_miner, "file_already_mined", lambda collection, source_file: False
)
monkeypatch.setattr(convo_miner, "mine_lock", lambda source_file: contextlib.nullcontext())
monkeypatch.setattr(convo_miner, "_detect_hall_cached", lambda content: "conversations")
drawers, room_counts, skipped = _file_chunks_locked(
col, "chat.txt", chunks, "wing", "general", "agent", "exchange"
)
assert drawers == 5
assert dict(room_counts) == {}
assert skipped is False
assert col.batch_sizes == [2, 2, 1]
+218
View File
@@ -0,0 +1,218 @@
"""Tests for mempalace.convo_scanner."""
import json
from pathlib import Path
from mempalace.convo_scanner import (
_decode_slug_fallback,
_extract_cwd_from_session,
_resolve_project_name,
_safe_mtime,
is_claude_projects_root,
scan_claude_projects,
)
# ── is_claude_projects_root ─────────────────────────────────────────────
def test_is_claude_projects_root_true(tmp_path):
project_dir = tmp_path / "-home-user-dev-foo"
project_dir.mkdir()
(project_dir / "abc.jsonl").write_text("{}\n")
assert is_claude_projects_root(tmp_path)
def test_is_claude_projects_root_false_no_dash_prefix(tmp_path):
project_dir = tmp_path / "normal-folder"
project_dir.mkdir()
(project_dir / "abc.jsonl").write_text("{}\n")
assert not is_claude_projects_root(tmp_path)
def test_is_claude_projects_root_false_no_jsonl(tmp_path):
project_dir = tmp_path / "-home-user-foo"
project_dir.mkdir()
(project_dir / "other.txt").write_text("hello")
assert not is_claude_projects_root(tmp_path)
def test_is_claude_projects_root_false_empty(tmp_path):
assert not is_claude_projects_root(tmp_path)
def test_is_claude_projects_root_false_nonexistent(tmp_path):
assert not is_claude_projects_root(tmp_path / "does-not-exist")
# ── cwd extraction ──────────────────────────────────────────────────────
def test_extract_cwd_from_session(tmp_path):
f = tmp_path / "session.jsonl"
lines = [
json.dumps({"type": "file-history-snapshot", "messageId": "x"}),
json.dumps({"type": "user", "cwd": "/home/user/dev/myproj", "content": "hi"}),
]
f.write_text("\n".join(lines) + "\n")
assert _extract_cwd_from_session(f) == "/home/user/dev/myproj"
def test_extract_cwd_from_session_skips_malformed(tmp_path):
f = tmp_path / "session.jsonl"
f.write_text(
"{not valid json\n" + json.dumps({"type": "user", "cwd": "/home/user/dev/good"}) + "\n"
)
assert _extract_cwd_from_session(f) == "/home/user/dev/good"
def test_extract_cwd_from_session_none_if_absent(tmp_path):
f = tmp_path / "session.jsonl"
f.write_text(json.dumps({"type": "x", "messageId": "y"}) + "\n")
assert _extract_cwd_from_session(f) is None
def test_extract_cwd_from_session_none_if_file_missing(tmp_path):
assert _extract_cwd_from_session(tmp_path / "missing.jsonl") is None
# ── slug fallback ───────────────────────────────────────────────────────
def test_decode_slug_fallback_last_segment():
assert _decode_slug_fallback("-home-user-dev-foo") == "foo"
def test_decode_slug_fallback_double_dash():
assert _decode_slug_fallback("-home-user--bentokit") == "bentokit"
def test_decode_slug_fallback_empty():
assert _decode_slug_fallback("") == ""
def test_decode_slug_fallback_only_dashes():
assert _decode_slug_fallback("---") == "---"
# ── safe metadata helpers ───────────────────────────────────────────────
def test_safe_mtime_returns_zero_on_stat_error(tmp_path, monkeypatch):
f = tmp_path / "session.jsonl"
f.write_text("{}\n")
original_stat = Path.stat
def fail_stat(self):
if self == f:
raise OSError("permission denied")
return original_stat(self)
monkeypatch.setattr(Path, "stat", fail_stat)
assert _safe_mtime(f) == 0.0
# ── _resolve_project_name ───────────────────────────────────────────────
def test_resolve_project_name_uses_cwd(tmp_path):
pdir = tmp_path / "-home-user-dev-coolproj"
pdir.mkdir()
session = pdir / "a.jsonl"
session.write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/cool-proj-real"}) + "\n")
assert _resolve_project_name(pdir) == "cool-proj-real"
def test_resolve_project_name_falls_back_when_no_cwd(tmp_path):
pdir = tmp_path / "-home-user-dev-foo"
pdir.mkdir()
(pdir / "a.jsonl").write_text(json.dumps({"type": "x"}) + "\n")
assert _resolve_project_name(pdir) == "foo"
def test_resolve_project_name_prefers_newer_session(tmp_path):
"""Newest session's cwd wins — covers the case where user renamed the
project directory between sessions."""
pdir = tmp_path / "-home-user-dev-old"
pdir.mkdir()
old = pdir / "old.jsonl"
old.write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/old"}) + "\n")
# Ensure distinguishable mtimes
old_mtime = old.stat().st_mtime - 100
import os
os.utime(old, (old_mtime, old_mtime))
new = pdir / "new.jsonl"
new.write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/new-name"}) + "\n")
assert _resolve_project_name(pdir) == "new-name"
# ── scan_claude_projects ────────────────────────────────────────────────
def test_scan_claude_projects_empty_dir(tmp_path):
assert scan_claude_projects(tmp_path) == []
def test_scan_claude_projects_not_a_projects_root(tmp_path):
"""Returns empty list if the dir doesn't look like .claude/projects/."""
(tmp_path / "some-folder").mkdir()
(tmp_path / "some-folder" / "readme.md").write_text("hi")
assert scan_claude_projects(tmp_path) == []
def test_scan_claude_projects_finds_projects(tmp_path):
p1 = tmp_path / "-home-user-dev-alpha"
p1.mkdir()
(p1 / "a.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/alpha"}) + "\n")
(p1 / "b.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/alpha"}) + "\n")
p2 = tmp_path / "-home-user-dev-beta"
p2.mkdir()
(p2 / "x.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/beta"}) + "\n")
result = scan_claude_projects(tmp_path)
names = [p.name for p in result]
assert "alpha" in names
assert "beta" in names
# alpha has 2 sessions, beta has 1 — alpha ranks higher
alpha = next(p for p in result if p.name == "alpha")
beta = next(p for p in result if p.name == "beta")
assert alpha.user_commits == 2
assert beta.user_commits == 1
def test_scan_claude_projects_ignores_dirs_without_jsonl(tmp_path):
empty_proj = tmp_path / "-home-user-dev-empty"
empty_proj.mkdir()
(empty_proj / "notes.md").write_text("hi")
assert scan_claude_projects(tmp_path) == []
def test_scan_claude_projects_marks_as_mine(tmp_path):
p = tmp_path / "-home-user-dev-owned"
p.mkdir()
(p / "s.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/dev/owned"}) + "\n")
result = scan_claude_projects(tmp_path)
assert len(result) == 1
assert result[0].is_mine is True
def test_scan_claude_projects_dedup_by_name(tmp_path):
"""Two encoded dirs resolving to the same project name collapse to one."""
p1 = tmp_path / "-home-user-a-proj"
p1.mkdir()
(p1 / "s.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/a/proj"}) + "\n")
(p1 / "t.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/a/proj"}) + "\n")
p2 = tmp_path / "-home-user-b-proj"
p2.mkdir()
(p2 / "u.jsonl").write_text(json.dumps({"type": "user", "cwd": "/home/user/b/proj"}) + "\n")
result = scan_claude_projects(tmp_path)
# Both decode to "proj"; only one remains — the one with more sessions wins
assert len(result) == 1
assert result[0].name == "proj"
assert result[0].user_commits == 2
+395
View File
@@ -0,0 +1,395 @@
"""Tests for corpus_origin detection.
The corpus-origin detector answers ONE foundational question before any
downstream Pass 2 classification runs:
"Is this corpus a record of AI-agent dialogue, and if so, which platform
and what persona names has the user assigned to the agent?"
Detection is two-tier:
- Tier 1: cheap content-aware heuristic (grep for well-known AI terms
and turn markers). No API calls. Always runs.
- Tier 2: LLM-assisted confirmation + persona extraction. Takes a small
sample of drawer texts and uses Haiku's pre-trained world knowledge
about Claude/ChatGPT/Gemini/etc. to confirm platform + identify
persona-names the user assigned to the agent.
Default stance: "this IS an AI-dialogue corpus" unless strong evidence
otherwise. False-negative (missing an AI corpus) is catastrophic for
downstream classification; false-positive is recoverable via per-drawer
voice-profile detection in later passes.
TDD: these tests fail until mempalace/corpus_origin.py is implemented."""
from mempalace.corpus_origin import (
CorpusOriginResult,
detect_origin_heuristic,
detect_origin_llm,
)
# ── Tier 1: heuristic (no LLM) ────────────────────────────────────────────
class TestHeuristic:
def test_claude_heavy_corpus_detected(self):
"""A corpus with abundant Claude references + turn markers should
be confidently detected as AI-dialogue."""
samples = [
"user: hey Claude, can you help me\nassistant: sure, what do you need\n",
"I was talking to Claude Opus about the MCP server setup",
"Sonnet 4.5 handled this better than Haiku 4.5 did",
"claude mcp add mempalace -- mempalace-mcp",
"human: what's up\nassistant: I'm happy to help",
]
result = detect_origin_heuristic(samples)
assert result.likely_ai_dialogue is True
assert result.confidence >= 0.8
assert (
"Claude" in " ".join(result.evidence) or "claude" in " ".join(result.evidence).lower()
)
def test_gpt_corpus_detected(self):
samples = [
"I asked ChatGPT to summarize my paper",
"The GPT-4 response was surprisingly good",
"user: explain quantum computing\nassistant: quantum computing uses qubits",
"OpenAI's model was able to help with the code",
]
result = detect_origin_heuristic(samples)
assert result.likely_ai_dialogue is True
assert any("GPT" in e or "ChatGPT" in e or "OpenAI" in e for e in result.evidence)
def test_pure_narrative_corpus_detected_as_not_ai(self):
"""A story/journal corpus with no AI signals should be flagged
not-AI (default stance flipped only with evidence)."""
samples = [
"Today the cat finally ventured into the garden. The dog watched.",
"The morning light came through the window as I wrote.",
"Chapter 3: The Reckoning. It was a dark and stormy night.",
"My father's old journal described the same field in 1972.",
]
result = detect_origin_heuristic(samples)
assert result.likely_ai_dialogue is False
assert result.confidence >= 0.8
def test_ambiguous_corpus_defaults_to_ai(self):
"""When evidence is thin or mixed, default to assuming AI-dialogue.
False-negative is worse than false-positive."""
samples = [
"some notes about the meeting today",
"Later on I went to the store.",
"Short file with little signal.",
]
result = detect_origin_heuristic(samples)
# Low signal → default stance is ai_dialogue=True with low confidence
assert result.likely_ai_dialogue is True
assert result.confidence <= 0.6
assert "default-stance" in " ".join(result.evidence).lower()
def test_turn_markers_alone_sufficient(self):
"""Even without AI brand mentions, strong turn-marker presence
indicates dialogue structure consistent with AI corpora."""
samples = [
"user: hello\nassistant: hi there, how can I help?\nuser: summarize X\nassistant: sure",
"human: what's the weather\nai: I don't have real-time data\n",
]
result = detect_origin_heuristic(samples)
assert result.likely_ai_dialogue is True
# ── Pattern + context (not capitalization, not English-rule) ──────────
def test_brand_terms_case_insensitive(self):
"""Detection cannot rely on the user typing proper-cased brand names.
Lowercase 'claude code', 'chatgpt', 'gemini-pro', 'mcp' must trip
the same as their proper-cased equivalents. NO turn-marker fallback
in this corpus — the brand matches must do the work."""
samples = [
"i love claude code, it just works for refactoring tasks",
"asked chatgpt to write a regex and it nailed it on the first try",
"switched to gemini-pro for the long-context summary task last week",
"added mempalace as an mcp server in my .claude/ settings file",
"anthropic's haiku model is cheap enough to run on every drawer",
]
result = detect_origin_heuristic(samples)
assert (
result.likely_ai_dialogue is True
), f"lowercase brand terms missed; evidence: {result.evidence}"
# Evidence must show MULTIPLE distinct case-insensitive brand matches.
# 'chatgpt' lowercase only matches under case-insensitive search
# (the brand list has 'ChatGPT' proper-cased only).
evidence_str = " ".join(result.evidence).lower()
matched = sum(t in evidence_str for t in ("chatgpt", "anthropic", "haiku", "gemini-pro"))
assert (
matched >= 2
), f"case-insensitive brand matches did not fire — only got: {result.evidence}"
def test_zodiac_corpus_not_flagged_as_ai(self):
"""An astrology forum post with high 'Gemini' density but ZERO
unambiguous AI signals (no MCP/LLM/ChatGPT/turn markers) must NOT
be flagged as AI-dialogue. Word-sense disambiguation is required:
Gemini-the-zodiac-sign vs Gemini-the-AI-platform."""
samples = [
"I'm a Gemini sun, Pisces moon, and Leo rising.",
"Geminis are dreamers and overthinkers — that's the dual nature.",
"Compatibility between Gemini and Sagittarius is famously strong.",
"If you're a Gemini, expect Mercury retrograde to hit you hardest.",
"My horoscope this week says Gemini energy will dominate Wednesday.",
"The Gemini twins in Greek mythology are Castor and Pollux.",
]
result = detect_origin_heuristic(samples)
assert (
result.likely_ai_dialogue is False
), f"zodiac corpus wrongly flagged AI; evidence: {result.evidence}"
def test_french_novel_with_claude_name_not_flagged(self):
"""A French novel where 'Claude' is a character name (Claude is a
common French masculine name) must NOT trip AI-dialogue detection.
Disambiguation is by context, not by the presence of the word."""
samples = [
"Claude marchait lentement le long de la Seine ce matin-là.",
"« Claude, tu rentres dîner? » lui demanda sa mère depuis la cuisine.",
"Pour Claude, l'art de vivre passait avant tout par la patience.",
"Le vieux Claude se souvenait encore de la guerre, des champs déserts.",
"Claude ouvrit la fenêtre. Le matin sentait le pain frais et la pluie.",
"Les amis de Claude s'étaient réunis chez lui pour fêter ses soixante ans.",
]
result = detect_origin_heuristic(samples)
assert (
result.likely_ai_dialogue is False
), f"French novel wrongly flagged AI; evidence: {result.evidence}"
def test_poetry_corpus_with_haiku_sonnet_not_flagged(self):
"""A poetry corpus with high 'haiku', 'sonnet', 'opus' density
(poetic forms / classical music terms) but no AI infrastructure
terms must NOT be flagged as AI-dialogue."""
samples = [
"A haiku is seventeen syllables across three lines: 5-7-5.",
"Shakespeare's sonnet 18 remains the most quoted in the English canon.",
"Beethoven's opus 27 includes the Moonlight Sonata.",
"I wrote three haiku this morning before coffee.",
"The sonnet form arrived in England via Wyatt and Surrey.",
"Her first opus, published at twenty, was a song cycle for soprano.",
]
result = detect_origin_heuristic(samples)
assert (
result.likely_ai_dialogue is False
), f"poetry corpus wrongly flagged AI; evidence: {result.evidence}"
def test_word_boundary_brand_matching(self):
"""Brand-term matching must use word boundaries. Embedded matches
inside larger words ('Claudette' → 'Claude', 'opuscule' → 'Opus',
'sonneteer' → 'Sonnet', 'llamas' → 'Llama', 'bardic' → 'Bard')
must NOT be counted as brand hits.
Word boundaries don't change classification on the co-occurrence-
suppressed cases, but they clean up the evidence strings — false
matches must not appear in the audit trail. They also prevent
'Claude Code' from triple-counting as 'Claude Code' + 'Claude'
overlap."""
samples = [
"My grandmother Claudette baked the most beautiful tarts every Sunday.",
"Two llamas were spotted near the trailhead this morning at sunrise.",
"Beethoven's opuscule for solo violin remained unpublished for decades.",
"She studied to become a sonneteer after reading the full Spenser cycle.",
"Bardic traditions in the Hebrides survived well into the eighteenth century.",
"The complete opuses of Mozart fill an entire wall of the library.",
]
result = detect_origin_heuristic(samples)
evidence_str = " ".join(result.evidence).lower()
# None of the brand terms should show up in evidence — every
# would-be match is an embedded false-positive that word
# boundaries should suppress.
for embedded_term in ("claude", "opus", "sonnet", "llama", "bard"):
assert f"'{embedded_term}'" not in evidence_str, (
f"word-boundary bug: '{embedded_term}' falsely matched inside "
f"a longer word — evidence: {result.evidence}"
)
# And classification should be not-AI (no real AI signals present).
assert (
result.likely_ai_dialogue is False
), f"corpus has no real AI signals; evidence: {result.evidence}"
def test_ambiguous_brand_with_unambiguous_signal_flagged(self):
"""When an ambiguous brand term ('Gemini') co-occurs with an
UNAMBIGUOUS AI signal (turn markers, MCP, ChatGPT, Claude Code)
in the same corpus, the Gemini hits SHOULD count and the corpus
SHOULD be flagged as AI-dialogue."""
samples = [
"Switched the agent from Gemini to ChatGPT mid-session for cost reasons.",
"Gemini handled the long-context task; user: please summarize\nassistant: here is the summary",
"user: try Gemini for this\nassistant: running it through gemini-pro now",
"MCP server config: Gemini as primary, OpenAI as fallback.",
]
result = detect_origin_heuristic(samples)
assert (
result.likely_ai_dialogue is True
), f"ambiguous+unambiguous co-occurrence missed; evidence: {result.evidence}"
# ── Tier 2: LLM-assisted (mocked) ─────────────────────────────────────────
class _FakeProvider:
"""Minimal stand-in for mempalace's LLMProvider used for testing."""
def __init__(self, canned_response):
self._response = canned_response
self.calls = []
def classify(self, system, user, json_mode=True):
self.calls.append({"system": system, "user": user})
class R:
text = self._response
return R()
def check_available(self):
return True, "ok"
class TestLLMConfirmation:
def test_extracts_persona_names_and_platform(self):
fake_response = """{
"is_ai_dialogue_corpus": true,
"confidence": 0.97,
"primary_platform": "Claude Code (Anthropic CLI)",
"agent_persona_names": ["Echo", "Sparrow", "Cipher", "Orc"],
"evidence": [
"user addresses agent as 'Echo' on assistant turns",
"Claude Code banner text in samples",
"references to MCP, CLAUDE.md, hooks"
]
}"""
provider = _FakeProvider(fake_response)
samples = [
"user: hey Echo, what's up\nassistant: I'm here, what do you need\n",
"Claude Code session banner Sonnet 4.5 Claude Pro",
]
result = detect_origin_llm(samples, provider)
assert result.likely_ai_dialogue is True
assert result.confidence >= 0.9
assert "Echo" in result.agent_persona_names
assert "Sparrow" in result.agent_persona_names
assert "Claude" in result.primary_platform
def test_narrative_corpus_llm_confirms_no_agent(self):
fake_response = """{
"is_ai_dialogue_corpus": false,
"confidence": 0.95,
"primary_platform": null,
"agent_persona_names": [],
"evidence": ["pure narrative prose, no turn markers, no AI terms"]
}"""
provider = _FakeProvider(fake_response)
samples = ["Once upon a time in a small village", "The old woman smiled"]
result = detect_origin_llm(samples, provider)
assert result.likely_ai_dialogue is False
assert result.agent_persona_names == []
assert result.primary_platform is None
def test_handles_malformed_llm_response(self):
"""If the LLM returns garbage, fall back gracefully to the
conservative default (assume AI-dialogue with low confidence)."""
provider = _FakeProvider("not even close to JSON")
result = detect_origin_llm(["sample text"], provider)
# Fallback: conservative default, low confidence
assert result.likely_ai_dialogue is True
assert result.confidence <= 0.5
assert (
"fallback" in " ".join(result.evidence).lower()
or "error" in " ".join(result.evidence).lower()
)
def test_filters_user_name_out_of_personas(self):
"""Regression test: Haiku sometimes leaks the user's own name into
agent_persona_names despite the prompt's CRITICAL distinction. The
parser must strip the user's name from personas if it appears in
both fields (case-insensitive). The user is the human author of
the corpus, not an agent persona."""
fake_response = """{
"is_ai_dialogue_corpus": true,
"confidence": 0.97,
"primary_platform": "Claude (Anthropic)",
"user_name": "Jordan",
"agent_persona_names": ["Echo", "Sparrow", "Jordan", "Cipher"],
"evidence": ["user Jordan talks to agents Echo/Sparrow/Cipher"]
}"""
provider = _FakeProvider(fake_response)
result = detect_origin_llm(["sample"], provider)
# user_name is exposed in its own field
assert result.user_name == "Jordan"
# "Jordan" is filtered out of agent_persona_names
assert "Jordan" not in result.agent_persona_names
# Real personas are preserved
for persona in ("Echo", "Sparrow", "Cipher"):
assert persona in result.agent_persona_names
def test_filter_is_case_insensitive(self):
"""The user-name filter works even when the LLM returns a casing
mismatch between user_name and the personas list."""
fake_response = """{
"is_ai_dialogue_corpus": true,
"confidence": 0.9,
"primary_platform": "Claude",
"user_name": "Jordan",
"agent_persona_names": ["Echo", "jordan", "JORDAN", "Cipher"],
"evidence": []
}"""
provider = _FakeProvider(fake_response)
result = detect_origin_llm(["sample"], provider)
# All case-variants of the user's name are filtered
assert "jordan" not in [p.lower() for p in result.agent_persona_names]
assert result.agent_persona_names == ["Echo", "Cipher"]
def test_user_name_field_surfaces_author(self):
"""The user_name field captures the human author of the corpus,
separate from agent personas. This gives downstream passes a
clear 'who is the user, who is the agent' distinction."""
fake_response = """{
"is_ai_dialogue_corpus": true,
"confidence": 0.95,
"primary_platform": "ChatGPT (OpenAI)",
"user_name": "Sarah",
"agent_persona_names": ["MyAssistant"],
"evidence": ["Sarah writes to MyAssistant"]
}"""
provider = _FakeProvider(fake_response)
result = detect_origin_llm(["sample"], provider)
assert result.user_name == "Sarah"
assert result.agent_persona_names == ["MyAssistant"]
# ── CorpusOriginResult dataclass ──────────────────────────────────────────
class TestResultDataclass:
def test_result_has_all_fields(self):
r = CorpusOriginResult(
likely_ai_dialogue=True,
confidence=0.95,
primary_platform="Claude Code",
agent_persona_names=["Echo"],
evidence=["test"],
)
assert r.likely_ai_dialogue is True
assert r.confidence == 0.95
assert r.primary_platform == "Claude Code"
assert r.agent_persona_names == ["Echo"]
assert r.evidence == ["test"]
def test_result_serializes_to_dict(self):
r = CorpusOriginResult(
likely_ai_dialogue=False,
confidence=0.9,
primary_platform=None,
agent_persona_names=[],
evidence=[],
)
d = r.to_dict()
assert d["likely_ai_dialogue"] is False
assert d["primary_platform"] is None
assert d["agent_persona_names"] == []
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
import pytest
import mempalace.embedding as embedding
@pytest.fixture(autouse=True)
def isolate_embedding_state(monkeypatch):
monkeypatch.setattr(embedding, "_EF_CACHE", {})
monkeypatch.setattr(embedding, "_WARNED", set())
def test_auto_picks_cuda(monkeypatch):
monkeypatch.setattr(
"onnxruntime.get_available_providers",
lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"],
)
assert embedding._resolve_providers("auto") == (
["CUDAExecutionProvider", "CPUExecutionProvider"],
"cuda",
)
def test_auto_falls_to_cpu(monkeypatch):
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
assert embedding._resolve_providers("auto") == (["CPUExecutionProvider"], "cpu")
def test_cuda_missing_warns_with_gpu_extra(monkeypatch, caplog):
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
assert embedding._resolve_providers("cuda") == (["CPUExecutionProvider"], "cpu")
assert "mempalace[gpu]" in caplog.text
def test_coreml_missing_warns_with_coreml_extra(monkeypatch, caplog):
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
assert embedding._resolve_providers("coreml") == (["CPUExecutionProvider"], "cpu")
assert "mempalace[coreml]" in caplog.text
def test_dml_missing_warns_with_dml_extra(monkeypatch, caplog):
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
assert embedding._resolve_providers("dml") == (["CPUExecutionProvider"], "cpu")
assert "mempalace[dml]" in caplog.text
def test_unknown_device_warns_once(monkeypatch, caplog):
monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])
assert embedding._resolve_providers("bogus") == (["CPUExecutionProvider"], "cpu")
assert embedding._resolve_providers("bogus") == (["CPUExecutionProvider"], "cpu")
assert caplog.text.count("Unknown embedding_device") == 1
def test_onnxruntime_import_error_falls_back_to_cpu(monkeypatch):
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "onnxruntime":
raise ImportError("missing")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
assert embedding._resolve_providers("cuda") == (["CPUExecutionProvider"], "cpu")
def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
class DummyEF:
def __init__(self, preferred_providers):
self.preferred_providers = preferred_providers
monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
monkeypatch.setattr(
embedding, "_resolve_providers", lambda device: (["CPUExecutionProvider"], "cpu")
)
first = embedding.get_embedding_function("cpu")
second = embedding.get_embedding_function("auto")
assert first is second
assert first.preferred_providers == ["CPUExecutionProvider"]
def test_describe_device_uses_resolved_effective_device(monkeypatch):
monkeypatch.setattr(
embedding,
"_resolve_providers",
lambda device: (["CUDAExecutionProvider", "CPUExecutionProvider"], "cuda"),
)
assert embedding.describe_device("auto") == "cuda"
+128 -2
View File
@@ -148,6 +148,33 @@ def test_classify_entity_pronoun_only_is_uncertain():
assert result["type"] == "uncertain"
def test_classify_entity_high_pronoun_signal_is_person():
"""A diary's main character hit by many pronouns should still classify
as a person even with only the pronoun signal category. Example from
real data: `Lu` has 16 pronoun hits out of 30 mentions."""
scores = {
"person_score": 32,
"project_score": 0,
"person_signals": ["pronoun nearby (16x)"],
"project_signals": [],
}
result = classify_entity("Lu", 30, scores)
assert result["type"] == "person"
def test_classify_entity_low_pronoun_proximity_is_uncertain():
"""Common sentence-start words (Never, Before) get a few pronouns nearby
incidentally. The ratio stays low (<20%), so they stay uncertain."""
scores = {
"person_score": 4,
"project_score": 0,
"person_signals": ["pronoun nearby (2x)"],
"project_signals": [],
}
result = classify_entity("Never", 21, scores)
assert result["type"] == "uncertain"
def test_classify_entity_mixed_signals():
scores = {
"person_score": 5,
@@ -208,13 +235,13 @@ def test_detect_entities_empty_files(tmp_path):
f = tmp_path / "empty.txt"
f.write_text("")
result = detect_entities([f])
assert result == {"people": [], "projects": [], "uncertain": []}
assert result == {"people": [], "projects": [], "topics": [], "uncertain": []}
def test_detect_entities_handles_missing_file(tmp_path):
missing = tmp_path / "nonexistent.txt"
result = detect_entities([missing])
assert result == {"people": [], "projects": [], "uncertain": []}
assert result == {"people": [], "projects": [], "topics": [], "uncertain": []}
def test_detect_entities_respects_max_files(tmp_path):
@@ -661,3 +688,102 @@ def test_boundary_chars_english_regression():
result = extract_candidates(text, languages=("en",))
assert "Riley" in result
assert result["Riley"] >= 3
# ── Chinese (zh-TW / zh-CN) entity detection ──────────────────────────
# CJK scripts have no word delimiters — a regex-based extractor can only
# catch names when they have a non-CJK neighbour (whitespace, punctuation,
# newline, or mixed English). Real-world technical notes in zh-TW / zh-CN
# routinely satisfy this: names appear at the start of bullet lines, next
# to English terms, or before full-width punctuation 「」:,。. The patterns
# below target that realistic regime.
def test_zh_tw_candidate_extraction_at_boundaries():
"""A 3-char Traditional Chinese name is extracted when neighboured by
whitespace, English, full-width punctuation, or line-start."""
text = (
"# 會議紀錄\n"
"- 朱宜振 主持\n"
"朱宜振 跟 Jeffrey 討論。\n"
"朱宜振: 方向正確。\n"
"朱宜振, 明天 pitch。\n"
)
result = extract_candidates(text, languages=("zh-TW",))
assert "朱宜振" in result, f"expected 朱宜振 in {result}"
assert result["朱宜振"] >= 3
def test_zh_tw_person_classification():
"""A Traditional Chinese name with dialogue + verb context classifies
as a person."""
text = (
"朱宜振: 「我們要 6 月 launch。」\n"
"朱宜振 同意 Arnold 的方案。\n"
"朱宜振 覺得 Hermes 方向對。\n"
"朱宜振 決定 ship pitch。\n"
)
lines = text.splitlines()
scores = score_entity("朱宜振", text, lines, languages=("zh-TW",))
# Dialogue + action signals fire — person score dominates
assert scores["person_score"] > 0, f"expected person signals, got {scores}"
def test_zh_tw_stopwords_filter_common_particles():
"""Common Chinese particles / pronouns should be stopword-filtered
even if they happen to share a surname prefix like 甘 or 習."""
from mempalace.i18n import get_entity_patterns
patterns = get_entity_patterns(("zh-TW",))
stopwords = set(patterns["stopwords"])
# Sanity: stopwords are lower-cased from the source list
assert "這個" in stopwords
assert "我們" in stopwords
assert "他們" in stopwords
assert "完成" in stopwords
def test_zh_tw_falls_back_to_english_for_non_cjk_names():
"""English names embedded in Chinese text are still captured via the
English pattern — Lman's Chinese notes mix in names like 'Jeffrey Lai'."""
text = (
"朱宜振 跟 Jeffrey Lai 討論 pitch。\n"
"Jeffrey Lai 報告進度。\n"
"朱宜振 同意 Jeffrey Lai 的方案。\n"
"朱宜振: 確認。\n"
)
result = extract_candidates(text, languages=("zh-TW", "en"))
assert "Jeffrey Lai" in result or "Jeffrey" in result
assert "朱宜振" in result
def test_zh_cn_candidate_extraction():
"""Simplified-Chinese name extraction mirrors zh-TW behaviour."""
text = "张三 今天主持。\n- 张三 跟 Bob 谈。\n张三: 已经搞定了。\n张三, 明天继续。\n"
result = extract_candidates(text, languages=("zh-CN",))
assert "张三" in result, f"expected 张三 in {result}"
assert result["张三"] >= 3
def test_zh_cn_and_zh_tw_union_covers_both_variants():
"""Passing both zh-CN and zh-TW unions the surname sets — a document
mixing simplified 张三 and traditional 張三 extracts both."""
text = "张三 说 hello。张三 笑了。张三 同意。\n張三 也參加。張三 寫 code。張三 決定。\n"
result = extract_candidates(text, languages=("zh-TW", "zh-CN"))
# At least one variant meets freq>=3
assert "张三" in result or "張三" in result
def test_zh_tw_known_limitation_inline_name_no_boundary():
"""Documented limitation: a name sandwiched between CJK chars with no
whitespace or punctuation break is not extracted. This is a fundamental
limit of regex-based CJK entity detection — words have no delimiters.
Realistic Chinese writing has enough non-CJK boundaries (punctuation,
newlines, mixed English) that 3+ occurrences normally produce matches
elsewhere in the document, so this rarely degrades real-world recall."""
# 朱宜振 appears 4x but every instance is flanked by CJK on both sides.
text = "他是朱宜振今天來。說朱宜振決定。又朱宜振負責。問朱宜振意見。"
result = extract_candidates(text, languages=("zh-TW",))
# Extraction is expected to miss this adversarial case.
assert "朱宜振" not in result
+46
View File
@@ -2,6 +2,8 @@
from unittest.mock import patch
import pytest
from mempalace.entity_registry import (
COMMON_ENGLISH_WORDS,
PERSON_CONTEXT_PATTERNS,
@@ -71,6 +73,50 @@ def test_save_creates_file(tmp_path):
assert (tmp_path / "entity_registry.json").exists()
def test_save_is_atomic_does_not_leave_tmp(tmp_path):
# Atomic write must not leave the .tmp sidecar file after a successful save.
registry = EntityRegistry.load(config_dir=tmp_path)
registry.save()
leftover = list(tmp_path.glob("entity_registry.json.tmp*"))
assert leftover == [], f"atomic write leaked tmp file(s): {leftover}"
def test_save_preserves_previous_on_serialization_failure(tmp_path, monkeypatch):
# If serialization fails mid-write, the previous registry must remain
# intact — this is the whole point of atomic write vs truncating in place.
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(
mode="personal",
people=[{"name": "Alice", "relationship": "friend", "context": "personal"}],
projects=[],
)
registry.save()
target = tmp_path / "entity_registry.json"
original = target.read_text(encoding="utf-8")
# Force os.replace to raise — simulates filesystem full / permission flip
# AFTER the temp file is written but BEFORE the rename completes.
import os as _os
real_replace = _os.replace
def boom(src, dst):
raise OSError("simulated rename failure")
monkeypatch.setattr(_os, "replace", boom)
with pytest.raises(OSError):
registry.seed(
mode="personal",
people=[{"name": "Bob", "relationship": "friend", "context": "personal"}],
projects=[],
)
registry.save()
# Restore os.replace before reading so the assertion can rely on it.
monkeypatch.setattr(_os, "replace", real_replace)
assert target.read_text(encoding="utf-8") == original
# ── seed ────────────────────────────────────────────────────────────────
+101
View File
@@ -134,3 +134,104 @@ def test_export_empty_palace():
assert stats == {"wings": 0, "rooms": 0, "drawers": 0}
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def _try_symlink_or_skip(target: str, link: str):
"""Create a symlink, skipping the test if the runtime forbids it.
Windows without Developer Mode/admin and some restricted CI sandboxes
refuse os.symlink with OSError or NotImplementedError. The exporter
hardening is meaningful only where symlinks can be created at all, so
skipping is preferable to a hard failure.
"""
import pytest
try:
os.symlink(target, link)
except (OSError, NotImplementedError) as e:
pytest.skip(f"symlink creation not supported in this environment: {e}")
def test_export_refuses_symlinked_output_dir():
"""A symlink at the output path must not be followed (defense-in-depth)."""
import pytest
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
decoy_target = os.path.join(tmpdir, "decoy_target")
os.makedirs(decoy_target)
output_dir = os.path.join(tmpdir, "export")
_try_symlink_or_skip(decoy_target, output_dir)
with pytest.raises(ValueError, match="symbolic link"):
export_palace(palace_path, output_dir)
# Decoy target must remain empty — nothing followed the symlink.
assert os.listdir(decoy_target) == []
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_refuses_symlinked_wing_dir():
"""A symlink pre-placed at a wing subdirectory must also be refused."""
import pytest
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
decoy_target = os.path.join(tmpdir, "decoy_target")
os.makedirs(decoy_target)
output_dir = os.path.join(tmpdir, "export")
os.makedirs(output_dir)
_try_symlink_or_skip(decoy_target, os.path.join(output_dir, "alpha"))
with pytest.raises(ValueError, match="symbolic link"):
export_palace(palace_path, output_dir)
assert os.listdir(decoy_target) == []
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_refuses_symlinked_room_file():
"""A symlink pre-placed at a room file path must not be followed."""
import pytest
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
decoy_target = os.path.join(tmpdir, "decoy_target.md")
Path(decoy_target).write_text("untouched\n", encoding="utf-8")
output_dir = os.path.join(tmpdir, "export")
os.makedirs(os.path.join(output_dir, "alpha"))
_try_symlink_or_skip(decoy_target, os.path.join(output_dir, "alpha", "backend.md"))
with pytest.raises(ValueError, match="symbolic link"):
export_palace(palace_path, output_dir)
# Decoy file must remain unchanged — open did not follow the symlink.
assert Path(decoy_target).read_text(encoding="utf-8") == "untouched\n"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_export_refuses_symlinked_index_file():
"""A symlink pre-placed at output_dir/index.md must not be followed."""
import pytest
tmpdir = tempfile.mkdtemp()
try:
palace_path = _setup_palace(tmpdir)
decoy_target = os.path.join(tmpdir, "decoy_index.md")
Path(decoy_target).write_text("untouched\n", encoding="utf-8")
output_dir = os.path.join(tmpdir, "export")
os.makedirs(output_dir)
_try_symlink_or_skip(decoy_target, os.path.join(output_dir, "index.md"))
with pytest.raises(ValueError, match="symbolic link"):
export_palace(palace_path, output_dir)
assert Path(decoy_target).read_text(encoding="utf-8") == "untouched\n"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+63
View File
@@ -286,3 +286,66 @@ class TestCLI:
assert "similar_name" in out
# Silence unused import warning.
_ = (MagicMock, patch, fact_checker)
def test_reconfigures_stdio_to_utf8_on_windows(self):
"""Windows fact_checker --stdin must decode payload as UTF-8.
Without this, Python defaults stdio to the system ANSI codepage
(cp1252/cp1251/cp950), which mojibakes non-ASCII text before
pattern parsing sees it.
"""
import io
import sys
from mempalace.fact_checker import _reconfigure_stdio_utf8_on_windows
class _ReconfigurableStringIO(io.StringIO):
def __init__(self, initial_value=""):
super().__init__(initial_value)
self.reconfigure_calls = []
def reconfigure(self, **kwargs):
self.reconfigure_calls.append(kwargs)
stdin = _ReconfigurableStringIO()
stdout = _ReconfigurableStringIO()
stderr = _ReconfigurableStringIO()
with (
patch.object(sys, "platform", "win32"),
patch.object(sys, "stdin", stdin),
patch.object(sys, "stdout", stdout),
patch.object(sys, "stderr", stderr),
):
_reconfigure_stdio_utf8_on_windows()
# Per-stream errors policy: stdin uses surrogateescape so a stray
# malformed byte from a redirected file does not crash the read,
# stdout/stderr use replace so an extracted fact carrying a
# surrogate half does not crash mid-print.
assert stdin.reconfigure_calls == [{"encoding": "utf-8", "errors": "surrogateescape"}]
assert stdout.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}]
assert stderr.reconfigure_calls == [{"encoding": "utf-8", "errors": "replace"}]
def test_reconfigure_stdio_is_noop_off_windows(self):
"""Linux/macOS already default to UTF-8 stdio -- helper must not touch streams."""
import io
import sys
from mempalace.fact_checker import _reconfigure_stdio_utf8_on_windows
class _ReconfigurableStringIO(io.StringIO):
def __init__(self):
super().__init__()
self.reconfigure_calls = []
def reconfigure(self, **kwargs):
self.reconfigure_calls.append(kwargs)
stdin = _ReconfigurableStringIO()
with (
patch.object(sys, "platform", "linux"),
patch.object(sys, "stdin", stdin),
):
_reconfigure_stdio_utf8_on_windows()
assert stdin.reconfigure_calls == []
+642
View File
@@ -0,0 +1,642 @@
"""Tests for the #1222 HNSW capacity probe and BM25-only fallback.
The probe and fallback never load chromadb's HNSW segment, so all of
these tests synthesize the on-disk shape directly: a chroma.sqlite3 with
the relevant schema rows and an ``index_metadata.pickle`` matching what
chromadb 1.5.x writes (``{"id_to_label": {...}, ...}``).
"""
from __future__ import annotations
import os
import pickle
import sqlite3
import pytest
from mempalace.backends.chroma import (
_hnsw_element_count,
_vector_segment_id,
hnsw_capacity_status,
)
from mempalace.searcher import _bm25_only_via_sqlite
COLLECTION = "mempalace_drawers"
# ── Fixtures ──────────────────────────────────────────────────────────
def _seed_chroma_db(
palace: str,
sqlite_count: int,
segment_id: str,
sync_threshold: int | None = None,
) -> None:
"""Create a minimal chroma.sqlite3 with one collection + VECTOR segment.
Mirrors the columns the probe queries: ``segments``, ``collections``,
``collection_metadata``, ``embeddings``, ``embedding_metadata``.
Schema matches chromadb 1.5.x; column types are kept loose because
we read with COUNT(*) and SELECT key, *_value rather than driver-
specific casts.
When ``sync_threshold`` is supplied, an ``hnsw:sync_threshold`` row
is added to ``collection_metadata`` so the divergence floor scales
accordingly. Omit to model an older palace that pre-dates PR #1191.
"""
db_path = os.path.join(palace, "chroma.sqlite3")
conn = sqlite3.connect(db_path)
try:
conn.executescript(
"""
CREATE TABLE collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE collection_metadata (
collection_id TEXT REFERENCES collections(id) ON DELETE CASCADE,
key TEXT NOT NULL,
str_value TEXT,
int_value INTEGER,
float_value REAL,
bool_value INTEGER,
PRIMARY KEY (collection_id, key)
);
CREATE TABLE segments (
id TEXT PRIMARY KEY,
collection TEXT NOT NULL,
scope TEXT NOT NULL
);
CREATE TABLE embeddings (
id INTEGER PRIMARY KEY,
segment_id TEXT NOT NULL,
embedding_id TEXT NOT NULL,
seq_id BLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE embedding_metadata (
id INTEGER REFERENCES embeddings(id),
key TEXT NOT NULL,
string_value TEXT,
int_value INTEGER,
float_value REAL,
bool_value INTEGER,
PRIMARY KEY (id, key)
);
CREATE VIRTUAL TABLE embedding_fulltext_search
USING fts5(string_value, tokenize='trigram');
"""
)
col_id = "col-test"
meta_seg = "seg-meta"
conn.execute("INSERT INTO collections (id, name) VALUES (?, ?)", (col_id, COLLECTION))
if sync_threshold is not None:
conn.execute(
"""INSERT INTO collection_metadata (collection_id, key, int_value)
VALUES (?, 'hnsw:sync_threshold', ?)""",
(col_id, sync_threshold),
)
conn.execute(
"INSERT INTO segments (id, collection, scope) VALUES (?, ?, 'VECTOR')",
(segment_id, col_id),
)
conn.execute(
"INSERT INTO segments (id, collection, scope) VALUES (?, ?, 'METADATA')",
(meta_seg, col_id),
)
for i in range(sqlite_count):
conn.execute(
"""INSERT INTO embeddings (id, segment_id, embedding_id, seq_id)
VALUES (?, ?, ?, ?)""",
(i + 1, segment_id, f"d-{i}", b"\x00\x00\x00\x00\x00\x00\x00\x01"),
)
conn.commit()
finally:
conn.close()
def _write_pickle(palace: str, segment_id: str, hnsw_count: int) -> None:
"""Write an index_metadata.pickle matching chromadb 1.5.x's shape.
1.5.x ``__reduce_ex__`` serializes the PersistentData instance as a
plain dict; we replicate that so the safe unpickler in
``_hnsw_element_count`` reads the same bytes shape it would in
production.
"""
seg_dir = os.path.join(palace, segment_id)
os.makedirs(seg_dir, exist_ok=True)
pickle_path = os.path.join(seg_dir, "index_metadata.pickle")
state = {
"dimensionality": 384,
"total_elements_added": hnsw_count,
"max_seq_id": None,
"id_to_label": {f"d-{i}": i for i in range(hnsw_count)},
"label_to_id": {i: f"d-{i}" for i in range(hnsw_count)},
"id_to_seq_id": {},
}
with open(pickle_path, "wb") as f:
pickle.dump(state, f, pickle.HIGHEST_PROTOCOL)
# ── _vector_segment_id ────────────────────────────────────────────────
def test_vector_segment_id_returns_uuid(tmp_path):
seg = "11111111-2222-3333-4444-555555555555"
_seed_chroma_db(str(tmp_path), sqlite_count=10, segment_id=seg)
assert _vector_segment_id(str(tmp_path), COLLECTION) == seg
def test_vector_segment_id_no_palace(tmp_path):
assert _vector_segment_id(str(tmp_path), COLLECTION) is None
def test_vector_segment_id_unknown_collection(tmp_path):
seg = "11111111-2222-3333-4444-555555555555"
_seed_chroma_db(str(tmp_path), sqlite_count=10, segment_id=seg)
assert _vector_segment_id(str(tmp_path), "nope") is None
# ── _hnsw_element_count ───────────────────────────────────────────────
def test_hnsw_element_count_reads_pickle(tmp_path):
seg = "seg-001"
_seed_chroma_db(str(tmp_path), sqlite_count=100, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=42)
assert _hnsw_element_count(str(tmp_path), seg) == 42
def test_hnsw_element_count_missing_pickle(tmp_path):
seg = "seg-001"
_seed_chroma_db(str(tmp_path), sqlite_count=100, segment_id=seg)
# Segment dir doesn't even exist — no flush ever happened.
assert _hnsw_element_count(str(tmp_path), seg) is None
def test_hnsw_element_count_rejects_arbitrary_class(tmp_path):
"""Pickled references to unallowed classes must not deserialize.
Guards against a tampered ``index_metadata.pickle`` triggering code
execution. The unpickler allowlist is the only protection between
the file and arbitrary import-time side effects. We hand-craft the
pickle bytes (rather than ``pickle.dump`` a local class) because
pickle can't serialize locally-defined classes — but the bytes form
that names an arbitrary stdlib class is a faithful proxy for the
tampered-file threat we want to test.
"""
import pickle as _pickle
seg = "seg-evil"
seg_dir = tmp_path / seg
seg_dir.mkdir()
pickle_path = seg_dir / "index_metadata.pickle"
# GLOBAL opcode pointing at os.system, then STOP. If the unpickler
# didn't enforce the allowlist, find_class would resolve os.system
# and pickle would set up the call. The allowlist must reject it
# before find_class returns anything.
payload = b"c" + b"os\nsystem\n" + _pickle.STOP
pickle_path.write_bytes(payload)
assert _hnsw_element_count(str(tmp_path), seg) is None
# ── hnsw_capacity_status ──────────────────────────────────────────────
def test_capacity_status_ok_when_balanced(tmp_path):
seg = "seg-001"
_seed_chroma_db(str(tmp_path), sqlite_count=1000, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=950)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["status"] == "ok"
assert info["diverged"] is False
assert info["sqlite_count"] == 1000
assert info["hnsw_count"] == 950
def test_capacity_status_flags_severe_divergence(tmp_path):
"""Reproduces #1222: sqlite has 192k, HNSW frozen at ~16k."""
seg = "seg-1222"
_seed_chroma_db(str(tmp_path), sqlite_count=20_000, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=2_000)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["status"] == "diverged"
assert info["diverged"] is True
assert info["divergence"] == 18_000
assert "repair" in info["message"].lower()
def test_capacity_status_tolerates_flush_lag(tmp_path):
"""A few hundred entries behind sqlite is normal post-mine state."""
seg = "seg-lag"
_seed_chroma_db(str(tmp_path), sqlite_count=5_000, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=4_500)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["diverged"] is False
assert info["status"] == "ok"
def test_capacity_status_does_not_flag_unflushed_with_large_sqlite(tmp_path):
"""No pickle + many sqlite rows is inconclusive, not divergence."""
seg = "seg-noflush"
_seed_chroma_db(str(tmp_path), sqlite_count=10_000, segment_id=seg)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["diverged"] is False
assert info["status"] == "unknown"
assert info["divergence"] is None
assert info["hnsw_count"] is None
assert "capacity unavailable" in info["message"]
assert "leaving vector search enabled" in info["message"]
def test_mcp_probe_does_not_disable_vectors_for_unflushed_metadata(tmp_path, monkeypatch):
"""The MCP preflight must not route all searches to BM25 on this signal."""
from mempalace import mcp_server
seg = "seg-mcp-noflush"
_seed_chroma_db(str(tmp_path), sqlite_count=10_000, segment_id=seg)
class _Cfg:
palace_path = str(tmp_path)
collection_name = "mempalace_drawers"
monkeypatch.setattr(mcp_server, "_config", _Cfg())
monkeypatch.setattr(mcp_server, "_vector_disabled", True)
monkeypatch.setattr(mcp_server, "_vector_disabled_reason", "old divergence")
mcp_server._refresh_vector_disabled_flag()
assert mcp_server._vector_disabled is False
assert mcp_server._vector_disabled_reason == ""
assert mcp_server._vector_capacity_status["status"] == "unknown"
assert "leaving vector search enabled" in mcp_server._vector_capacity_status["message"]
def test_capacity_status_quiet_for_empty_palace(tmp_path):
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["diverged"] is False
assert info["status"] == "unknown"
# ── Divergence threshold scales with hnsw:sync_threshold ───────────────
def test_capacity_status_tolerates_lag_under_large_sync_threshold(tmp_path):
"""Regression for the PR #1191 / PR #1227 conflict.
Palaces created via mempalace's _HNSW_BLOAT_GUARD (sync_threshold=
50_000) naturally accumulate up to ~50K queued entries between
flushes. The pickle-vs-sqlite probe must scale its tolerance to
``2 × sync_threshold`` so this expected lag is not flagged as
corruption — otherwise vector search disables for ~80% of the
write cycle on any actively-mined ≥100K palace.
"""
seg = "seg-bloat-guard"
_seed_chroma_db(str(tmp_path), sqlite_count=100_000, segment_id=seg, sync_threshold=50_000)
_write_pickle(str(tmp_path), seg, hnsw_count=50_000)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
# 50K divergence is exactly one flush window — well within 2× = 100K.
assert info["diverged"] is False, info["message"]
assert info["status"] == "ok"
assert info["divergence"] == 50_000
def test_capacity_status_still_flags_real_corruption_under_large_sync(tmp_path):
"""The dynamic floor must still catch genuine #1222-style corruption.
sqlite at 200K with HNSW frozen at 16K is the original #1222 shape —
any reasonable threshold should flag it, regardless of whether the
collection was created with sync_threshold=1000 or 50_000.
"""
seg = "seg-1222-with-bloat-guard"
_seed_chroma_db(str(tmp_path), sqlite_count=200_000, segment_id=seg, sync_threshold=50_000)
_write_pickle(str(tmp_path), seg, hnsw_count=16_384)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
# 183,616 missing — far past 2 × 50K = 100K floor and 10% of 200K = 20K.
assert info["diverged"] is True
assert info["status"] == "diverged"
assert info["divergence"] == 183_616
def test_capacity_status_default_threshold_when_no_sync_metadata(tmp_path):
"""Older palaces without ``hnsw:sync_threshold`` fall back to 2000 floor.
Pre-PR-#1191 collections only carry ``hnsw:space``. The probe must
use chromadb's own default sync_threshold of 1000 → floor of 2000,
matching pre-fix behavior.
"""
seg = "seg-legacy"
# No sync_threshold supplied — collection_metadata stays empty.
_seed_chroma_db(str(tmp_path), sqlite_count=10_000, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=7_500)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
# 2,500 divergence > max(2000 floor, 10% of 10K = 1000) → DIVERGED
assert info["diverged"] is True
assert info["divergence"] == 2_500
def test_unflushed_path_also_uses_dynamic_floor(tmp_path):
"""The never-flushed branch must scale with sync_threshold too.
A 30K-drawer collection under sync_threshold=50_000 hasn't reached
its first flush yet — pickle is absent. Pre-fix this would flag
DIVERGED (30K > fixed 2000 floor); post-fix the 30K stays under
the dynamic 100K floor.
"""
seg = "seg-preflush-large"
_seed_chroma_db(str(tmp_path), sqlite_count=30_000, segment_id=seg, sync_threshold=50_000)
info = hnsw_capacity_status(str(tmp_path), COLLECTION)
assert info["hnsw_count"] is None
assert info["diverged"] is False, info["message"]
# ── BM25-only sqlite fallback ─────────────────────────────────────────
def _seed_drawers(palace: str, segment_id: str, drawers: list[tuple[str, dict, str]]) -> None:
"""Insert (text, metadata, embedding_id) tuples into a seeded palace.
Replaces the bare ``embeddings`` rows from ``_seed_chroma_db`` so the
sqlite count matches what we insert here.
"""
db_path = os.path.join(palace, "chroma.sqlite3")
conn = sqlite3.connect(db_path)
try:
conn.execute("DELETE FROM embeddings")
for i, (text, meta, eid) in enumerate(drawers, start=1):
conn.execute(
"""INSERT INTO embeddings (id, segment_id, embedding_id, seq_id)
VALUES (?, ?, ?, ?)""",
(i, segment_id, eid, b"\x00" * 8),
)
conn.execute(
"""INSERT INTO embedding_metadata (id, key, string_value)
VALUES (?, 'chroma:document', ?)""",
(i, text),
)
conn.execute(
"INSERT INTO embedding_fulltext_search (rowid, string_value) VALUES (?, ?)",
(i, text),
)
for k, v in meta.items():
if isinstance(v, int):
conn.execute(
"""INSERT INTO embedding_metadata (id, key, int_value)
VALUES (?, ?, ?)""",
(i, k, v),
)
else:
conn.execute(
"""INSERT INTO embedding_metadata (id, key, string_value)
VALUES (?, ?, ?)""",
(i, k, str(v)),
)
conn.commit()
finally:
conn.close()
def _set_drawer_created_at(palace: str, timestamps: dict[int, str]) -> None:
db_path = os.path.join(palace, "chroma.sqlite3")
conn = sqlite3.connect(db_path)
try:
for emb_id, created_at in timestamps.items():
conn.execute("UPDATE embeddings SET created_at = ? WHERE id = ?", (created_at, emb_id))
conn.commit()
finally:
conn.close()
@pytest.fixture
def palace_with_drawers(tmp_path):
seg = "seg-bm25"
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
drawers = [
(
"ChromaDB segfault on every tool call after HNSW divergence",
{"wing": "ops", "room": "incidents", "source_file": "/x/incident.md"},
"d-1",
),
(
"Memory palace technique using rooms and drawers for recall",
{"wing": "design", "room": "metaphor", "source_file": "/x/design.md"},
"d-2",
),
(
"Repair rebuild backs up only the sqlite database",
{"wing": "ops", "room": "runbook", "source_file": "/x/repair.md"},
"d-3",
),
]
_seed_drawers(str(tmp_path), seg, drawers)
return tmp_path
def test_bm25_fallback_returns_matches(palace_with_drawers):
out = _bm25_only_via_sqlite("segfault chromadb", str(palace_with_drawers), n_results=5)
assert out["fallback"] == "bm25_only_via_sqlite"
assert len(out["results"]) >= 1
top = out["results"][0]
# The incident drawer is the closest BM25 match for these terms.
assert "segfault" in top["text"].lower()
assert top["matched_via"] == "bm25_sqlite"
# Vector fields are intentionally absent in fallback mode.
assert top["similarity"] is None
assert top["distance"] is None
def test_bm25_fallback_filters_by_wing(palace_with_drawers):
out = _bm25_only_via_sqlite(
"memory palace recall", str(palace_with_drawers), wing="design", n_results=5
)
assert all(r["wing"] == "design" for r in out["results"])
def test_bm25_fallback_applies_wing_before_fts_candidate_limit(tmp_path):
seg = "seg-bm25-fts-limit"
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
_seed_drawers(
str(tmp_path),
seg,
[
(
"shared token outside target wing",
{"wing": "ops", "room": "incidents", "source_file": "/x/ops.md"},
"d-1",
),
(
"shared token inside target wing",
{"wing": "project", "room": "diary", "source_file": "/x/project.md"},
"d-2",
),
],
)
out = _bm25_only_via_sqlite("shared token", str(tmp_path), wing="project", max_candidates=1)
assert out["total_before_filter"] == 1
assert len(out["results"]) == 1
assert out["results"][0]["wing"] == "project"
def test_bm25_fallback_applies_room_before_fts_candidate_limit(tmp_path):
seg = "seg-bm25-room-limit"
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
_seed_drawers(
str(tmp_path),
seg,
[
(
"shared token wrong room",
{"wing": "project", "room": "scratch", "source_file": "/x/scratch.md"},
"d-1",
),
(
"shared token right room",
{"wing": "project", "room": "diary", "source_file": "/x/diary.md"},
"d-2",
),
],
)
out = _bm25_only_via_sqlite(
"shared token",
str(tmp_path),
wing="project",
room="diary",
max_candidates=1,
)
assert out["total_before_filter"] == 1
assert len(out["results"]) == 1
assert out["results"][0]["wing"] == "project"
assert out["results"][0]["room"] == "diary"
def test_bm25_fallback_applies_wing_before_recency_candidate_limit(tmp_path):
seg = "seg-bm25-recency-limit"
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
_seed_drawers(
str(tmp_path),
seg,
[
(
"target drawer for short query",
{"wing": "project", "room": "diary", "source_file": "/x/project.md"},
"d-1",
),
(
"newer drawer outside target wing",
{"wing": "ops", "room": "incidents", "source_file": "/x/ops.md"},
"d-2",
),
],
)
_set_drawer_created_at(
str(tmp_path),
{
1: "2026-01-01 00:00:00",
2: "2026-02-01 00:00:00",
},
)
out = _bm25_only_via_sqlite("a", str(tmp_path), wing="project", max_candidates=1)
assert out["total_before_filter"] == 1
assert len(out["results"]) == 1
assert out["results"][0]["wing"] == "project"
def test_bm25_fallback_returns_empty_when_filtered_wing_has_no_candidates(tmp_path):
seg = "seg-bm25-empty-filter"
_seed_chroma_db(str(tmp_path), sqlite_count=0, segment_id=seg)
_seed_drawers(
str(tmp_path),
seg,
[
(
"shared token outside target wing",
{"wing": "ops", "room": "incidents", "source_file": "/x/ops.md"},
"d-1",
),
],
)
out = _bm25_only_via_sqlite("shared token", str(tmp_path), wing="project", max_candidates=1)
assert out["total_before_filter"] == 0
assert out["results"] == []
def test_bm25_fallback_no_palace(tmp_path):
out = _bm25_only_via_sqlite("anything", str(tmp_path))
assert "error" in out
def test_bm25_fallback_handles_short_query(palace_with_drawers):
"""Single-character tokens are unmatchable in trigram FTS5 — must
not crash, must fall back to the recency window."""
out = _bm25_only_via_sqlite("a", str(palace_with_drawers), n_results=5)
# Falls back to recency window; returns whatever it can rank.
assert out["fallback"] == "bm25_only_via_sqlite"
assert isinstance(out["results"], list)
# ── repair.status CLI command ─────────────────────────────────────────
def test_repair_status_reports_diverged(tmp_path, capsys):
"""The status command prints DIVERGED and recommends rebuild."""
from mempalace.repair import status as repair_status
seg = "seg-status"
_seed_chroma_db(str(tmp_path), sqlite_count=20_000, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=2_000)
out = repair_status(palace_path=str(tmp_path))
captured = capsys.readouterr().out
assert "DIVERGED" in captured
assert "mempalace repair`" in captured
assert out["drawers"]["diverged"] is True
def test_repair_status_quiet_on_healthy_palace(tmp_path, capsys):
from mempalace.repair import status as repair_status
seg = "seg-status-ok"
_seed_chroma_db(str(tmp_path), sqlite_count=500, segment_id=seg)
_write_pickle(str(tmp_path), seg, hnsw_count=480)
repair_status(palace_path=str(tmp_path))
captured = capsys.readouterr().out
assert "DIVERGED" not in captured
assert "Recommended" not in captured
# ── tool_status sqlite fallback (#1222 short-circuit) ─────────────────
def test_tool_status_via_sqlite_returns_breakdown(palace_with_drawers, monkeypatch):
"""When _vector_disabled is set, tool_status reads counts from sqlite
instead of opening a chromadb client."""
from mempalace import mcp_server
# _config.palace_path is a read-only property; swap the whole object
# for a tiny stand-in so we don't have to monkey with the real
# MempalaceConfig.
class _Cfg:
palace_path = str(palace_with_drawers)
collection_name = "mempalace_drawers"
monkeypatch.setattr(mcp_server, "_config", _Cfg())
monkeypatch.setattr(mcp_server, "_vector_disabled", True)
monkeypatch.setattr(mcp_server, "_vector_disabled_reason", "test divergence")
out = mcp_server._tool_status_via_sqlite()
assert out["vector_disabled"] is True
assert out["vector_disabled_reason"] == "test divergence"
assert out["total_drawers"] == 3
# Wing breakdown comes from the seeded palace_with_drawers fixture:
# ops×2 (incident + repair runbook), design×1 (metaphor).
assert out["wings"].get("ops") == 2
assert out["wings"].get("design") == 1
+113
View File
@@ -0,0 +1,113 @@
import os
from pathlib import Path
from mempalace.backends.chroma import (
_HNSW_LINK_TO_DATA_MAX_RATIO,
_hnsw_link_to_data_ratio,
_segment_appears_healthy,
quarantine_stale_hnsw,
)
def _write_segment(
seg_dir: Path,
*,
data_size: int = 100,
link_size: int = 100,
write_metadata: bool = True,
) -> None:
seg_dir.mkdir(parents=True, exist_ok=True)
(seg_dir / "data_level0.bin").write_bytes(b"\0" * data_size)
(seg_dir / "link_lists.bin").write_bytes(b"\0" * link_size)
if write_metadata:
# Enough bytes to pass the existing pickle envelope sniff-test:
# starts with pickle protocol marker 0x80 and ends with STOP 0x2e.
(seg_dir / "index_metadata.pickle").write_bytes(b"\x80" + b"x" * 16 + b"\x2e")
def test_hnsw_link_to_data_ratio_reports_payload_size_ratio(tmp_path):
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
_write_segment(seg_dir, data_size=100, link_size=250)
assert _hnsw_link_to_data_ratio(str(seg_dir)) == 2.5
def test_segment_health_rejects_exploded_link_lists_even_with_valid_pickle(tmp_path):
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
_write_segment(
seg_dir,
data_size=100,
link_size=int(100 * (_HNSW_LINK_TO_DATA_MAX_RATIO + 1)),
write_metadata=True,
)
assert not _segment_appears_healthy(str(seg_dir))
def test_segment_health_keeps_reasonable_payload_with_valid_pickle(tmp_path):
seg_dir = tmp_path / "11111111-2222-3333-4444-555555555555"
_write_segment(
seg_dir,
data_size=100,
link_size=int(100 * _HNSW_LINK_TO_DATA_MAX_RATIO),
write_metadata=True,
)
assert _segment_appears_healthy(str(seg_dir))
def test_quarantine_catches_link_bloat_without_mtime_drift(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
db_path = palace / "chroma.sqlite3"
db_path.write_text("sqlite placeholder")
seg_dir = palace / "11111111-2222-3333-4444-555555555555"
_write_segment(
seg_dir,
data_size=100,
link_size=int(100 * (_HNSW_LINK_TO_DATA_MAX_RATIO + 1)),
write_metadata=True,
)
# Make sqlite and HNSW mtimes identical. The old mtime-only gate would
# skip this segment even though the payload is structurally corrupt.
same_time = 1_700_000_000
os.utime(db_path, (same_time, same_time))
os.utime(seg_dir / "data_level0.bin", (same_time, same_time))
moved = quarantine_stale_hnsw(str(palace), stale_seconds=999_999)
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-")
def test_quarantine_leaves_reasonable_payload_in_place(tmp_path):
palace = tmp_path / "palace"
palace.mkdir()
db_path = palace / "chroma.sqlite3"
db_path.write_text("sqlite placeholder")
seg_dir = palace / "11111111-2222-3333-4444-555555555555"
_write_segment(
seg_dir,
data_size=100,
link_size=100,
write_metadata=True,
)
same_time = 1_700_000_000
os.utime(db_path, (same_time, same_time))
os.utime(seg_dir / "data_level0.bin", (same_time, same_time))
moved = quarantine_stale_hnsw(str(palace), stale_seconds=999_999)
assert moved == []
assert seg_dir.exists()
+775 -99
View File
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
"""
Integration tests for the legacy ``.sh`` hook scripts.
The shell hooks do their own Python resolution (unlike the Python
``hooks_cli.py`` which uses ``sys.executable`` — trivially correct).
GUI-launched harnesses on macOS provide a minimal PATH that often lacks
the Python where ``mempalace`` is installed, so the shell path needs to:
1. honour ``$MEMPAL_PYTHON`` as an explicit user override;
2. fall back to ``$(command -v python3)`` / bare ``python3``;
3. *never* crash the hook when the resolved interpreter can't import
mempalace — log and skip the auto-ingest instead, so Claude Code
doesn't see a non-zero exit from its Stop hook.
These regressions matter because every failure mode they catch produced
silent breakage in production — the user's hook appeared to "not fire"
but was actually crashing deep in a PATH-resolution edge case.
"""
from __future__ import annotations
import json
import os
import stat
import subprocess
import sys
from pathlib import Path
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"
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
# ── helpers ───────────────────────────────────────────────────────────────
def _write_fake_python(
path: Path, *, can_import_mempalace: bool = False, marker_file: Path | None = None
) -> Path:
"""Create a python3 shim that proxies to the real interpreter so
the hook's JSON-parsing calls still work, but fails ``-c 'import
mempalace'`` / ``-m mempalace`` when ``can_import_mempalace`` is
False.
Every invocation appends the shim name to ``marker_file`` so tests
can prove which interpreter the hook invoked — using a file because
the hook pipes some python calls to ``2>/dev/null``, so stderr
markers are unreliable."""
real_python = sys.executable
marker = str(marker_file) if marker_file is not None else ""
shim_src = f"""#!/bin/bash
# Fake python3 shim: proxy to the real interpreter, drop a marker,
# and simulate a missing mempalace install when configured that way.
MARKER_FILE="{marker}"
if [ -n "$MARKER_FILE" ]; then
echo "{path.name}" >> "$MARKER_FILE"
fi
CAN_IMPORT={"1" if can_import_mempalace else "0"}
# Simulate the "mempalace is not installed in this interpreter" case.
if [ "$CAN_IMPORT" = "0" ]; then
if [ "$1" = "-c" ] && echo "$2" | grep -q "import mempalace"; then
exit 1
fi
if [ "$1" = "-m" ] && [ "$2" = "mempalace" ]; then
exit 1
fi
fi
# Everything else — JSON parsing, heredoc stdin, etc — delegate to real python.
exec "{real_python}" "$@"
"""
path.write_text(shim_src)
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return path
def _run_hook(
script: Path,
stdin_json: dict,
*,
env_overrides: dict | None = None,
path_prefix: list[Path] | None = None,
) -> subprocess.CompletedProcess:
"""Invoke a shell hook with a minimal controlled environment."""
env = {
# Give the hook a clean slate — no inherited MEMPAL_* vars.
"HOME": os.environ.get("HOME", "/tmp"),
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
}
if path_prefix:
env["PATH"] = os.pathsep.join(str(p) for p in path_prefix) + os.pathsep + env["PATH"]
if env_overrides:
env.update(env_overrides)
return subprocess.run(
["bash", str(script)],
input=json.dumps(stdin_json),
capture_output=True,
text=True,
env=env,
timeout=30,
)
# ── MEMPAL_PYTHON resolution contract ────────────────────────────────────
class TestMempalPythonOverride:
def test_explicit_override_wins_over_path(self, tmp_path):
"""If MEMPAL_PYTHON is set and executable, the hook must use it
in preference to whatever is on PATH."""
marker = tmp_path / "markers.log"
fake = _write_fake_python(
tmp_path / "override_python",
can_import_mempalace=True,
marker_file=marker,
)
result = _run_hook(
SAVE_HOOK,
{"session_id": "abc", "stop_hook_active": False, "transcript_path": ""},
env_overrides={"MEMPAL_PYTHON": str(fake), "HOME": str(tmp_path)},
)
assert (
result.returncode == 0
), f"hook exited non-zero: stderr={result.stderr!r} stdout={result.stdout!r}"
invocations = marker.read_text().splitlines() if marker.exists() else []
assert (
"override_python" in invocations
), f"MEMPAL_PYTHON override was not used. Marker log: {invocations!r}"
def test_ignores_override_when_not_executable(self, tmp_path):
"""If MEMPAL_PYTHON is set but the file isn't executable, the
hook must fall back to PATH rather than blow up with a
'permission denied'."""
bogus = tmp_path / "not_executable"
bogus.write_text("# not a python")
# Do NOT chmod +x — the hook should notice and skip.
result = _run_hook(
SAVE_HOOK,
{"session_id": "abc", "stop_hook_active": False, "transcript_path": ""},
env_overrides={"MEMPAL_PYTHON": str(bogus), "HOME": str(tmp_path)},
)
assert (
result.returncode == 0
), f"hook crashed on non-executable MEMPAL_PYTHON: {result.stderr!r}"
def test_falls_back_to_path_when_unset(self, tmp_path):
"""With MEMPAL_PYTHON unset, the hook uses whatever ``python3``
is found on PATH. Prove this by putting a marker-emitting shim
first on PATH."""
marker = tmp_path / "markers.log"
fake = _write_fake_python(
tmp_path / "python3",
can_import_mempalace=True,
marker_file=marker,
)
result = _run_hook(
SAVE_HOOK,
{"session_id": "abc", "stop_hook_active": False, "transcript_path": ""},
env_overrides={"MEMPAL_PYTHON": "", "HOME": str(tmp_path)},
path_prefix=[fake.parent],
)
assert result.returncode == 0
invocations = marker.read_text().splitlines() if marker.exists() else []
assert (
"python3" in invocations
), f"fallback-to-PATH did not use the shimmed python3. Marker log: {invocations!r}"

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