ca5899e361
- B904: chain OSError/collection errors with "raise ... from e" in
normalize.py and searcher.py so the original traceback is preserved.
- B007: rename unused loop variables to _name in dedup, dialect, layers,
and room_detector_local.
- S110/S112: replace bare "try/except/pass" and "try/except/continue"
with logger.debug(..., exc_info=True) in mcp_server, searcher,
palace, palace_graph, miner, convo_miner, and fact_checker so
background failures are observable without changing behaviour.
A module-level logger ("mempalace_mcp", matching mcp_server/searcher)
is added to the five files that didn't already have one. Configured
ruff checks (E/F/W/C901) and ruff --select B, S110, S112 all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
340 lines
11 KiB
Python
340 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
room_detector_local.py — Local setup, no API required.
|
|
|
|
Two ways to define rooms without calling any AI:
|
|
1. Auto-detect from folder structure (zero config)
|
|
2. Define manually in mempalace.yaml
|
|
|
|
No internet. No API key. Your files stay on your machine.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import sys
|
|
import yaml
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Common room patterns — detected from folder names and filenames
|
|
# Format: {folder_keyword: room_name}
|
|
FOLDER_ROOM_MAP = {
|
|
"frontend": "frontend",
|
|
"front-end": "frontend",
|
|
"front_end": "frontend",
|
|
"client": "frontend",
|
|
"ui": "frontend",
|
|
"views": "frontend",
|
|
"components": "frontend",
|
|
"pages": "frontend",
|
|
"backend": "backend",
|
|
"back-end": "backend",
|
|
"back_end": "backend",
|
|
"server": "backend",
|
|
"api": "backend",
|
|
"routes": "backend",
|
|
"services": "backend",
|
|
"controllers": "backend",
|
|
"models": "backend",
|
|
"database": "backend",
|
|
"db": "backend",
|
|
"docs": "documentation",
|
|
"doc": "documentation",
|
|
"documentation": "documentation",
|
|
"wiki": "documentation",
|
|
"readme": "documentation",
|
|
"notes": "documentation",
|
|
"design": "design",
|
|
"designs": "design",
|
|
"mockups": "design",
|
|
"wireframes": "design",
|
|
"assets": "design",
|
|
"storyboard": "design",
|
|
"costs": "costs",
|
|
"cost": "costs",
|
|
"budget": "costs",
|
|
"finance": "costs",
|
|
"financial": "costs",
|
|
"pricing": "costs",
|
|
"invoices": "costs",
|
|
"accounting": "costs",
|
|
"meetings": "meetings",
|
|
"meeting": "meetings",
|
|
"calls": "meetings",
|
|
"meeting_notes": "meetings",
|
|
"standup": "meetings",
|
|
"minutes": "meetings",
|
|
"team": "team",
|
|
"staff": "team",
|
|
"hr": "team",
|
|
"hiring": "team",
|
|
"employees": "team",
|
|
"people": "team",
|
|
"research": "research",
|
|
"references": "research",
|
|
"reading": "research",
|
|
"papers": "research",
|
|
"planning": "planning",
|
|
"roadmap": "planning",
|
|
"strategy": "planning",
|
|
"specs": "planning",
|
|
"requirements": "planning",
|
|
"tests": "testing",
|
|
"test": "testing",
|
|
"testing": "testing",
|
|
"qa": "testing",
|
|
"scripts": "scripts",
|
|
"tools": "scripts",
|
|
"utils": "scripts",
|
|
"config": "configuration",
|
|
"configs": "configuration",
|
|
"settings": "configuration",
|
|
"infrastructure": "configuration",
|
|
"infra": "configuration",
|
|
"deploy": "configuration",
|
|
}
|
|
|
|
|
|
def detect_rooms_from_folders(project_dir: str) -> list:
|
|
"""
|
|
Walk the project folder structure.
|
|
Find top-level subdirectories that match known room patterns.
|
|
Returns list of room dicts.
|
|
"""
|
|
project_path = Path(project_dir).expanduser().resolve()
|
|
found_rooms = {}
|
|
|
|
SKIP_DIRS = {
|
|
".git",
|
|
"node_modules",
|
|
"__pycache__",
|
|
".venv",
|
|
"venv",
|
|
"env",
|
|
"dist",
|
|
"build",
|
|
".next",
|
|
"coverage",
|
|
}
|
|
|
|
# Check top-level directories first (most reliable signal)
|
|
for item in project_path.iterdir():
|
|
try:
|
|
is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
|
|
except OSError as exc:
|
|
logger.debug("Skipping %s: %s", item, exc)
|
|
continue
|
|
if is_dir and item.name not in SKIP_DIRS:
|
|
name_lower = item.name.lower().replace("-", "_")
|
|
if name_lower in FOLDER_ROOM_MAP:
|
|
room_name = FOLDER_ROOM_MAP[name_lower]
|
|
if room_name not in found_rooms:
|
|
found_rooms[room_name] = item.name
|
|
# Also check if folder name IS a good room name directly
|
|
elif len(item.name) > 2 and item.name[0].isalpha():
|
|
clean = item.name.lower().replace("-", "_").replace(" ", "_")
|
|
if clean not in found_rooms:
|
|
found_rooms[clean] = item.name
|
|
|
|
# Walk one level deeper for nested patterns
|
|
for item in project_path.iterdir():
|
|
try:
|
|
item_is_dir = item.is_dir() # WinError 448 — reparse point / untrusted mount point
|
|
except OSError as exc:
|
|
logger.debug("Skipping %s: %s", item, exc)
|
|
continue
|
|
if item_is_dir and item.name not in SKIP_DIRS:
|
|
try:
|
|
subitems = list(
|
|
item.iterdir()
|
|
) # WinError 448 — iterdir can also fail on some reparse points
|
|
except OSError as exc:
|
|
logger.debug("Skipping contents of %s: %s", item, exc)
|
|
continue
|
|
for subitem in subitems:
|
|
try:
|
|
subitem_is_dir = (
|
|
subitem.is_dir()
|
|
) # WinError 448 — reparse point / untrusted mount point
|
|
except OSError as exc:
|
|
logger.debug("Skipping %s: %s", subitem, exc)
|
|
continue
|
|
if subitem_is_dir and subitem.name not in SKIP_DIRS:
|
|
name_lower = subitem.name.lower().replace("-", "_")
|
|
if name_lower in FOLDER_ROOM_MAP:
|
|
room_name = FOLDER_ROOM_MAP[name_lower]
|
|
if room_name not in found_rooms:
|
|
found_rooms[room_name] = subitem.name
|
|
|
|
# Build room list
|
|
rooms = []
|
|
for room_name, original in found_rooms.items():
|
|
rooms.append(
|
|
{
|
|
"name": room_name,
|
|
"description": f"Files from {original}/",
|
|
"keywords": [room_name, original.lower()],
|
|
}
|
|
)
|
|
|
|
# Always add "general" as fallback
|
|
if not any(r["name"] == "general" for r in rooms):
|
|
rooms.append(
|
|
{
|
|
"name": "general",
|
|
"description": "Files that don't fit other rooms",
|
|
"keywords": [],
|
|
}
|
|
)
|
|
|
|
return rooms
|
|
|
|
|
|
def detect_rooms_from_files(project_dir: str) -> list:
|
|
"""
|
|
Fallback: if folder structure gives no signal,
|
|
detect rooms from recurring filename patterns.
|
|
"""
|
|
project_path = Path(project_dir).expanduser().resolve()
|
|
keyword_counts = defaultdict(int)
|
|
|
|
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
|
|
|
|
for _root, dirs, filenames in os.walk(project_path):
|
|
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
|
|
for filename in filenames:
|
|
name_lower = filename.lower().replace("-", "_").replace(" ", "_")
|
|
for keyword, room in FOLDER_ROOM_MAP.items():
|
|
if keyword in name_lower:
|
|
keyword_counts[room] += 1
|
|
|
|
# Return rooms that appear more than twice
|
|
rooms = []
|
|
for room, count in sorted(keyword_counts.items(), key=lambda x: x[1], reverse=True):
|
|
if count >= 2:
|
|
rooms.append(
|
|
{
|
|
"name": room,
|
|
"description": f"Files related to {room}",
|
|
"keywords": [room],
|
|
}
|
|
)
|
|
if len(rooms) >= 6:
|
|
break
|
|
|
|
if not rooms:
|
|
rooms = [{"name": "general", "description": "All project files", "keywords": []}]
|
|
|
|
return rooms
|
|
|
|
|
|
def print_proposed_structure(project_name: str, rooms: list, total_files: int, source: str):
|
|
print(f"\n{'=' * 55}")
|
|
print(" MemPalace Init — Local setup")
|
|
print(f"{'=' * 55}")
|
|
print(f"\n WING: {project_name}")
|
|
print(f" ({total_files} files found, rooms detected from {source})\n")
|
|
for room in rooms:
|
|
print(f" ROOM: {room['name']}")
|
|
print(f" {room['description']}")
|
|
print(f"\n{'─' * 55}")
|
|
|
|
|
|
def get_user_approval(rooms: list) -> list:
|
|
"""Same approval flow as AI version."""
|
|
print(" Review the proposed rooms above.")
|
|
print(" Options:")
|
|
print(" [enter] Accept all rooms")
|
|
print(" [edit] Remove or rename rooms")
|
|
print(" [add] Add a room manually")
|
|
print()
|
|
|
|
choice = input(" Your choice [enter/edit/add]: ").strip().lower()
|
|
|
|
if choice in ("", "y", "yes"):
|
|
return rooms
|
|
|
|
if choice == "edit":
|
|
print("\n Current rooms:")
|
|
for i, room in enumerate(rooms):
|
|
print(f" {i + 1}. {room['name']} — {room['description']}")
|
|
remove = input("\n Room numbers to REMOVE (comma-separated, or enter to skip): ").strip()
|
|
if remove:
|
|
to_remove = {int(x.strip()) - 1 for x in remove.split(",") if x.strip().isdigit()}
|
|
rooms = [r for i, r in enumerate(rooms) if i not in to_remove]
|
|
|
|
if choice == "add" or input("\n Add any missing rooms? [y/N]: ").strip().lower() == "y":
|
|
while True:
|
|
new_name = (
|
|
input(" New room name (or enter to stop): ").strip().lower().replace(" ", "_")
|
|
)
|
|
if not new_name:
|
|
break
|
|
new_desc = input(f" Description for '{new_name}': ").strip()
|
|
rooms.append({"name": new_name, "description": new_desc, "keywords": [new_name]})
|
|
print(f" Added: {new_name}")
|
|
|
|
return rooms
|
|
|
|
|
|
def save_config(project_dir: str, project_name: str, rooms: list):
|
|
config = {
|
|
"wing": project_name,
|
|
"rooms": [
|
|
{
|
|
"name": r["name"],
|
|
"description": r["description"],
|
|
"keywords": r.get("keywords", [r["name"]]),
|
|
}
|
|
for r in rooms
|
|
],
|
|
}
|
|
config_path = Path(project_dir).expanduser().resolve() / "mempalace.yaml"
|
|
with open(config_path, "w") as f:
|
|
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
|
|
print(f"\n Config saved: {config_path}")
|
|
print("\n Next step:")
|
|
print(f" mempalace mine {project_dir}")
|
|
print(f"\n{'=' * 55}\n")
|
|
|
|
|
|
def detect_rooms_local(project_dir: str, yes: bool = False):
|
|
"""Main entry point for local setup."""
|
|
from .config import normalize_wing_name
|
|
|
|
project_path = Path(project_dir).expanduser().resolve()
|
|
project_name = normalize_wing_name(project_path.name)
|
|
|
|
if not project_path.exists():
|
|
print(f"ERROR: Directory not found: {project_dir}")
|
|
sys.exit(1)
|
|
|
|
# Count files
|
|
from .miner import scan_project
|
|
|
|
files = scan_project(project_dir)
|
|
|
|
# Try folder structure first
|
|
rooms = detect_rooms_from_folders(project_dir)
|
|
source = "folder structure"
|
|
|
|
# If only "general" found, try filename patterns
|
|
if len(rooms) <= 1:
|
|
rooms = detect_rooms_from_files(project_dir)
|
|
source = "filename patterns"
|
|
|
|
# If still nothing, just use general
|
|
if not rooms:
|
|
rooms = [{"name": "general", "description": "All project files", "keywords": []}]
|
|
source = "fallback (flat project)"
|
|
|
|
print_proposed_structure(project_name, rooms, len(files), source)
|
|
if yes:
|
|
approved_rooms = rooms
|
|
else:
|
|
approved_rooms = get_user_approval(rooms)
|
|
save_config(project_dir, project_name, approved_rooms)
|