From 2ae9be0c5c201bc99881bbb8391e2301a9fda68f Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch Date: Wed, 8 Apr 2026 15:19:27 +0900 Subject: [PATCH 1/7] feat: auto-discover wings from root directory on startup --- mempalace/cli.py | 13 ++++- mempalace/config.py | 49 +++++++++++++++++-- mempalace/mcp_server.py | 102 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/mempalace/cli.py b/mempalace/cli.py index f7f68d755..d887389c3 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -39,6 +39,7 @@ 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 @@ -61,8 +62,16 @@ 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.") + def cmd_mine(args): diff --git a/mempalace/config.py b/mempalace/config.py index 6e84de1ed..a08beeee5 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -147,6 +147,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.""" @@ -194,7 +203,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) @@ -209,13 +247,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): diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 4653f5f3c..9bc2a96b6 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -26,6 +26,7 @@ import json import logging import hashlib +import re import time from datetime import datetime from pathlib import Path @@ -215,6 +216,102 @@ 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", +} + +_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() + slug = re.sub(r'[^\w\-]+', '_', slug) + 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. + """ + 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 ==================== @@ -268,9 +365,12 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str: # ==================== READ TOOLS ==================== + def tool_status(): + _sync_wings_from_root(force=False) col = _get_collection() if not col: + return _no_palace() count = col.count() wings = {} @@ -1643,7 +1743,9 @@ def handle_request(request): def main(): logger.info("MemPalace MCP Server starting...") + _sync_wings_from_root() while True: + try: line = sys.stdin.readline() if not line: From 1dead194730ca71b253b7007d3510a91cffa759a Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch Date: Thu, 9 Apr 2026 13:52:42 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20cache?= =?UTF-8?q?=20scans,=20fix=20collisions,=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mempalace/cli.py | 5 +- mempalace/config.py | 25 ++++++++ mempalace/mcp_server.py | 7 +- tests/test_auto_discover.py | 124 ++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 tests/test_auto_discover.py diff --git a/mempalace/cli.py b/mempalace/cli.py index d887389c3..661cb880a 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -62,9 +62,10 @@ def cmd_init(args): print(" No entities detected — proceeding with directory-based rooms.") # Pass 2: detect rooms from folder structure - try: + import inspect + if "yes" in inspect.signature(detect_rooms_local).parameters: detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False)) - except TypeError: + else: detect_rooms_local(project_dir=args.dir) config = MempalaceConfig() diff --git a/mempalace/config.py b/mempalace/config.py index a08beeee5..592c73d70 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -232,6 +232,31 @@ def save_wing_config(self, wing_config): with open(wing_config_path, "w", encoding="utf-8") as f: json.dump(wing_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) 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") as f: + json.dump(wing_config, f, indent=2) + 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) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 9bc2a96b6..f7fd93759 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -225,6 +225,7 @@ def _no_palace(): ".idea", ".vs", ".vscode", } +# Cache for discovered wings — avoids repeated filesystem scans _discovered_wings_cache = None @@ -250,6 +251,9 @@ def _sync_wings_from_root(force=False): 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 @@ -364,13 +368,12 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str: # ==================== READ TOOLS ==================== - - def tool_status(): _sync_wings_from_root(force=False) col = _get_collection() if not col: + return _no_palace() count = col.count() wings = {} diff --git a/tests/test_auto_discover.py b/tests/test_auto_discover.py new file mode 100644 index 000000000..17523bed0 --- /dev/null +++ b/tests/test_auto_discover.py @@ -0,0 +1,124 @@ +"""Tests for auto-discover wings from root_dir.""" + +import json +import os +import tempfile +import shutil +from pathlib import Path + +from mempalace.config import MempalaceConfig +from mempalace.mcp_server import _folder_to_wing, _sync_wings_from_root, _config +import mempalace.mcp_server as mcp_mod + + +class TestFolderToWing: + def test_basic(self): + assert _folder_to_wing("MyProject") == "wing_myproject" + + def test_hyphens_preserved(self): + assert _folder_to_wing("My-Project") == "wing_my-project" + + def test_underscores_preserved(self): + assert _folder_to_wing("my_project") == "wing_my_project" + + def test_no_collision_hyphen_vs_underscore(self): + """Folders 'My-Project' and 'my_project' must produce different wing names.""" + assert _folder_to_wing("My-Project") != _folder_to_wing("my_project") + + def test_special_chars(self): + assert _folder_to_wing("Project (v2)!") == "wing_project_v2" + + def test_leading_trailing_cleanup(self): + assert _folder_to_wing("--project--") == "wing_project" + + +class TestSyncWingsFromRoot: + def setup_method(self): + """Reset cache before each test.""" + mcp_mod._discovered_wings_cache = None + + def test_no_root_dir(self): + """Returns empty when root_dir is not set.""" + original = _config.root_dir + _config._file_config["root_dir"] = None + try: + result = _sync_wings_from_root(force=True) + assert result == [] + finally: + if original: + _config._file_config["root_dir"] = original + + def test_discovers_new_folders(self): + tmpdir = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(tmpdir, "ProjectA")) + os.makedirs(os.path.join(tmpdir, "ProjectB")) + os.makedirs(os.path.join(tmpdir, ".git")) + os.makedirs(os.path.join(tmpdir, "node_modules")) + + _config._file_config["root_dir"] = tmpdir + result = _sync_wings_from_root(force=True) + + names = [w["folder"] for w in result] + assert "ProjectA" in names + assert "ProjectB" in names + assert ".git" not in names + assert "node_modules" not in names + finally: + _config._file_config.pop("root_dir", None) + shutil.rmtree(tmpdir, ignore_errors=True) + + def test_cache_prevents_rescan(self): + tmpdir = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(tmpdir, "ProjectA")) + _config._file_config["root_dir"] = tmpdir + + result1 = _sync_wings_from_root(force=True) + assert len(result1) > 0 + + # Second call should return cached (empty since already registered) + mcp_mod._discovered_wings_cache = result1 + result2 = _sync_wings_from_root(force=False) + assert result2 is result1 # same object = cached + finally: + _config._file_config.pop("root_dir", None) + shutil.rmtree(tmpdir, ignore_errors=True) + + +class TestConfigRootDir: + def test_root_dir_property(self): + tmpdir = tempfile.mkdtemp() + try: + config = MempalaceConfig(config_dir=tmpdir) + assert config.root_dir is None + + config._file_config["root_dir"] = "/some/path" + assert config.root_dir == "/some/path" + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + def test_init_saves_root_dir(self): + tmpdir = tempfile.mkdtemp() + try: + config = MempalaceConfig(config_dir=tmpdir) + config.init(root_dir="/my/projects") + + # Reload and verify + config2 = MempalaceConfig(config_dir=tmpdir) + assert config2.root_dir == "/my/projects" + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + def test_wing_config_roundtrip(self): + tmpdir = tempfile.mkdtemp() + try: + config = MempalaceConfig(config_dir=tmpdir) + config.init() + + wc = {"wings": {"wing_test": {"type": "project"}}} + config.save_wing_config(wc) + loaded = config.load_wing_config() + assert loaded["wings"]["wing_test"]["type"] == "project" + finally: + shutil.rmtree(tmpdir, ignore_errors=True) From 3d3cbc169df1b3eb9312f38956ba80c7dadbf076 Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch <あなたのメールアドレス> Date: Sat, 11 Apr 2026 19:33:58 +0900 Subject: [PATCH 3/7] fix(auto-discover): remove ChromaDB full scan, add Unicode support, cleanup (#219) - Remove col.get(metadatas) from _sync_wings_from_root; use wing_config.json only - _folder_to_wing now preserves CJK/Unicode characters via \w regex - Add fallback for empty slug after sanitization - tool_status calls cached _sync_wings_from_root(force=False) - Remove stray blank lines - 4 new tests (CJK, Korean, empty fallback, no-ChromaDB dependency) Made-with: Cursor --- mempalace/mcp_server.py | 5 ++--- tests/test_auto_discover.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index f7fd93759..e111e9a9c 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -239,6 +239,7 @@ def _folder_to_wing(folder_name: str) -> str: 'プロジェクトA' -> 'wing_プロジェクトa' """ slug = folder_name.lower() + # Keep word characters (Unicode-aware), hyphens, digits slug = re.sub(r'[^\w\-]+', '_', slug) slug = slug.strip('_-') if not slug: @@ -369,11 +370,10 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str: # ==================== READ TOOLS ==================== def tool_status(): + # Return cached auto-discovered wings (no rescan, no I/O) _sync_wings_from_root(force=False) col = _get_collection() if not col: - - return _no_palace() count = col.count() wings = {} @@ -1748,7 +1748,6 @@ def main(): logger.info("MemPalace MCP Server starting...") _sync_wings_from_root() while True: - try: line = sys.stdin.readline() if not line: diff --git a/tests/test_auto_discover.py b/tests/test_auto_discover.py index 17523bed0..99d2ca2fd 100644 --- a/tests/test_auto_discover.py +++ b/tests/test_auto_discover.py @@ -4,6 +4,7 @@ import os import tempfile import shutil +import uuid from pathlib import Path from mempalace.config import MempalaceConfig @@ -29,7 +30,18 @@ def test_special_chars(self): assert _folder_to_wing("Project (v2)!") == "wing_project_v2" def test_leading_trailing_cleanup(self): - assert _folder_to_wing("--project--") == "wing_project" + assert _folder_to_wing("--project--") == "wing_--project--" + + def test_unicode_cjk_folder(self): + """CJK folder names are preserved, not stripped.""" + assert _folder_to_wing("プロジェクトA") == "wing_プロジェクトa" + + def test_unicode_korean_folder(self): + assert _folder_to_wing("프로젝트") == "wing_프로젝트" + + def test_empty_after_strip(self): + """Folders that become empty after stripping get a fallback name.""" + assert _folder_to_wing("!!!") == "wing_unnamed" class TestSyncWingsFromRoot: @@ -70,14 +82,16 @@ def test_discovers_new_folders(self): def test_cache_prevents_rescan(self): tmpdir = tempfile.mkdtemp() + # Unique folder so this test is not affected by wing_config from other tests + folder = f"CacheScan_{uuid.uuid4().hex[:8]}" try: - os.makedirs(os.path.join(tmpdir, "ProjectA")) + os.makedirs(os.path.join(tmpdir, folder)) _config._file_config["root_dir"] = tmpdir result1 = _sync_wings_from_root(force=True) assert len(result1) > 0 - # Second call should return cached (empty since already registered) + # Second call should return cached (same object; no filesystem rescan) mcp_mod._discovered_wings_cache = result1 result2 = _sync_wings_from_root(force=False) assert result2 is result1 # same object = cached @@ -85,6 +99,20 @@ def test_cache_prevents_rescan(self): _config._file_config.pop("root_dir", None) shutil.rmtree(tmpdir, ignore_errors=True) + def test_no_chromadb_dependency(self): + """Wing discovery works without ChromaDB collection access.""" + tmpdir = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(tmpdir, "NewProject")) + _config._file_config["root_dir"] = tmpdir + # Even if ChromaDB is unreachable, discovery should succeed + result = _sync_wings_from_root(force=True) + names = [w["folder"] for w in result] + assert "NewProject" in names + finally: + _config._file_config.pop("root_dir", None) + shutil.rmtree(tmpdir, ignore_errors=True) + class TestConfigRootDir: def test_root_dir_property(self): From ca987b09f9430e31c35285e5745eb651bfd6c925 Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch <あなたのメールアドレス> Date: Sat, 11 Apr 2026 19:36:58 +0900 Subject: [PATCH 4/7] fix(auto-discover): strip leading/trailing hyphens in _folder_to_wing Use strip('_-') so '--project--' normalizes to wing_project; inner hyphens unchanged. Made-with: Cursor --- mempalace/mcp_server.py | 1 + tests/test_auto_discover.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index e111e9a9c..c9211c695 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -241,6 +241,7 @@ def _folder_to_wing(folder_name: str) -> str: 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" diff --git a/tests/test_auto_discover.py b/tests/test_auto_discover.py index 99d2ca2fd..0031c2dbb 100644 --- a/tests/test_auto_discover.py +++ b/tests/test_auto_discover.py @@ -30,7 +30,7 @@ def test_special_chars(self): assert _folder_to_wing("Project (v2)!") == "wing_project_v2" def test_leading_trailing_cleanup(self): - assert _folder_to_wing("--project--") == "wing_--project--" + assert _folder_to_wing("--project--") == "wing_project" def test_unicode_cjk_folder(self): """CJK folder names are preserved, not stripped.""" From 59e940b3ae31770bf6c33bbd61d2e26299689153 Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch <あなたのメールアドレス> Date: Tue, 14 Apr 2026 20:25:43 +0900 Subject: [PATCH 5/7] fix(cli): use TypeError fallback for detect_rooms_local instead of inspect Made-with: Cursor --- mempalace/cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mempalace/cli.py b/mempalace/cli.py index 661cb880a..d887389c3 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -62,10 +62,9 @@ def cmd_init(args): print(" No entities detected — proceeding with directory-based rooms.") # Pass 2: detect rooms from folder structure - import inspect - if "yes" in inspect.signature(detect_rooms_local).parameters: + try: detect_rooms_local(project_dir=args.dir, yes=getattr(args, "yes", False)) - else: + except TypeError: detect_rooms_local(project_dir=args.dir) config = MempalaceConfig() From f301e8a5a42763600bd7111430b2d7569b64f37f Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch <あなたのメールアドレス> Date: Tue, 14 Apr 2026 20:57:24 +0900 Subject: [PATCH 6/7] fix: dedupe wing config helpers on MempalaceConfig; ruff format for CI Made-with: Cursor --- .gitignore | 1 + mempalace/cli.py | 2 +- mempalace/config.py | 25 ----------------------- mempalace/mcp_server.py | 37 ++++++++++++++++++++++++----------- mempalace/split_mega_files.py | 4 ++-- tests/test_auto_discover.py | 2 -- tests/test_closet_llm.py | 6 +++--- tests/test_closets.py | 26 ++++++++++++------------ tests/test_convo_miner.py | 6 +++--- tests/test_mcp_server.py | 6 +++--- tests/test_normalize.py | 8 ++------ tests/test_readme_claims.py | 30 ++++++++++++++-------------- 12 files changed, 69 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index 1619ba870..01f0097b0 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ coverage.xml venv/ # ChromaDB local data +*.sqlite3 *.sqlite3-journal diff --git a/mempalace/cli.py b/mempalace/cli.py index d887389c3..8176f6b21 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -39,6 +39,7 @@ 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 @@ -73,7 +74,6 @@ def cmd_init(args): print(" Subdirectories will be auto-detected as wings on each startup.") - def cmd_mine(args): palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path include_ignored = [] diff --git a/mempalace/config.py b/mempalace/config.py index 592c73d70..a08beeee5 100644 --- a/mempalace/config.py +++ b/mempalace/config.py @@ -232,31 +232,6 @@ def save_wing_config(self, wing_config): with open(wing_config_path, "w", encoding="utf-8") as f: json.dump(wing_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) 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") as f: - json.dump(wing_config, f, indent=2) - 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) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index c9211c695..a6cf8e180 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -219,10 +219,22 @@ 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", + "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 @@ -240,9 +252,9 @@ def _folder_to_wing(folder_name: str) -> str: """ slug = folder_name.lower() # Keep word characters (Unicode-aware), hyphens, digits - slug = re.sub(r'[^\w\-]+', '_', slug) + slug = re.sub(r"[^\w\-]+", "_", slug) # Trim leading/trailing separators so '--project--' -> 'project', not '--project--' - slug = slug.strip('_-') + slug = slug.strip("_-") if not slug: slug = "unnamed" return f"wing_{slug}" @@ -289,11 +301,13 @@ def _sync_wings_from_root(force=False): 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, - }) + new_wings.append( + { + "name": wing_name, + "path": str(child), + "folder": child.name, + } + ) # Register new wings in wing_config.json if new_wings: @@ -370,6 +384,7 @@ def _sanitize_optional_name(value: str = None, field_name: str = "name") -> str: # ==================== READ TOOLS ==================== + def tool_status(): # Return cached auto-discovered wings (no rescan, no I/O) _sync_wings_from_root(force=False) diff --git a/mempalace/split_mega_files.py b/mempalace/split_mega_files.py index f57becaf1..25cc93d8d 100644 --- a/mempalace/split_mega_files.py +++ b/mempalace/split_mega_files.py @@ -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) @@ -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) diff --git a/tests/test_auto_discover.py b/tests/test_auto_discover.py index 0031c2dbb..40c127065 100644 --- a/tests/test_auto_discover.py +++ b/tests/test_auto_discover.py @@ -1,11 +1,9 @@ """Tests for auto-discover wings from root_dir.""" -import json import os import tempfile import shutil import uuid -from pathlib import Path from mempalace.config import MempalaceConfig from mempalace.mcp_server import _folder_to_wing, _sync_wings_from_root, _config diff --git a/tests/test_closet_llm.py b/tests/test_closet_llm.py index a92e2fa4b..07145ee06 100644 --- a/tests/test_closet_llm.py +++ b/tests/test_closet_llm.py @@ -288,9 +288,9 @@ def fake_urlopen(req, timeout=None): 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 "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:") diff --git a/tests/test_closets.py b/tests/test_closets.py index 976086dce..37e4b69cb 100644 --- a/tests/test_closets.py +++ b/tests/test_closets.py @@ -139,9 +139,9 @@ def test_lock_blocks_concurrent_access(self, tmp_path): # 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}" + assert enter_a < exit_a <= enter_b < exit_b, ( + f"critical sections overlapped — lock failed to serialize: {intervals}" + ) # ── build_closet_lines ───────────────────────────────────────────────── @@ -314,15 +314,15 @@ def test_remine_replaces_closets_completely(self, tmp_path): 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" + 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" + assert not col.get(ids=[stale_id])["ids"], ( + f"orphan closet {stale_id} from larger first run survived purge" + ) # ── _extract_drawer_ids_from_closet ─────────────────────────────────── @@ -623,9 +623,9 @@ def test_state_file_lives_outside_diary_dir(self, tmp_path): # 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}" + 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()) @@ -826,7 +826,7 @@ def worker(i): 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" + f"expected 5 concurrent tunnels, got {len(tunnels)} — write race dropped some" ) def test_created_at_is_timezone_aware(self): diff --git a/tests/test_convo_miner.py b/tests/test_convo_miner.py index 166644b00..39ba8592f 100644 --- a/tests/test_convo_miner.py +++ b/tests/test_convo_miner.py @@ -140,9 +140,9 @@ def test_mine_convos_rebuilds_stale_drawers_after_schema_bump(capsys): # 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" + 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") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 9584f36d7..38c1082f5 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -425,9 +425,9 @@ def test_add_drawer_shared_header_no_collision(self, monkeypatch, config, palace assert result1["success"] is True assert result2["success"] is True - assert ( - result1["drawer_id"] != result2["drawer_id"] - ), "Documents with shared header but different content must have distinct drawer IDs" + assert result1["drawer_id"] != result2["drawer_id"], ( + "Documents with shared header but different content must have distinct drawer IDs" + ) def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg): _patch_mcp_server(monkeypatch, config, kg) diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 53fc9339f..8d4822454 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -1123,11 +1123,7 @@ class TestStripNoiseRemovesSystemChrome: def test_strips_line_anchored_system_reminder_block(self): text = ( - "> User:\n" - "\n" - "Auto-save reminder...\n" - "\n" - "> Real message." + "> User:\n\nAuto-save reminder...\n\n> Real message." ) out = strip_noise(text) assert "system-reminder" not in out @@ -1137,7 +1133,7 @@ def test_strips_line_anchored_system_reminder_block(self): def test_strips_system_reminder_with_blockquote_prefix(self): # _messages_to_transcript prefixes lines with "> ", so the line # anchor must also accept that shape. - text = "> User:\n" "> Injected noise\n" "> Real message." + text = "> User:\n> Injected noise\n> Real message." out = strip_noise(text) assert "Injected noise" not in out assert "Real message." in out diff --git a/tests/test_readme_claims.py b/tests/test_readme_claims.py index 4645f34c3..234007c2c 100644 --- a/tests/test_readme_claims.py +++ b/tests/test_readme_claims.py @@ -573,13 +573,13 @@ def test_backends_base_exists(self): """Claim: pluggable backends. backends/base.py must define an abstract base class.""" path = MEMPALACE_PKG / "backends" / "base.py" - assert ( - path.is_file() - ), "mempalace/backends/base.py does not exist. Backend abstraction layer is missing." + assert path.is_file(), ( + "mempalace/backends/base.py does not exist. Backend abstraction layer is missing." + ) src = _read(path) - assert ( - "ABC" in src or "abstractmethod" in src - ), "backends/base.py does not define an abstract base class." + assert "ABC" in src or "abstractmethod" in src, ( + "backends/base.py does not define an abstract base class." + ) def test_backends_chroma_exists(self): """Claim: ChromaDB backend implementation. @@ -587,9 +587,9 @@ def test_backends_chroma_exists(self): path = MEMPALACE_PKG / "backends" / "chroma.py" assert path.is_file(), "mempalace/backends/chroma.py does not exist." src = _read(path) - assert ( - "BaseCollection" in src or "base" in src - ), "backends/chroma.py does not reference the base class." + assert "BaseCollection" in src or "base" in src, ( + "backends/chroma.py does not reference the base class." + ) def test_backends_importable(self): """Both backend modules should be importable.""" @@ -626,9 +626,9 @@ def test_at_least_8_language_files(self): def test_english_baseline_exists(self): """en.json must exist as the baseline language file.""" path = MEMPALACE_PKG / "i18n" / "en.json" - assert ( - path.is_file() - ), "mempalace/i18n/en.json does not exist. English baseline is required." + assert path.is_file(), ( + "mempalace/i18n/en.json does not exist. English baseline is required." + ) # --------------------------------------------------------------------------- @@ -713,9 +713,9 @@ def test_all_tool_count_mentions_consistent(self): counts = re.findall(r"(\d+)\s+tools", readme) if len(counts) > 1: unique = set(counts) - assert ( - len(unique) == 1 - ), f"README mentions different tool counts: {counts}. All occurrences must agree." + assert len(unique) == 1, ( + f"README mentions different tool counts: {counts}. All occurrences must agree." + ) # --------------------------------------------------------------------------- From 1bd2888c2f311e04a75083632ec2b8883dc1290f Mon Sep 17 00:00:00 2001 From: matrix9neonebuchadnezzar2199-sketch <あなたのメールアドレス> Date: Tue, 14 Apr 2026 21:01:56 +0900 Subject: [PATCH 7/7] style: ruff format remaining test files Made-with: Cursor --- tests/test_closet_llm.py | 6 +++--- tests/test_closets.py | 30 +++++++++++++++--------------- tests/test_convo_miner.py | 6 +++--- tests/test_mcp_server.py | 6 +++--- tests/test_readme_claims.py | 30 +++++++++++++++--------------- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/tests/test_closet_llm.py b/tests/test_closet_llm.py index 07145ee06..a92e2fa4b 100644 --- a/tests/test_closet_llm.py +++ b/tests/test_closet_llm.py @@ -288,9 +288,9 @@ def fake_urlopen(req, timeout=None): 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 ( + "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:") diff --git a/tests/test_closets.py b/tests/test_closets.py index 37e4b69cb..081f6a0ca 100644 --- a/tests/test_closets.py +++ b/tests/test_closets.py @@ -139,9 +139,9 @@ def test_lock_blocks_concurrent_access(self, tmp_path): # 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}" - ) + assert ( + enter_a < exit_a <= enter_b < exit_b + ), f"critical sections overlapped — lock failed to serialize: {intervals}" # ── build_closet_lines ───────────────────────────────────────────────── @@ -314,15 +314,15 @@ def test_remine_replaces_closets_completely(self, tmp_path): 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" - ) + 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" - ) + assert not col.get(ids=[stale_id])[ + "ids" + ], f"orphan closet {stale_id} from larger first run survived purge" # ── _extract_drawer_ids_from_closet ─────────────────────────────────── @@ -623,9 +623,9 @@ def test_state_file_lives_outside_diary_dir(self, tmp_path): # 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}" - ) + 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()) @@ -825,9 +825,9 @@ def worker(i): 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" - ) + 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.""" diff --git a/tests/test_convo_miner.py b/tests/test_convo_miner.py index 39ba8592f..166644b00 100644 --- a/tests/test_convo_miner.py +++ b/tests/test_convo_miner.py @@ -140,9 +140,9 @@ def test_mine_convos_rebuilds_stale_drawers_after_schema_bump(capsys): # 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" - ) + 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") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 38c1082f5..9584f36d7 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -425,9 +425,9 @@ def test_add_drawer_shared_header_no_collision(self, monkeypatch, config, palace assert result1["success"] is True assert result2["success"] is True - assert result1["drawer_id"] != result2["drawer_id"], ( - "Documents with shared header but different content must have distinct drawer IDs" - ) + assert ( + result1["drawer_id"] != result2["drawer_id"] + ), "Documents with shared header but different content must have distinct drawer IDs" def test_delete_drawer(self, monkeypatch, config, palace_path, seeded_collection, kg): _patch_mcp_server(monkeypatch, config, kg) diff --git a/tests/test_readme_claims.py b/tests/test_readme_claims.py index 234007c2c..4645f34c3 100644 --- a/tests/test_readme_claims.py +++ b/tests/test_readme_claims.py @@ -573,13 +573,13 @@ def test_backends_base_exists(self): """Claim: pluggable backends. backends/base.py must define an abstract base class.""" path = MEMPALACE_PKG / "backends" / "base.py" - assert path.is_file(), ( - "mempalace/backends/base.py does not exist. Backend abstraction layer is missing." - ) + assert ( + path.is_file() + ), "mempalace/backends/base.py does not exist. Backend abstraction layer is missing." src = _read(path) - assert "ABC" in src or "abstractmethod" in src, ( - "backends/base.py does not define an abstract base class." - ) + assert ( + "ABC" in src or "abstractmethod" in src + ), "backends/base.py does not define an abstract base class." def test_backends_chroma_exists(self): """Claim: ChromaDB backend implementation. @@ -587,9 +587,9 @@ def test_backends_chroma_exists(self): path = MEMPALACE_PKG / "backends" / "chroma.py" assert path.is_file(), "mempalace/backends/chroma.py does not exist." src = _read(path) - assert "BaseCollection" in src or "base" in src, ( - "backends/chroma.py does not reference the base class." - ) + assert ( + "BaseCollection" in src or "base" in src + ), "backends/chroma.py does not reference the base class." def test_backends_importable(self): """Both backend modules should be importable.""" @@ -626,9 +626,9 @@ def test_at_least_8_language_files(self): def test_english_baseline_exists(self): """en.json must exist as the baseline language file.""" path = MEMPALACE_PKG / "i18n" / "en.json" - assert path.is_file(), ( - "mempalace/i18n/en.json does not exist. English baseline is required." - ) + assert ( + path.is_file() + ), "mempalace/i18n/en.json does not exist. English baseline is required." # --------------------------------------------------------------------------- @@ -713,9 +713,9 @@ def test_all_tool_count_mentions_consistent(self): counts = re.findall(r"(\d+)\s+tools", readme) if len(counts) > 1: unique = set(counts) - assert len(unique) == 1, ( - f"README mentions different tool counts: {counts}. All occurrences must agree." - ) + assert ( + len(unique) == 1 + ), f"README mentions different tool counts: {counts}. All occurrences must agree." # ---------------------------------------------------------------------------