Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
181e00a
fix(mcp_server): reset chromadb System cache on staleness reconnect (…
colorpanda82 Jul 15, 2026
e5db079
fix(chroma): reset chromadb System cache in ChromaBackend._client() o…
colorpanda82 Jul 15, 2026
e672a3f
test(chroma): reformat test_backends.py to satisfy ruff format
colorpanda82 Jul 17, 2026
7777094
test(mcp_server): re-acquire closets handle after delete_by_source
colorpanda82 Jul 17, 2026
3da1d79
fix(miner): close four re-mine safety gaps in process_file
KeilerHirsch Jul 28, 2026
eb0e776
docs(changelog): tighten 3.7.0 notes to match prior release style
igorls Aug 11, 2026
cb03ee6
fix(tests): harden hybrid search against empty Windows Chroma reads
igorls Aug 11, 2026
9a3afc9
Merge pull request #2220 from MemPalace/fix/windows-hybrid-search-emp…
igorls Aug 11, 2026
db29959
fix(ingest): never block on a non-regular file (#2221)
mvalentsev Aug 11, 2026
0f3f0c6
docs(changelog): note the non-regular-file ingest hang (#2221)
mvalentsev Aug 11, 2026
b210423
Merge pull request #2219 from MemPalace/docs/changelog-370-readable
igorls Aug 11, 2026
f19f961
merge: fix(miner) re-mine completion safety (#2088)
igorls Aug 11, 2026
1654cd2
fix: 3.7.1 critical patch — re-mine honesty + SIGTERM lock release
igorls Aug 11, 2026
1104089
merge: fix(mcp) reset chromadb System cache on reconnect (#2002, #2026)
igorls Aug 12, 2026
f32f362
merge: fix(chroma) reset System cache on backend reopen (#2028, #2032)
igorls Aug 12, 2026
759b8f1
fix(convo): stamp chunk_total and clean partial multi-batch mines (#2…
igorls Aug 12, 2026
fc1431a
test(repair): release SharedSystemClient after seeding for Windows re…
igorls Aug 12, 2026
83d8cbd
Merge pull request #2228 from MemPalace/fix/3.7.1-critical-patch
igorls Aug 12, 2026
583fff5
chore(release): 3.7.1
igorls Aug 12, 2026
10eeb45
Merge pull request #2238 from MemPalace/chore/release-3.7.1
igorls Aug 13, 2026
a5a4071
chore: merge main into develop after 3.7.0 promotion
igorls Aug 13, 2026
906b918
Merge pull request #2242 from MemPalace/chore/sync-main-into-develop-…
igorls Aug 13, 2026
359c579
Merge pull request #2241 from MemPalace/develop
igorls Aug 14, 2026
1cc133a
chore(release): sync upstream 3.7.1
offendingcommit Aug 15, 2026
205e772
fix(release): preserve fork behavior after upstream sync
offendingcommit Aug 15, 2026
641b61c
fix(ci): record HTTP status before response delivery
offendingcommit Aug 15, 2026
e4bf4f9
fix(ci): normalize SDK HTTP status values
offendingcommit Aug 15, 2026
c7ec654
test(ci): make conversation fixtures portable
offendingcommit Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"name": "mempalace",
"source": "./.claude-plugin",
"description": "AI memory system — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, guided setup.",
"version": "3.7.0+oc.2",
"version": "3.7.1+oc.1",
"author": {
"name": "milla-jovovich"
}
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"version": "3.7.0+oc.2",
"version": "3.7.1+oc.1",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mempalace",
"version": "3.7.0+oc.2",
"version": "3.7.1+oc.1",
"description": "Give your AI a memory — mine projects and conversations into a searchable palace. 36 MCP tools, auto-save hooks, and guided setup.",
"author": {
"name": "milla-jovovich"
Expand Down
104 changes: 66 additions & 38 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
MIT — see [LICENSE](LICENSE).

<!-- Link Definitions -->
[version-shield]: https://img.shields.io/badge/version-3.7.0+oc.2-4dc9f6?style=flat-square&labelColor=0a0e14
[version-shield]: https://img.shields.io/badge/version-3.7.1+oc.1-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/
Expand Down
2 changes: 1 addition & 1 deletion integrations/openclaw/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.7.0
version: 3.7.1
homepage: https://github.com/MemPalace/mempalace
user-invocable: true
metadata:
Expand Down
70 changes: 67 additions & 3 deletions mempalace/backends/chroma.py
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,33 @@ def _close_client(client) -> None:
logger.debug("client.close() unavailable or failed", exc_info=True)


def _clear_chroma_system_cache() -> None:
"""Drop chromadb's process-global ``SharedSystemClient`` cache.

chromadb caches its ``System`` (and the live HNSW segment) keyed by path.
A bare ``chromadb.PersistentClient(path=...)`` reopen reuses that cached
System, so after a peer/rebuild has changed ``chroma.sqlite3`` on disk we
would rebuild against the stale in-memory segment and persist an outdated
index over the on-disk changes -- the same data-loss class as #2002,
reached via :meth:`ChromaBackend._client` instead of
``mcp_server._get_client``. This mirrors the reset already performed by
``mcp_server._force_chroma_cache_reset`` and ``repair._close_chroma_handles``.

The clear is process-global (it evicts every palace's cached System, not
just this path); chromadb exposes no per-path eviction. It only fires on the
inode/mtime-change branch of ``_client``, never the steady-state hot path,
so the redundant rebuild cost is bounded to genuine external-change reopens.
"""
try:
from chromadb.api.client import SharedSystemClient

clear = getattr(SharedSystemClient, "clear_system_cache", None)
if callable(clear):
clear()
except Exception:
logger.debug("Failed to clear chromadb SharedSystemClient cache", exc_info=True)


class ChromaCollection(BaseCollection):
"""Thin adapter translating ChromaDB dict returns into typed results.

Expand All @@ -1568,9 +1595,14 @@ class ChromaCollection(BaseCollection):
directly without going through ``ChromaBackend``.
"""

def __init__(self, collection, palace_path: Optional[str] = None):
def __init__(self, collection, palace_path: Optional[str] = None, after_write=None):
self._collection = collection
self._palace_path = palace_path
self._after_write = after_write

def _record_write(self) -> None:
if self._after_write is not None:
self._after_write()

@contextlib.contextmanager
def _write_lock(self):
Expand Down Expand Up @@ -1662,6 +1694,7 @@ def add(self, *, documents, ids, metadatas=None, embeddings=None):
kwargs["embeddings"] = embeddings
with self._write_lock():
self._collection.add(**kwargs)
self._record_write()

def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
kwargs: dict[str, Any] = {
Expand All @@ -1675,6 +1708,7 @@ def upsert(self, *, documents, ids, metadatas=None, embeddings=None):
kwargs["embeddings"] = embeddings
with self._write_lock():
self._collection.upsert(**kwargs)
self._record_write()

def update(
self,
Expand All @@ -1695,6 +1729,7 @@ def update(
kwargs["embeddings"] = embeddings
with self._write_lock():
self._collection.update(**kwargs)
self._record_write()

# ------------------------------------------------------------------
# Reads
Expand Down Expand Up @@ -1840,6 +1875,7 @@ def delete(self, *, ids=None, where=None):
kwargs["where"] = where
with self._write_lock():
self._collection.delete(**kwargs)
self._record_write()

def count(self):
return self._collection.count()
Expand Down Expand Up @@ -2292,6 +2328,19 @@ def _client(self, palace_path: str):
or (mtime_appeared and palace_path in self._freshness)
):
ChromaBackend._quarantined_paths.discard(palace_path)
# Release the old client's SQLite and HNSW handles before
# clearing chromadb's global system cache. Replacing the dict
# entry alone retains one loaded HNSW client per peer write.
_close_client(self._clients.pop(palace_path, None))
cached = None
# #2028: the same external change means chromadb's path-keyed
# System cache is now stale. Reconstructing PersistentClient
# below would reuse the cached System (and its in-memory HNSW
# segment), so drop the shared cache first -- otherwise the
# rebuilt client persists an outdated index over the on-disk
# change. Gated on genuine external change (not first open) so
# cold opens never pay the global-evict cost.
_clear_chroma_system_cache()
ChromaBackend._prepare_palace_for_open(palace_path)
cached = chromadb.PersistentClient(path=palace_path)
self._clients[palace_path] = cached
Expand Down Expand Up @@ -2441,7 +2490,16 @@ def get_collection(
raise ValueError(explanation) from e
raise
_pin_hnsw_threads(collection)
return ChromaCollection(collection, palace_path=palace_path)
# Collection creation and migration can write chroma.sqlite3 before
# the returned wrapper has a chance to run its after-write callback.
self._freshness[palace_path] = self._db_stat(palace_path)
return ChromaCollection(
collection,
palace_path=palace_path,
after_write=lambda: self._freshness.__setitem__(
palace_path, self._db_stat(palace_path)
),
)

def close_palace(self, palace) -> None:
"""Drop cached handles for ``palace`` and release its SQLite file lock.
Expand Down Expand Up @@ -2515,7 +2573,13 @@ def create_collection(
metadata=_hnsw_creation_metadata({"hnsw_space": hnsw_space}),
**ef_kwargs,
)
return ChromaCollection(collection, palace_path=palace_path)
return ChromaCollection(
collection,
palace_path=palace_path,
after_write=lambda: self._freshness.__setitem__(
palace_path, self._db_stat(palace_path)
),
)


def _normalize_get_collection_args(args, kwargs):
Expand Down
49 changes: 42 additions & 7 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ def _gather_origin_samples(project_dir) -> list:
if total_chars >= _PASS_ZERO_TOTAL_CAP:
break
try:
# ``scan_for_detection`` picks candidates by extension, so a FIFO
# named ``notes.md`` reaches this loop; opening one for reading
# blocks until a writer appears. ``is_file()`` stats instead.
# It belongs inside the try: it raises PermissionError on an
# unreadable directory, which the open below used to absorb.
if not filepath.is_file():
continue
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read(_PASS_ZERO_PER_FILE_CAP)
except OSError:
Expand Down Expand Up @@ -263,9 +270,17 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
if not (project_path / ".git").exists():
return False
gitignore = project_path / ".gitignore"
# ``exists()`` is true for a FIFO, and both the read below and the append
# at the end of this function would block in the kernel on one. Decide by
# type instead: an absent file still yields "" as before, a regular one
# is read, and anything else is left untouched.
if gitignore.exists() and not gitignore.is_file():
return False
# Force UTF-8: Windows defaults to GBK and chokes on non-ASCII .gitignore
# comments, killing auto-init even though the file is valid UTF-8.
existing = gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.exists() else ""
existing = (
gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.is_file() 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:
Expand Down Expand Up @@ -420,9 +435,19 @@ def cmd_init(args):
if confirmed["people"] or confirmed["projects"] or confirmed.get("topics"):
project_path = Path(args.dir).expanduser().resolve()
entities_path = project_path / "entities.json"
with open(entities_path, "w", encoding="utf-8") as f:
json.dump(confirmed, f, indent=2, ensure_ascii=False)
print(f" Entities saved: {entities_path}")
# Opening a pre-existing FIFO for writing blocks in the kernel
# until a reader appears. Only a regular file is a valid target
# for the per-project audit trail; the global registry merge
# below is unaffected either way.
if entities_path.exists() and not entities_path.is_file():
print(
f" ! Not writing entities: {entities_path} is not a regular file",
file=sys.stderr,
)
else:
with open(entities_path, "w", encoding="utf-8") as f:
json.dump(confirmed, f, indent=2, ensure_ascii=False)
print(f" Entities saved: {entities_path}")

from .config import normalize_wing_name
from .miner import add_to_known_entities
Expand All @@ -438,7 +463,14 @@ def cmd_init(args):
print(" No entities detected -- proceeding with directory-based rooms.")

# Pass 2: detect rooms from folder structure
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
try:
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
except OSError as exc:
# Writing mempalace.yaml is the point of init; a target it cannot
# write (a pre-existing pipe, a full disk) is a hard failure, and a
# message beats the traceback this used to produce.
print(f"\n ERROR: {exc}", file=sys.stderr)
sys.exit(1)
cfg.init()
backend = _backend_arg(args)
if backend:
Expand Down Expand Up @@ -2219,12 +2251,15 @@ def cmd_compress(args):
# Load dialect (with optional entity config)
config_path = args.config
if not config_path:
# ``isfile`` rather than ``exists``: the latter is true for a FIFO,
# and ``Dialect.from_config`` opens whatever it is handed, which
# blocks in the kernel on a pipe named entities.json in the cwd.
for candidate in ["entities.json", os.path.join(palace_path, "entities.json")]:
if os.path.exists(candidate):
if os.path.isfile(candidate):
config_path = candidate
break

if config_path and os.path.exists(config_path):
if config_path and os.path.isfile(config_path):
dialect = Dialect.from_config(config_path)
print(f" Loaded entity config: {config_path}")
else:
Expand Down
23 changes: 21 additions & 2 deletions mempalace/convo_miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import errno
import os
import sys
import json
Expand Down Expand Up @@ -200,10 +201,15 @@ def _path_within_root(path: Path, root: Path) -> bool:
def _is_regular_source_file(filepath: Path, root: Path) -> bool:
if not _path_within_root(filepath, root):
return False
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
fd = -1
try:
fd = os.open(filepath, flags)
try:
fd = os.open(filepath, flags)
except OSError as exc:
if exc.errno != errno.EAGAIN or not stat.S_ISREG(os.lstat(filepath).st_mode):
raise
fd = os.open(filepath, flags & ~getattr(os, "O_NONBLOCK", 0))
st = os.fstat(fd)
return stat.S_ISREG(st.st_mode) and st.st_size <= MAX_FILE_SIZE
except OSError:
Expand Down Expand Up @@ -864,6 +870,10 @@ def scan_convos(convo_dir: str, include_subagents: bool = False) -> list:
)
continue
if not _is_regular_source_file(filepath, convo_path):
print(
f" SKIP: {filepath.name} (not a regular file)",
file=sys.stderr,
)
continue
files.append(filepath)
return files
Expand Down Expand Up @@ -1020,6 +1030,7 @@ def _file_chunks_locked(
normalized_chunk_size,
expected_chunk_count,
)
written_ids: list[str] = []
chunk_iterator = iter(chunks) if target_needs_upsert else iter(())
while batch := list(islice(chunk_iterator, DRAWER_UPSERT_BATCH_SIZE)):
batch_docs: list = []
Expand Down Expand Up @@ -1066,6 +1077,7 @@ def _file_chunks_locked(
"extract_mode": extract_mode,
"normalize_version": NORMALIZE_VERSION,
"id_recipe": ID_RECIPE,
"chunk_total": expected_chunk_count,
}
if source_mtime is not None:
meta["source_mtime"] = source_mtime
Expand Down Expand Up @@ -1114,8 +1126,15 @@ def _file_chunks_locked(
metadatas=batch_metas,
)
drawers_added += len(batch_docs)
written_ids.extend(batch_ids)
except Exception as e:
if "already exists" not in str(e).lower():
# Preserve a previously committed normalized generation,
# but never leave this attempt's successful earlier batches
# looking complete after a later batch fails (#2183).
cleanup_ids = list(dict.fromkeys([*written_ids, *batch_ids]))
if cleanup_ids:
collection.delete(ids=cleanup_ids)
raise

if normalized is not None:
Expand Down
7 changes: 7 additions & 0 deletions mempalace/entity_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,13 @@ def detect_entities(
if files_read >= max_files:
break
try:
# Decide by file type before opening: ``scan_for_detection``
# picks candidates by extension, so a FIFO named ``notes.md``
# reaches this loop and a blocking open of one waits in the
# kernel for a writer that may never come. ``is_file()`` stats
# instead of opening and never blocks.
if not Path(filepath).is_file():
continue
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read(MAX_BYTES_PER_FILE)
all_text.append(content)
Expand Down
10 changes: 10 additions & 0 deletions mempalace/hook_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import json
import os
import re
import sys

Expand Down Expand Up @@ -71,9 +72,18 @@ def count_human_messages(path: str) -> int:
Claude transcripts are UTF-8. Windows Python defaults to cp1252 in many
environments, so the encoding must be explicit. Invalid bytes are ignored
to match the hooks' fail-soft behavior.

A path that exists but is not a regular file counts zero rather than
being opened: opening a FIFO for reading blocks in the kernel until a
writer appears, and this function has no timeout. A path that does not
exist still raises from the ``open`` below, as before.
``mempal_save_hook.sh`` screens with ``[ -f ]``, which is false for a
pipe, so the guard here covers callers that do not.
"""

count = 0
if os.path.exists(path) and not os.path.isfile(path):
return count
with open(path, encoding="utf-8", errors="ignore") as fh:
for line in fh:
try:
Expand Down
7 changes: 7 additions & 0 deletions mempalace/llm_refine.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,13 @@ def collect_corpus_text(
chunks: list[str] = []
for p in selected:
try:
# ``_walk_prose`` selects by suffix and the stat above reads only
# st_mtime, so a FIFO named ``notes.md`` reaches this loop and a
# blocking open of one waits for a writer that may never come.
# Inside the try: is_file() raises on an unreadable directory,
# which the open below already absorbed.
if not p.is_file():
continue
with open(p, encoding="utf-8", errors="replace") as f:
chunks.append(f.read(max_bytes_per_file))
except OSError:
Expand Down
Loading