Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ coverage.xml
venv/

# ChromaDB local data
*.sqlite3
*.sqlite3-journal
13 changes: 11 additions & 2 deletions mempalace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
def cmd_init(args):
import json
from pathlib import Path

directory = str(Path(args.dir).expanduser().resolve())
from .entity_detector import scan_for_detection, detect_entities, confirm_entities
from .room_detector_local import detect_rooms_local

Expand All @@ -92,8 +94,15 @@ 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))
MempalaceConfig().init()
try:
detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False))
except TypeError:
detect_rooms_local(project_dir=args.dir)

config = MempalaceConfig()
config.init(root_dir=directory)
print(f"\n Root directory set: {directory}")
print(" Subdirectories will be auto-detected as wings on each startup.")

# Pass 3: protect git repos from accidentally committing per-project files
_ensure_mempalace_files_gitignored(args.dir)
Expand Down
49 changes: 46 additions & 3 deletions mempalace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ def palace_path(self):
return env_val
return self._file_config.get("palace_path", DEFAULT_PALACE_PATH)

@property
def root_dir(self):
"""Root directory specified during init.
Subdirectories become wings automatically."""
env_val = os.environ.get("MEMPALACE_ROOT_DIR")
if env_val:
return env_val
return self._file_config.get("root_dir", None)

@property
def collection_name(self):
"""ChromaDB collection name."""
Expand Down Expand Up @@ -218,7 +227,36 @@ def set_hook_setting(self, key: str, value: bool):
except OSError:
pass

def init(self):
def _save(self):
"""Persist current config to disk."""
self._config_dir.mkdir(parents=True, exist_ok=True)
with open(self._config_file, "w", encoding="utf-8") as f:
json.dump(self._file_config, f, indent=2, ensure_ascii=False)

@property
def config_dir(self):
"""Public access to the config directory path."""
return self._config_dir

def load_wing_config(self):
"""Load wing_config.json and return as dict."""
wing_config_path = self._config_dir / "wing_config.json"
if wing_config_path.exists():
try:
with open(wing_config_path, encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {}

def save_wing_config(self, wing_config):
"""Save wing_config.json."""
self._config_dir.mkdir(parents=True, exist_ok=True)
wing_config_path = self._config_dir / "wing_config.json"
with open(wing_config_path, "w", encoding="utf-8") as f:
json.dump(wing_config, f, indent=2, ensure_ascii=False)

def init(self, root_dir=None):
"""Create config directory and write default config.json if it doesn't exist."""
self._config_dir.mkdir(parents=True, exist_ok=True)
# Restrict directory permissions to owner only (Unix)
Expand All @@ -233,13 +271,18 @@ def init(self):
"topic_wings": DEFAULT_TOPIC_WINGS,
"hall_keywords": DEFAULT_HALL_KEYWORDS,
}
with open(self._config_file, "w") as f:
json.dump(default_config, f, indent=2)
if root_dir:
default_config["root_dir"] = str(root_dir)
with open(self._config_file, "w", encoding="utf-8") as f:
json.dump(default_config, f, indent=2, ensure_ascii=False)
# Restrict config file to owner read/write only
try:
self._config_file.chmod(0o600)
except (OSError, NotImplementedError):
pass
elif root_dir:
self._file_config["root_dir"] = str(root_dir)
self._save()
return self._config_file

def save_people_map(self, people_map):
Expand Down
120 changes: 120 additions & 0 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
import json # noqa: E402
import logging # noqa: E402
import hashlib # noqa: E402
import re # noqa: E402
import time # noqa: E402
from datetime import datetime # noqa: E402
from pathlib import Path # noqa: E402
Expand Down Expand Up @@ -240,6 +241,122 @@ def _no_palace():
}


# ==================== AUTO-DISCOVER WINGS ====================

IGNORE_DIRS = {
"node_modules",
".git",
".cursor",
"__pycache__",
".venv",
"venv",
".env",
"dist",
"build",
"target",
".next",
".nuxt",
".mempalace",
".idea",
".vs",
".vscode",
}

# Cache for discovered wings — avoids repeated filesystem scans
_discovered_wings_cache = None


def _folder_to_wing(folder_name: str) -> str:
"""Normalize a folder name into a valid wing name.

Preserves Unicode characters (CJK, etc.), hyphens, and underscores.
Leading/trailing hyphens and underscores are stripped.
'My-Project' -> 'wing_my-project'
'my_project' -> 'wing_my_project'
'プロジェクトA' -> 'wing_プロジェクトa'
"""
slug = folder_name.lower()
# Keep word characters (Unicode-aware), hyphens, digits
slug = re.sub(r"[^\w\-]+", "_", slug)
# Trim leading/trailing separators so '--project--' -> 'project', not '--project--'
slug = slug.strip("_-")
if not slug:
slug = "unnamed"
return f"wing_{slug}"


def _sync_wings_from_root(force=False):
"""Scan subdirectories under root_dir and register new ones as wings.

Called once on server startup. Results are cached so subsequent
calls (e.g. from tool_status) are free unless force=True.

New folders become wings automatically. Deleted folders are left alone
(memories are preserved).
"""
global _discovered_wings_cache

if _discovered_wings_cache is not None and not force:
return _discovered_wings_cache

root_dir = _config.root_dir
if not root_dir:
_discovered_wings_cache = []
return []

root_path = Path(root_dir).expanduser().resolve()
if not root_path.is_dir():
logger.warning(f"root_dir not found: {root_dir}")
_discovered_wings_cache = []
return []

# Known wings from wing_config.json (single source of truth)
wing_config = _config.load_wing_config()
known_wings = set(wing_config.get("wings", {}).keys())

# Scan subdirectories
new_wings = []
for child in sorted(root_path.iterdir()):
if not child.is_dir():
continue
if child.name.startswith("."):
continue
if child.name.lower() in IGNORE_DIRS:
continue

wing_name = _folder_to_wing(child.name)
if wing_name not in known_wings:
new_wings.append(
{
"name": wing_name,
"path": str(child),
"folder": child.name,
}
)

# Register new wings in wing_config.json
if new_wings:
wings = wing_config.get("wings", {})
for w in new_wings:
wings[w["name"]] = {
"type": "project",
"path": w["path"],
"keywords": [w["folder"].lower()],
"auto_discovered": True,
}
wing_config["wings"] = wings
if "default_wing" not in wing_config:
wing_config["default_wing"] = "wing_general"

_config.save_wing_config(wing_config)

names = ", ".join(w["folder"] for w in new_wings)
logger.info(f"Auto-discovered {len(new_wings)} new wing(s): {names}")

_discovered_wings_cache = new_wings
return new_wings


# ==================== HELPERS ====================


Expand Down Expand Up @@ -294,6 +411,8 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str:


def tool_status():
# Return cached auto-discovered wings (no rescan, no I/O)
_sync_wings_from_root(force=False)
# 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).
Expand Down Expand Up @@ -1686,6 +1805,7 @@ def _restore_stdout():
def main():
_restore_stdout()
logger.info("MemPalace MCP Server starting...")
_sync_wings_from_root()
while True:
try:
line = sys.stdin.readline()
Expand Down
4 changes: 2 additions & 2 deletions mempalace/split_mega_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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