Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<your-username>/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)
```

Expand Down
2 changes: 1 addition & 1 deletion hooks/mempal_precompact_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion hooks/mempal_save_hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 <dir> then mempalace mine <dir>")
Expand Down
58 changes: 57 additions & 1 deletion mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import re
from pathlib import Path

import chromadb


# ── Input validation ──────────────────────────────────────────────────────────
# Shared sanitizers for wing/room/entity names. Prevents path traversal,
Expand Down Expand Up @@ -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_<uuid>_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",
Expand Down Expand Up @@ -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_<uuid>').

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."""
Expand Down
22 changes: 13 additions & 9 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)


Expand Down
21 changes: 11 additions & 10 deletions mempalace/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import chromadb

from .config import MempalaceConfig
from mempalace.config import get_chroma_client, get_collection_name


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 <dir>"

Expand Down Expand Up @@ -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."

Expand Down Expand Up @@ -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."

Expand Down Expand Up @@ -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 []

Expand Down Expand Up @@ -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:
Expand Down
16 changes: 6 additions & 10 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand All @@ -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
Expand Down
7 changes: 3 additions & 4 deletions mempalace/miner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 <dir> then mempalace mine <dir>")
Expand Down
12 changes: 7 additions & 5 deletions mempalace/palace.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"""

import os
import chromadb

from .config import get_chroma_client, get_collection_name

SKIP_DIRS = {
".git",
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions mempalace/palace_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions mempalace/searcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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 <dir> then mempalace mine <dir>")
Expand Down Expand Up @@ -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 {
Expand Down