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}")