diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f98df74bc1..0ac629db56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,8 +5,11 @@ Thanks for wanting to help. MemPalace is open source and we welcome contribution ## Getting Started ```bash -git clone https://github.com/milla-jovovich/mempalace.git +# Fork the repo on GitHub first, then clone your fork +git clone https://github.com//mempalace.git cd mempalace +git remote add upstream https://github.com/milla-jovovich/mempalace.git + pip install -e ".[dev]" # installs with dev dependencies (pytest, build, twine) ``` diff --git a/hooks/mempal_precompact_hook.sh b/hooks/mempal_precompact_hook.sh index 550a813be1..784080ac8a 100755 --- a/hooks/mempal_precompact_hook.sh +++ b/hooks/mempal_precompact_hook.sh @@ -72,6 +72,6 @@ fi cat << 'HOOKJSON' { "decision": "block", - "reason": "COMPACTION IMMINENT. Save ALL topics, decisions, quotes, code, and important context from this session to your memory system. Be thorough — after compaction, detailed context will be lost. Organize into appropriate categories. Use verbatim quotes where possible. Save everything, then allow compaction to proceed." + "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 diff --git a/hooks/mempal_save_hook.sh b/hooks/mempal_save_hook.sh index a0e4681fca..df6715e30f 100755 --- a/hooks/mempal_save_hook.sh +++ b/hooks/mempal_save_hook.sh @@ -145,7 +145,7 @@ if [ "$SINCE_LAST" -ge "$SAVE_INTERVAL" ] && [ "$EXCHANGE_COUNT" -gt 0 ]; then cat << 'HOOKJSON' { "decision": "block", - "reason": "AUTO-SAVE checkpoint. Save key topics, decisions, quotes, and code from this session to your memory system. Organize into appropriate categories. Use verbatim quotes where possible. Continue conversation after saving." + "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." } HOOKJSON else diff --git a/mempalace/cli.py b/mempalace/cli.py index 1d106ca7d3..7c4c4021b1 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -34,6 +34,7 @@ from pathlib import Path from .config import MempalaceConfig +from mempalace.config import get_chroma_client, get_collection_name def cmd_init(args): @@ -183,8 +184,8 @@ def cmd_repair(args): # Try to read existing drawers try: - client = chromadb.PersistentClient(path=palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) total = col.count() print(f" Drawers found: {total}") except Exception as e: @@ -295,8 +296,8 @@ def cmd_compress(args): # Connect to palace try: - client = chromadb.PersistentClient(path=palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: print(f"\n No palace found at {palace_path}") print(" Run: mempalace init then mempalace mine ") diff --git a/mempalace/config.py b/mempalace/config.py index fcfb2c8afe..6d4c6f481c 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -9,6 +9,8 @@ import re from pathlib import Path +import chromadb + # ── Input validation ────────────────────────────────────────────────────────── # Shared sanitizers for wing/room/entity names. Prevents path traversal, @@ -57,10 +59,38 @@ def sanitize_content(value: str, max_length: int = 100_000) -> str: raise ValueError("content contains null bytes") return value - DEFAULT_PALACE_PATH = os.path.expanduser("~/.mempalace/palace") DEFAULT_COLLECTION_NAME = "mempalace_drawers" +# --- Multi-tenant Chroma HttpClient support (mpc-multi-tenant patch) -------- + +_chroma_client_cache = None + + +def get_chroma_client(config=None): + """Return a cached chromadb.HttpClient for the shared Chroma Server.""" + global _chroma_client_cache + if _chroma_client_cache is not None: + return _chroma_client_cache + if config is None: + config = MempalaceConfig() + _chroma_client_cache = chromadb.HttpClient( + host=config.chroma_http_host, + port=config.chroma_http_port, + ) + return _chroma_client_cache + + +def get_collection_name(config=None, suffix=None): + """Return the tenant-scoped collection name (e.g., 'tenant__mempalace_drawers').""" + if config is None: + config = MempalaceConfig() + base = suffix or config.collection_name + prefix = config.collection_prefix + if prefix: + return f"{prefix}_{base}" + return base + DEFAULT_TOPIC_WINGS = [ "emotions", "consciousness", @@ -152,6 +182,32 @@ def collection_name(self): """ChromaDB collection name.""" return self._file_config.get("collection_name", DEFAULT_COLLECTION_NAME) + @property + def chroma_http_host(self): + """Chroma Server hostname (default: localhost).""" + return os.environ.get("MEMPALACE_CHROMA_HOST") or self._file_config.get( + "chroma_http_host", "localhost" + ) + + @property + def chroma_http_port(self): + """Chroma Server port (default: 8000).""" + v = os.environ.get("MEMPALACE_CHROMA_PORT") or self._file_config.get( + "chroma_http_port", 8000 + ) + return int(v) + + @property + def collection_prefix(self): + """Per-tenant collection name prefix (e.g., 'tenant_'). + + Set by the sidecar per-request via MEMPALACE_COLLECTION_PREFIX. + When empty, collection names are unprefixed (single-user mode). + """ + return os.environ.get("MEMPALACE_COLLECTION_PREFIX") or self._file_config.get( + "collection_prefix", "" + ) + @property def people_map(self): """Mapping of name variants to canonical names.""" diff --git a/mempalace/hooks_cli.py b/mempalace/hooks_cli.py index b6d229082b..2ce13f432d 100644 --- a/mempalace/hooks_cli.py +++ b/mempalace/hooks_cli.py @@ -18,18 +18,22 @@ STATE_DIR = Path.home() / ".mempalace" / "hook_state" STOP_BLOCK_REASON = ( - "AUTO-SAVE checkpoint. Save key topics, decisions, quotes, and code " - "from this session to your memory system. Organize into appropriate " - "categories. Use verbatim quotes where possible. Continue conversation " - "after saving." + "AUTO-SAVE checkpoint (MemPalace). Save this session's key content:\n" + "1. mempalace_diary_write — AAAK-compressed session summary\n" + "2. mempalace_add_drawer — verbatim quotes, decisions, code snippets\n" + "3. mempalace_kg_add — entity relationships (optional)\n" + "Do NOT write to Claude Code's native auto-memory (.md files). " + "Continue conversation after saving." ) PRECOMPACT_BLOCK_REASON = ( - "COMPACTION IMMINENT. Save ALL topics, decisions, quotes, code, and " - "important context from this session to your memory system. Be thorough " - "\u2014 after compaction, detailed context will be lost. Organize into " - "appropriate categories. Use verbatim quotes where possible. Save " - "everything, then allow compaction to proceed." + "COMPACTION IMMINENT (MemPalace). Save ALL session content before context is lost:\n" + "1. mempalace_diary_write — thorough AAAK-compressed session summary\n" + "2. mempalace_add_drawer — ALL verbatim quotes, decisions, code, context\n" + "3. mempalace_kg_add — entity relationships (optional)\n" + "Be thorough \u2014 after compaction, detailed context will be lost. " + "Do NOT write to Claude Code's native auto-memory (.md files). " + "Save everything to MemPalace, then allow compaction to proceed." ) diff --git a/mempalace/layers.py b/mempalace/layers.py index 6abb99bc9b..cec6f9aa16 100644 --- a/mempalace/layers.py +++ b/mempalace/layers.py @@ -24,6 +24,7 @@ import chromadb from .config import MempalaceConfig +from mempalace.config import get_chroma_client, get_collection_name # --------------------------------------------------------------------------- @@ -91,8 +92,8 @@ def __init__(self, palace_path: str = None, wing: str = None): def generate(self) -> str: """Pull top drawers from ChromaDB and format as compact L1 text.""" try: - client = chromadb.PersistentClient(path=self.palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: return "## L1 — No palace found. Run: mempalace mine " @@ -196,8 +197,8 @@ def __init__(self, palace_path: str = None): def retrieve(self, wing: str = None, room: str = None, n_results: int = 10) -> str: """Retrieve drawers filtered by wing and/or room.""" try: - client = chromadb.PersistentClient(path=self.palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: return "No palace found." @@ -260,8 +261,8 @@ def __init__(self, palace_path: str = None): def search(self, query: str, wing: str = None, room: str = None, n_results: int = 5) -> str: """Semantic search, returns compact result text.""" try: - client = chromadb.PersistentClient(path=self.palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: return "No palace found." @@ -316,8 +317,8 @@ def search_raw( ) -> list: """Return raw dicts instead of formatted text.""" try: - client = chromadb.PersistentClient(path=self.palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: return [] @@ -437,8 +438,8 @@ def status(self) -> dict: # Count drawers try: - client = chromadb.PersistentClient(path=self.palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) count = col.count() result["total_drawers"] = count except Exception: diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 09203b6b58..8cf79a6567 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -26,12 +26,11 @@ from datetime import datetime from pathlib import Path -from .config import MempalaceConfig, sanitize_name, sanitize_content +from .config import MempalaceConfig, sanitize_name, sanitize_content, get_chroma_client, get_collection_name from .version import __version__ from .query_sanitizer import sanitize_query from .searcher import search_memories from .palace_graph import traverse, find_tunnels, graph_stats -import chromadb from .knowledge_graph import KnowledgeGraph @@ -101,15 +100,11 @@ def _wal_log(operation: str, params: dict, result: dict = None): logger.error(f"WAL write failed: {e}") -_client_cache = None -_collection_cache = None - - def _get_client(): - """Return a singleton ChromaDB PersistentClient.""" + """Return a singleton ChromaDB HttpClient via multi-tenant config.""" global _client_cache if _client_cache is None: - _client_cache = chromadb.PersistentClient(path=_config.palace_path) + _client_cache = get_chroma_client() return _client_cache @@ -118,10 +113,11 @@ def _get_collection(create=False): global _collection_cache try: client = _get_client() + col_name = get_collection_name(_config) if create: - _collection_cache = client.get_or_create_collection(_config.collection_name) + _collection_cache = client.get_or_create_collection(col_name) elif _collection_cache is None: - _collection_cache = client.get_collection(_config.collection_name) + _collection_cache = client.get_collection(col_name) return _collection_cache except Exception: return None diff --git a/mempalace/miner.py b/mempalace/miner.py index f342a2d5c9..28c8c17791 100644 --- a/mempalace/miner.py +++ b/mempalace/miner.py @@ -15,8 +15,7 @@ from datetime import datetime from collections import defaultdict -import chromadb - +from .config import get_chroma_client, get_collection_name from .palace import SKIP_DIRS, get_collection, file_already_mined READABLE_EXTENSIONS = { @@ -625,8 +624,8 @@ def mine( def status(palace_path: str): """Show what's been filed in the palace.""" try: - client = chromadb.PersistentClient(path=palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: print(f"\n No palace found at {palace_path}") print(" Run: mempalace init then mempalace mine ") diff --git a/mempalace/palace.py b/mempalace/palace.py index 6ddf19084c..909eb6f926 100644 --- a/mempalace/palace.py +++ b/mempalace/palace.py @@ -5,7 +5,8 @@ """ import os -import chromadb + +from .config import get_chroma_client, get_collection_name SKIP_DIRS = { ".git", @@ -34,18 +35,19 @@ } -def get_collection(palace_path: str, collection_name: str = "mempalace_drawers"): +def get_collection(palace_path: str, collection_name: str = None): """Get or create the palace ChromaDB collection.""" os.makedirs(palace_path, exist_ok=True) try: os.chmod(palace_path, 0o700) except (OSError, NotImplementedError): pass - client = chromadb.PersistentClient(path=palace_path) + client = get_chroma_client() + col_name = collection_name or get_collection_name() try: - return client.get_collection(collection_name) + return client.get_collection(col_name) except Exception: - return client.create_collection(collection_name) + return client.create_collection(col_name) def file_already_mined(collection, source_file: str, check_mtime: bool = False) -> bool: diff --git a/mempalace/palace_graph.py b/mempalace/palace_graph.py index e4fda93edd..ace8779d67 100644 --- a/mempalace/palace_graph.py +++ b/mempalace/palace_graph.py @@ -19,13 +19,14 @@ from .config import MempalaceConfig import chromadb +from mempalace.config import get_chroma_client, get_collection_name def _get_collection(config=None): config = config or MempalaceConfig() try: - client = chromadb.PersistentClient(path=config.palace_path) - return client.get_collection(config.collection_name) + client = get_chroma_client() + return client.get_collection(get_collection_name(config)) except Exception: return None diff --git a/mempalace/searcher.py b/mempalace/searcher.py index 163abd88c5..6f87f774c3 100644 --- a/mempalace/searcher.py +++ b/mempalace/searcher.py @@ -10,6 +10,7 @@ from pathlib import Path import chromadb +from mempalace.config import get_chroma_client, get_collection_name logger = logging.getLogger("mempalace_mcp") @@ -24,8 +25,8 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r Optionally filter by wing (project) or room (aspect). """ try: - client = chromadb.PersistentClient(path=palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception: print(f"\n No palace found at {palace_path}") print(" Run: mempalace init then mempalace mine ") @@ -98,8 +99,8 @@ def search_memories( Used by the MCP server and other callers that need data. """ try: - client = chromadb.PersistentClient(path=palace_path) - col = client.get_collection("mempalace_drawers") + client = get_chroma_client() + col = client.get_collection(get_collection_name()) except Exception as e: logger.error("No palace found at %s: %s", palace_path, e) return {