diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 7d263a66c2..cc8138b0cc 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -2,7 +2,7 @@ """ MemPalace MCP Server — read/write palace access for Claude Code ================================================================ -Install: claude mcp add mempalace -- python -m mempalace.mcp_server [--palace /path/to/palace] +Install: claude mcp add mempalace -- python /path/to/mcp_server.py Tools (read): mempalace_status — total drawers, wing/room breakdown @@ -17,8 +17,6 @@ mempalace_delete_drawer — remove a drawer by ID """ -import argparse -import os import sys import json import logging @@ -26,55 +24,27 @@ from datetime import datetime from .config import MempalaceConfig -from .version import __version__ from .searcher import search_memories from .palace_graph import traverse, find_tunnels, graph_stats -import chromadb - from .knowledge_graph import KnowledgeGraph -logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr) -logger = logging.getLogger("mempalace_mcp") - +_kg = KnowledgeGraph() -def _parse_args(): - parser = argparse.ArgumentParser(description="MemPalace MCP Server") - parser.add_argument( - "--palace", - metavar="PATH", - help="Path to the palace directory (overrides config file and env var)", - ) - args, _ = parser.parse_known_args() - return args - - -_args = _parse_args() +import chromadb -if _args.palace: - os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(_args.palace) +logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr) +logger = logging.getLogger("mempalace_mcp") _config = MempalaceConfig() -if _args.palace: - _kg = KnowledgeGraph(db_path=os.path.join(_config.palace_path, "knowledge_graph.sqlite3")) -else: - _kg = KnowledgeGraph() - - -_client_cache = None -_collection_cache = None def _get_collection(create=False): - """Return the ChromaDB collection, caching the client between calls.""" - global _client_cache, _collection_cache + """Return the ChromaDB collection, or None on failure.""" try: - if _client_cache is None: - _client_cache = chromadb.PersistentClient(path=_config.palace_path) + client = chromadb.PersistentClient(path=_config.palace_path) if create: - _collection_cache = _client_cache.get_or_create_collection(_config.collection_name) - elif _collection_cache is None: - _collection_cache = _client_cache.get_collection(_config.collection_name) - return _collection_cache + return client.get_or_create_collection(_config.collection_name) + return client.get_collection(_config.collection_name) except Exception: return None @@ -82,9 +52,24 @@ def _get_collection(create=False): def _no_palace(): return { "error": "No palace found", + "palace_path": _config.palace_path, "hint": "Run: mempalace init && mempalace mine ", } +def _get_all_metadatas_batch(col, batch_size=1000): + """Get all metadatas in batches to avoid SQLite variable limit.""" + offset = 0 + all_metadatas = [] + while True: + result = col.get(offset=offset, limit=batch_size, include=["metadatas"]) + if not result["ids"]: + break + all_metadatas.extend(result["metadatas"]) + offset += batch_size + return all_metadatas + + + # ==================== READ TOOLS ==================== @@ -97,7 +82,7 @@ def tool_status(): wings = {} rooms = {} try: - all_meta = col.get(include=["metadatas"], limit=10000)["metadatas"] + all_meta = _get_all_metadatas_batch(col) for m in all_meta: w = m.get("wing", "unknown") r = m.get("room", "unknown") @@ -154,7 +139,7 @@ def tool_list_wings(): return _no_palace() wings = {} try: - all_meta = col.get(include=["metadatas"], limit=10000)["metadatas"] + all_meta = _get_all_metadatas_batch(col) for m in all_meta: w = m.get("wing", "unknown") wings[w] = wings.get(w, 0) + 1 @@ -169,7 +154,7 @@ def tool_list_rooms(wing: str = None): return _no_palace() rooms = {} try: - kwargs = {"include": ["metadatas"], "limit": 10000} + kwargs = {"include": ["metadatas"]} if wing: kwargs["where"] = {"wing": wing} all_meta = col.get(**kwargs)["metadatas"] @@ -187,7 +172,7 @@ def tool_get_taxonomy(): return _no_palace() taxonomy = {} try: - all_meta = col.get(include=["metadatas"], limit=10000)["metadatas"] + all_meta = _get_all_metadatas_batch(col) for m in all_meta: w = m.get("wing", "unknown") r = m.get("room", "unknown") @@ -284,18 +269,19 @@ def tool_add_drawer( if not col: return _no_palace() - drawer_id = f"drawer_{wing}_{room}_{hashlib.md5(content.encode()).hexdigest()[:16]}" + # Duplicate check + dup = tool_check_duplicate(content, threshold=0.9) + if dup.get("is_duplicate"): + return { + "success": False, + "reason": "duplicate", + "matches": dup["matches"], + } - # Idempotency: if the deterministic ID already exists, return success as a no-op. - try: - existing = col.get(ids=[drawer_id]) - if existing and existing["ids"]: - return {"success": True, "reason": "already_exists", "drawer_id": drawer_id} - except Exception: - pass + drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((content[:100] + datetime.now().isoformat()).encode()).hexdigest()[:16]}" try: - col.upsert( + col.add( ids=[drawer_id], documents=[content], metadatas=[ @@ -340,24 +326,19 @@ def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"): return {"entity": entity, "as_of": as_of, "facts": results, "count": len(results)} -def tool_kg_add( - subject: str, predicate: str, object: str, valid_from: str = None, source_closet: str = None -): +def tool_kg_add(subject: str, predicate: str, object: str, + valid_from: str = None, source_closet: str = None): """Add a relationship to the knowledge graph.""" - triple_id = _kg.add_triple( - subject, predicate, object, valid_from=valid_from, source_closet=source_closet - ) - return {"success": True, "triple_id": triple_id, "fact": f"{subject} → {predicate} → {object}"} + triple_id = _kg.add_triple(subject, predicate, object, + valid_from=valid_from, source_closet=source_closet) + return {"success": True, "triple_id": triple_id, + "fact": f"{subject} → {predicate} → {object}"} def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = None): """Mark a fact as no longer true (set end date).""" _kg.invalidate(subject, predicate, object, ended=ended) - return { - "success": True, - "fact": f"{subject} → {predicate} → {object}", - "ended": ended or "today", - } + return {"success": True, "fact": f"{subject} → {predicate} → {object}", "ended": ended or "today"} def tool_kg_timeline(entity: str = None): @@ -395,18 +376,16 @@ def tool_diary_write(agent_name: str, entry: str, topic: str = "general"): col.add( ids=[entry_id], documents=[entry], - metadatas=[ - { - "wing": wing, - "room": room, - "hall": "hall_diary", - "topic": topic, - "type": "diary_entry", - "agent": agent_name, - "filed_at": now.isoformat(), - "date": now.strftime("%Y-%m-%d"), - } - ], + metadatas=[{ + "wing": wing, + "room": room, + "hall": "hall_diary", + "topic": topic, + "type": "diary_entry", + "agent": agent_name, + "filed_at": now.isoformat(), + "date": now.strftime("%Y-%m-%d"), + }], ) logger.info(f"Diary entry: {entry_id} → {wing}/diary/{topic}") return { @@ -434,7 +413,6 @@ def tool_diary_read(agent_name: str, last_n: int = 10): results = col.get( where={"$and": [{"wing": wing}, {"room": "diary"}]}, include=["documents", "metadatas"], - limit=10000, ) if not results["ids"]: @@ -443,14 +421,12 @@ def tool_diary_read(agent_name: str, last_n: int = 10): # Combine and sort by timestamp entries = [] for doc, meta in zip(results["documents"], results["metadatas"]): - entries.append( - { - "date": meta.get("date", ""), - "timestamp": meta.get("filed_at", ""), - "topic": meta.get("topic", ""), - "content": doc, - } - ) + entries.append({ + "date": meta.get("date", ""), + "timestamp": meta.get("filed_at", ""), + "topic": meta.get("topic", ""), + "content": doc, + }) entries.sort(key=lambda x: x["timestamp"], reverse=True) entries = entries[:last_n] @@ -503,18 +479,9 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "input_schema": { "type": "object", "properties": { - "entity": { - "type": "string", - "description": "Entity to query (e.g. 'Max', 'MyProject', 'Alice')", - }, - "as_of": { - "type": "string", - "description": "Date filter — only facts valid at this date (YYYY-MM-DD, optional)", - }, - "direction": { - "type": "string", - "description": "outgoing (entity→?), incoming (?→entity), or both (default: both)", - }, + "entity": {"type": "string", "description": "Entity to query (e.g. 'Max', 'MyProject', 'Alice')"}, + "as_of": {"type": "string", "description": "Date filter — only facts valid at this date (YYYY-MM-DD, optional)"}, + "direction": {"type": "string", "description": "outgoing (entity→?), incoming (?→entity), or both (default: both)"}, }, "required": ["entity"], }, @@ -526,19 +493,10 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "type": "object", "properties": { "subject": {"type": "string", "description": "The entity doing/being something"}, - "predicate": { - "type": "string", - "description": "The relationship type (e.g. 'loves', 'works_on', 'daughter_of')", - }, + "predicate": {"type": "string", "description": "The relationship type (e.g. 'loves', 'works_on', 'daughter_of')"}, "object": {"type": "string", "description": "The entity being connected to"}, - "valid_from": { - "type": "string", - "description": "When this became true (YYYY-MM-DD, optional)", - }, - "source_closet": { - "type": "string", - "description": "Closet ID where this fact appears (optional)", - }, + "valid_from": {"type": "string", "description": "When this became true (YYYY-MM-DD, optional)"}, + "source_closet": {"type": "string", "description": "Closet ID where this fact appears (optional)"}, }, "required": ["subject", "predicate", "object"], }, @@ -552,10 +510,7 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "subject": {"type": "string", "description": "Entity"}, "predicate": {"type": "string", "description": "Relationship"}, "object": {"type": "string", "description": "Connected entity"}, - "ended": { - "type": "string", - "description": "When it stopped being true (YYYY-MM-DD, default: today)", - }, + "ended": {"type": "string", "description": "When it stopped being true (YYYY-MM-DD, default: today)"}, }, "required": ["subject", "predicate", "object"], }, @@ -566,10 +521,7 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "input_schema": { "type": "object", "properties": { - "entity": { - "type": "string", - "description": "Entity to get timeline for (optional — omit for full timeline)", - }, + "entity": {"type": "string", "description": "Entity to get timeline for (optional — omit for full timeline)"}, }, }, "handler": tool_kg_timeline, @@ -584,14 +536,8 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "input_schema": { "type": "object", "properties": { - "start_room": { - "type": "string", - "description": "Room to start from (e.g. 'chromadb-setup', 'riley-school')", - }, - "max_hops": { - "type": "integer", - "description": "How many connections to follow (default: 2)", - }, + "start_room": {"type": "string", "description": "Room to start from (e.g. 'chromadb-setup', 'riley-school')"}, + "max_hops": {"type": "integer", "description": "How many connections to follow (default: 2)"}, }, "required": ["start_room"], }, @@ -679,18 +625,9 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "input_schema": { "type": "object", "properties": { - "agent_name": { - "type": "string", - "description": "Your name — each agent gets their own diary wing", - }, - "entry": { - "type": "string", - "description": "Your diary entry in AAAK format — compressed, entity-coded, emotion-marked", - }, - "topic": { - "type": "string", - "description": "Topic tag (optional, default: general)", - }, + "agent_name": {"type": "string", "description": "Your name — each agent gets their own diary wing"}, + "entry": {"type": "string", "description": "Your diary entry in AAAK format — compressed, entity-coded, emotion-marked"}, + "topic": {"type": "string", "description": "Topic tag (optional, default: general)"}, }, "required": ["agent_name", "entry"], }, @@ -701,14 +638,8 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "input_schema": { "type": "object", "properties": { - "agent_name": { - "type": "string", - "description": "Your name — each agent gets their own diary wing", - }, - "last_n": { - "type": "integer", - "description": "Number of recent entries to read (default: 10)", - }, + "agent_name": {"type": "string", "description": "Your name — each agent gets their own diary wing"}, + "last_n": {"type": "integer", "description": "Number of recent entries to read (default: 10)"}, }, "required": ["agent_name"], }, @@ -729,7 +660,7 @@ def handle_request(request): "result": { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, - "serverInfo": {"name": "mempalace", "version": __version__}, + "serverInfo": {"name": "mempalace", "version": "2.0.0"}, }, } elif method == "notifications/initialized": @@ -754,17 +685,6 @@ def handle_request(request): "id": req_id, "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"}, } - # Coerce argument types based on input_schema. - # MCP JSON transport may deliver integers as floats or strings; - # ChromaDB and Python slicing require native int. - schema_props = TOOLS[tool_name]["input_schema"].get("properties", {}) - for key, value in list(tool_args.items()): - prop_schema = schema_props.get(key, {}) - declared_type = prop_schema.get("type") - if declared_type == "integer" and not isinstance(value, int): - tool_args[key] = int(value) - elif declared_type == "number" and not isinstance(value, (int, float)): - tool_args[key] = float(value) try: result = TOOLS[tool_name]["handler"](**tool_args) return { @@ -772,13 +692,9 @@ def handle_request(request): "id": req_id, "result": {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]}, } - except Exception: - logger.exception(f"Tool error in {tool_name}") - return { - "jsonrpc": "2.0", - "id": req_id, - "error": {"code": -32000, "message": "Internal tool error"}, - } + except Exception as e: + logger.error(f"Tool error in {tool_name}: {e}") + return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": str(e)}} return { "jsonrpc": "2.0", diff --git a/mempalace/miner.py b/mempalace/miner.py index 66fbe03a6c..d8bd51e13d 100644 --- a/mempalace/miner.py +++ b/mempalace/miner.py @@ -10,7 +10,6 @@ import os import sys import hashlib -import fnmatch from pathlib import Path from datetime import datetime from collections import defaultdict @@ -52,27 +51,6 @@ ".next", "coverage", ".mempalace", - ".ruff_cache", - ".mypy_cache", - ".pytest_cache", - ".cache", - ".tox", - ".nox", - ".idea", - ".vscode", - ".ipynb_checkpoints", - ".eggs", - "htmlcov", - "target", -} - -SKIP_FILENAMES = { - "mempalace.yaml", - "mempalace.yml", - "mempal.yaml", - "mempal.yml", - ".gitignore", - "package-lock.json", } CHUNK_SIZE = 800 # chars per drawer @@ -80,196 +58,6 @@ MIN_CHUNK_SIZE = 50 # skip tiny chunks -# ============================================================================= -# IGNORE MATCHING -# ============================================================================= - - -class GitignoreMatcher: - """Lightweight matcher for one directory's .gitignore patterns.""" - - def __init__(self, base_dir: Path, rules: list): - self.base_dir = base_dir - self.rules = rules - - @classmethod - def from_dir(cls, dir_path: Path): - gitignore_path = dir_path / ".gitignore" - if not gitignore_path.is_file(): - return None - - try: - lines = gitignore_path.read_text(encoding="utf-8", errors="replace").splitlines() - except Exception: - return None - - rules = [] - for raw_line in lines: - line = raw_line.strip() - if not line: - continue - - if line.startswith("\\#") or line.startswith("\\!"): - line = line[1:] - elif line.startswith("#"): - continue - - negated = line.startswith("!") - if negated: - line = line[1:] - - anchored = line.startswith("/") - if anchored: - line = line.lstrip("/") - - dir_only = line.endswith("/") - if dir_only: - line = line.rstrip("/") - - if not line: - continue - - rules.append( - { - "pattern": line, - "anchored": anchored, - "dir_only": dir_only, - "negated": negated, - } - ) - - if not rules: - return None - - return cls(dir_path, rules) - - def matches(self, path: Path, is_dir: bool = None): - try: - relative = path.relative_to(self.base_dir).as_posix().strip("/") - except ValueError: - return None - - if not relative: - return None - - if is_dir is None: - is_dir = path.is_dir() - - ignored = None - for rule in self.rules: - if self._rule_matches(rule, relative, is_dir): - ignored = not rule["negated"] - return ignored - - def _rule_matches(self, rule: dict, relative: str, is_dir: bool) -> bool: - pattern = rule["pattern"] - parts = relative.split("/") - pattern_parts = pattern.split("/") - - if rule["dir_only"]: - target_parts = parts if is_dir else parts[:-1] - if not target_parts: - return False - if rule["anchored"] or len(pattern_parts) > 1: - return self._match_from_root(target_parts, pattern_parts) - return any(fnmatch.fnmatch(part, pattern) for part in target_parts) - - if rule["anchored"] or len(pattern_parts) > 1: - return self._match_from_root(parts, pattern_parts) - - return any(fnmatch.fnmatch(part, pattern) for part in parts) - - def _match_from_root(self, target_parts: list, pattern_parts: list) -> bool: - def matches(path_index: int, pattern_index: int) -> bool: - if pattern_index == len(pattern_parts): - return True - - if path_index == len(target_parts): - return all(part == "**" for part in pattern_parts[pattern_index:]) - - pattern_part = pattern_parts[pattern_index] - if pattern_part == "**": - return matches(path_index, pattern_index + 1) or matches( - path_index + 1, pattern_index - ) - - if not fnmatch.fnmatch(target_parts[path_index], pattern_part): - return False - - return matches(path_index + 1, pattern_index + 1) - - return matches(0, 0) - - -def load_gitignore_matcher(dir_path: Path, cache: dict): - """Load and cache one directory's .gitignore matcher.""" - if dir_path not in cache: - cache[dir_path] = GitignoreMatcher.from_dir(dir_path) - return cache[dir_path] - - -def is_gitignored(path: Path, matchers: list, is_dir: bool = False) -> bool: - """Apply active .gitignore matchers in ancestor order; last match wins.""" - ignored = False - for matcher in matchers: - decision = matcher.matches(path, is_dir=is_dir) - if decision is not None: - ignored = decision - return ignored - - -def should_skip_dir(dirname: str) -> bool: - """Skip known generated/cache directories before gitignore matching.""" - return dirname in SKIP_DIRS or dirname.endswith(".egg-info") - - -def normalize_include_paths(include_ignored: list) -> set: - """Normalize comma-parsed include paths into project-relative POSIX strings.""" - normalized = set() - for raw_path in include_ignored or []: - candidate = str(raw_path).strip().strip("/") - if candidate: - normalized.add(Path(candidate).as_posix()) - return normalized - - -def is_exact_force_include(path: Path, project_path: Path, include_paths: set) -> bool: - """Return True when a path exactly matches an explicit include override.""" - if not include_paths: - return False - - try: - relative = path.relative_to(project_path).as_posix().strip("/") - except ValueError: - return False - - return relative in include_paths - - -def is_force_included(path: Path, project_path: Path, include_paths: set) -> bool: - """Return True when a path or one of its ancestors/descendants was explicitly included.""" - if not include_paths: - return False - - try: - relative = path.relative_to(project_path).as_posix().strip("/") - except ValueError: - return False - - if not relative: - return False - - for include_path in include_paths: - if relative == include_path: - return True - if relative.startswith(f"{include_path}/"): - return True - if include_path.startswith(f"{relative}/"): - return True - - return False - - # ============================================================================= # CONFIG # ============================================================================= @@ -311,12 +99,21 @@ def detect_room(filepath: Path, content: str, rooms: list, project_path: Path) - filename = filepath.stem.lower() content_lower = content[:2000].lower() - # Priority 1: folder path matches room name or keywords + # Priority 1: explicit 'path' in room config (exact match) + for room in rooms: + room_path = room.get("path", "").lower() + if room_path and relative.startswith(room_path): + return room["name"] + + # Priority 1b: folder path contains room name (fallback) path_parts = relative.replace("\\", "/").split("/") for part in path_parts[:-1]: # skip filename itself for room in rooms: - candidates = [room["name"].lower()] + [k.lower() for k in room.get("keywords", [])] - if any(part == c or c in part or part in c for c in candidates): + room_name = room["name"].lower() + # Handle underscores vs dashes: core_java <-> core-java + normalized = room_name.replace("_", "-") + if (room_name in part or part in room_name or + normalized in part or part in normalized): return room["name"] # Priority 2: filename matches room name @@ -402,53 +199,61 @@ def get_collection(palace_path: str): return client.create_collection("mempalace_drawers") -def file_already_mined(collection, source_file: str) -> bool: - """Fast check: has this file been filed before and is unchanged? +def compute_content_hash(content: str) -> str: + """Compute SHA-256 hash of file content.""" + return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] + - Compares the stored mtime in drawer metadata against the file's current - mtime. Returns False (needs re-mining) when the file has been modified - since it was last mined, or when no mtime was stored. +def file_already_mined(collection, source_file: str, content_hash: str = None) -> tuple[bool, list]: + """ + Check if file was already mined AND whether content changed. + Returns: (is_unchanged, existing_drawer_ids) + - is_unchanged=True → content identical, skip file + - is_unchanged=False → content changed or new file, re-index needed """ try: - results = collection.get(where={"source_file": source_file}, limit=1) - if not results.get("ids"): - return False - stored_meta = results["metadatas"][0] if results.get("metadatas") else {} - stored_mtime = stored_meta.get("source_mtime") - if stored_mtime is None: - return False - current_mtime = os.path.getmtime(source_file) - return float(stored_mtime) == current_mtime + results = collection.get(where={"source_file": source_file}, include=["metadatas"]) + drawer_ids = results.get("ids", []) + if not drawer_ids: + return False, [] # New file + + # Check stored hash in first drawer's metadata + metadatas = results.get("metadatas", []) + if metadatas and content_hash: + stored_hash = metadatas[0].get("content_hash") + if stored_hash == content_hash: + return True, drawer_ids # Content unchanged + return False, drawer_ids # Content changed or no hash except Exception: - return False + return False, [] def add_drawer( - collection, wing: str, room: str, content: str, source_file: str, chunk_index: int, agent: str + collection, wing: str, room: str, content: str, source_file: str, + chunk_index: int, agent: str, content_hash: str = None ): """Add one drawer to the palace.""" - drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk_index)).encode(), usedforsecurity=False).hexdigest()[:16]}" + drawer_id = f"drawer_{wing}_{room}_{hashlib.md5((source_file + str(chunk_index)).encode()).hexdigest()[:16]}" try: - metadata = { - "wing": wing, - "room": room, - "source_file": source_file, - "chunk_index": chunk_index, - "added_by": agent, - "filed_at": datetime.now().isoformat(), - } - # Store file mtime so we can detect modifications later. - try: - metadata["source_mtime"] = os.path.getmtime(source_file) - except OSError: - pass - collection.upsert( + collection.add( documents=[content], ids=[drawer_id], - metadatas=[metadata], + metadatas=[ + { + "wing": wing, + "room": room, + "source_file": source_file, + "chunk_index": chunk_index, + "added_by": agent, + "filed_at": datetime.now().isoformat(), + "content_hash": content_hash or "", + } + ], ) return True - except Exception: + except Exception as e: + if "already exists" in str(e).lower() or "duplicate" in str(e).lower(): + return False raise @@ -465,29 +270,40 @@ def process_file( rooms: list, agent: str, dry_run: bool, -) -> tuple: - """Read, chunk, route, and file one file. Returns (drawer_count, room_name).""" +) -> int: + """Read, chunk, route, and file one file. Returns drawer count.""" - # Skip if already filed source_file = str(filepath) - if not dry_run and file_already_mined(collection, source_file): - return 0, None try: content = filepath.read_text(encoding="utf-8", errors="replace") - except OSError: - return 0, None + except Exception: + return 0 content = content.strip() if len(content) < MIN_CHUNK_SIZE: - return 0, None + return 0 + + content_hash = compute_content_hash(content) + + if not dry_run: + is_unchanged, old_drawer_ids = file_already_mined(collection, source_file, content_hash) + if is_unchanged: + return 0 # Content unchanged, skip + elif old_drawer_ids: + # Content changed — delete old drawers first + for old_id in old_drawer_ids: + try: + collection.delete(ids=[old_id]) + except Exception: + pass room = detect_room(filepath, content, rooms, project_path) chunks = chunk_text(content, source_file) if dry_run: print(f" [DRY RUN] {filepath.name} → room:{room} ({len(chunks)} drawers)") - return len(chunks), room + return len(chunks) drawers_added = 0 for chunk in chunks: @@ -499,11 +315,12 @@ def process_file( source_file=source_file, chunk_index=chunk["chunk_index"], agent=agent, + content_hash=content_hash, ) if added: drawers_added += 1 - return drawers_added, room + return drawers_added # ============================================================================= @@ -511,58 +328,26 @@ def process_file( # ============================================================================= -def scan_project( - project_dir: str, - respect_gitignore: bool = True, - include_ignored: list = None, -) -> list: +def scan_project(project_dir: str) -> list: """Return list of all readable file paths.""" project_path = Path(project_dir).expanduser().resolve() files = [] - active_matchers = [] - matcher_cache = {} - include_paths = normalize_include_paths(include_ignored) - for root, dirs, filenames in os.walk(project_path): - root_path = Path(root) - - if respect_gitignore: - active_matchers = [ - matcher - for matcher in active_matchers - if root_path == matcher.base_dir or matcher.base_dir in root_path.parents - ] - current_matcher = load_gitignore_matcher(root_path, matcher_cache) - if current_matcher is not None: - active_matchers.append(current_matcher) - - dirs[:] = [ - d - for d in dirs - if is_force_included(root_path / d, project_path, include_paths) - or not should_skip_dir(d) - ] - if respect_gitignore and active_matchers: - dirs[:] = [ - d - for d in dirs - if is_force_included(root_path / d, project_path, include_paths) - or not is_gitignored(root_path / d, active_matchers, is_dir=True) - ] - + dirs[:] = [d for d in dirs if d not in SKIP_DIRS] for filename in filenames: - filepath = root_path / filename - force_include = is_force_included(filepath, project_path, include_paths) - exact_force_include = is_exact_force_include(filepath, project_path, include_paths) - - if not force_include and filename in SKIP_FILENAMES: - continue - if filepath.suffix.lower() not in READABLE_EXTENSIONS and not exact_force_include: - continue - if respect_gitignore and active_matchers and not force_include: - if is_gitignored(filepath, active_matchers, is_dir=False): + filepath = Path(root) / filename + if filepath.suffix.lower() in READABLE_EXTENSIONS: + # Skip config files + if filename in ( + "mempalace.yaml", + "mempalace.yml", + "mempal.yaml", + "mempal.yml", + ".gitignore", + "package-lock.json", + ): continue - files.append(filepath) + files.append(filepath) return files @@ -578,8 +363,6 @@ def mine( agent: str = "mempalace", limit: int = 0, dry_run: bool = False, - respect_gitignore: bool = True, - include_ignored: list = None, ): """Mine a project directory into the palace.""" @@ -589,11 +372,7 @@ def mine( wing = wing_override or config["wing"] rooms = config.get("rooms", [{"name": "general", "description": "All project files"}]) - files = scan_project( - project_dir, - respect_gitignore=respect_gitignore, - include_ignored=include_ignored, - ) + files = scan_project(project_dir) if limit > 0: files = files[:limit] @@ -606,10 +385,6 @@ def mine( print(f" Palace: {palace_path}") if dry_run: print(" DRY RUN — nothing will be filed") - if not respect_gitignore: - print(" .gitignore: DISABLED") - if include_ignored: - print(f" Include: {', '.join(sorted(normalize_include_paths(include_ignored)))}") print(f"{'─' * 55}\n") if not dry_run: @@ -622,7 +397,7 @@ def mine( room_counts = defaultdict(int) for i, filepath in enumerate(files, 1): - drawers, room = process_file( + drawers = process_file( filepath=filepath, project_path=project_path, collection=collection, @@ -635,6 +410,7 @@ def mine( files_skipped += 1 else: total_drawers += drawers + room = detect_room(filepath, "", rooms, project_path) room_counts[room] += 1 if not dry_run: print(f" ✓ [{i:4}/{len(files)}] {filepath.name[:50]:50} +{drawers}")