diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md index fd98952409..a291eceb79 100644 --- a/.claude-plugin/README.md +++ b/.claude-plugin/README.md @@ -50,7 +50,7 @@ Set the `MEMPAL_DIR` environment variable to a directory path to automatically r ## MCP Server -The plugin automatically configures a local MCP server with 19 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything. +The plugin automatically configures a local MCP server with 21 tools for storing, searching, and managing memories. No manual MCP setup is required -- `/mempalace:init` handles everything. ## Full Documentation diff --git a/README.md b/README.md index a1a7ccb783..e1cfdbf9e9 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Restart Claude Code, then type `/skills` to verify "mempalace" appears. claude mcp add mempalace -- python -m mempalace.mcp_server ``` -Now your AI has 19 tools available through MCP. Ask it anything: +Now your AI has 21 tools available through MCP. Ask it anything: > *"What did we decide about auth last month?"* @@ -458,7 +458,7 @@ claude plugin install --scope user mempalace claude mcp add mempalace -- python -m mempalace.mcp_server ``` -### 19 Tools +### 21 Tools **Palace (read)** @@ -472,12 +472,16 @@ claude mcp add mempalace -- python -m mempalace.mcp_server | `mempalace_check_duplicate` | Check before filing | | `mempalace_get_aaak_spec` | AAAK dialect reference | +Search results now include an `id` field for each hit (the drawer ID). + **Palace (write)** | Tool | What | |------|------| | `mempalace_add_drawer` | File verbatim content | | `mempalace_delete_drawer` | Remove by ID | +| `mempalace_delete_wing` | Delete all drawers in a wing | +| `mempalace_delete_room` | Delete all drawers in a room | **Knowledge Graph** @@ -576,6 +580,10 @@ mempalace search "query" # search everything mempalace search "query" --wing myapp # within a wing mempalace search "query" --room auth-migration # within a room +# Delete +mempalace delete-wing # delete entire wing +mempalace delete-room # delete specific room + # Memory stack mempalace wake-up # load L0 + L1 context mempalace wake-up --wing driftwood # project-specific @@ -630,7 +638,7 @@ Plain text. Becomes Layer 0 — loaded every session. | `cli.py` | CLI entry point | | `config.py` | Configuration loading and defaults | | `normalize.py` | Converts 5 chat formats to standard transcript | -| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, memory protocol | +| `mcp_server.py` | MCP server — 21 tools, AAAK auto-teach, memory protocol | | `miner.py` | Project file ingest | | `convo_miner.py` | Conversation ingest — chunks by exchange pair | | `searcher.py` | Semantic search via ChromaDB | @@ -654,7 +662,7 @@ mempalace/ ├── README.md ← you are here ├── mempalace/ ← core package (README) │ ├── cli.py ← CLI entry point -│ ├── mcp_server.py ← MCP server (19 tools) +│ ├── mcp_server.py ← MCP server (21 tools) │ ├── knowledge_graph.py ← temporal entity graph │ ├── palace_graph.py ← room navigation graph │ ├── dialect.py ← AAAK compression diff --git a/mempalace/README.md b/mempalace/README.md index fdbbb62066..c74eae3089 100644 --- a/mempalace/README.md +++ b/mempalace/README.md @@ -16,7 +16,7 @@ The Python package that powers MemPalace. All modules, all logic. | `dialect.py` | AAAK compression — entity codes, emotion markers, 30x lossless ratio | | `knowledge_graph.py` | Temporal entity-relationship graph — SQLite, time-filtered queries, fact invalidation | | `palace_graph.py` | Room-based navigation graph — BFS traversal, tunnel detection across wings | -| `mcp_server.py` | MCP server — 19 tools, AAAK auto-teach, Palace Protocol, agent diary | +| `mcp_server.py` | MCP server — 21 tools, AAAK auto-teach, Palace Protocol, agent diary | | `onboarding.py` | Guided first-run setup — asks about people/projects, generates AAAK bootstrap + wing config | | `entity_registry.py` | Entity code registry — maps names to AAAK codes, handles ambiguous names | | `entity_detector.py` | Auto-detect people and projects from file content | diff --git a/mempalace/cli.py b/mempalace/cli.py index 0a24abf64d..6cd5fe3328 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -361,6 +361,71 @@ def cmd_compress(args): print(" (dry run -- nothing stored)") +def cmd_delete_wing(args): + """Delete an entire wing.""" + import chromadb + + config = MempalaceConfig() + palace_path = os.path.expanduser(args.palace) if args.palace else config.palace_path + + try: + client = chromadb.PersistentClient(path=palace_path) + col = client.get_collection(config.collection_name) + + results = col.get(where={"wing": args.wing}, include=[]) + count = len(results["ids"]) + + if count == 0: + print(f"Wing not found: {args.wing}") + return 1 + + if not args.force: + confirm = input(f"Delete wing '{args.wing}' with {count} drawers? [y/N] ") + if confirm.lower() != "y": + print("Aborted") + return 0 + + col.delete(where={"wing": args.wing}) + print(f"Deleted wing '{args.wing}' ({count} drawers)") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + + +def cmd_delete_room(args): + """Delete a room in a wing.""" + import chromadb + + config = MempalaceConfig() + palace_path = os.path.expanduser(args.palace) if args.palace else config.palace_path + + try: + client = chromadb.PersistentClient(path=palace_path) + col = client.get_collection(config.collection_name) + + where = {"$and": [{"wing": args.wing}, {"room": args.room}]} + results = col.get(where=where, include=[]) + count = len(results["ids"]) + + if count == 0: + print(f"Room not found: {args.wing}/{args.room}") + return 1 + + if not args.force: + confirm = input(f"Delete room '{args.wing}/{args.room}' with {count} drawers? [y/N] ") + if confirm.lower() != "y": + print("Aborted") + return 0 + + col.delete(where=where) + print(f"Deleted room '{args.wing}/{args.room}' ({count} drawers)") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + + def main(): parser = argparse.ArgumentParser( description="MemPalace — Give your AI a memory. No API key required.", @@ -503,6 +568,19 @@ def main(): # status sub.add_parser("status", help="Show what's been filed") + # delete-wing + p_delete_wing = sub.add_parser("delete-wing", help="Delete an entire wing") + p_delete_wing.add_argument("wing", help="Wing name to delete") + p_delete_wing.add_argument("--force", action="store_true", help="Skip confirmation") + p_delete_wing.set_defaults(func=cmd_delete_wing) + + # delete-room + p_delete_room = sub.add_parser("delete-room", help="Delete a room in a wing") + p_delete_room.add_argument("wing", help="Wing name") + p_delete_room.add_argument("room", help="Room name to delete") + p_delete_room.add_argument("--force", action="store_true", help="Skip confirmation") + p_delete_room.set_defaults(func=cmd_delete_room) + args = parser.parse_args() if not args.command: @@ -535,6 +613,8 @@ def main(): "wake-up": cmd_wakeup, "repair": cmd_repair, "status": cmd_status, + "delete-wing": cmd_delete_wing, + "delete-room": cmd_delete_room, } dispatch[args.command](args) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 7d263a66c2..23beb95636 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -331,6 +331,47 @@ def tool_delete_drawer(drawer_id: str): return {"success": False, "error": str(e)} +def tool_delete_wing(wing: str): + """Delete all drawers in a wing. Irreversible.""" + col = _get_collection() + if not col: + return _no_palace() + + results = col.get(where={"wing": wing}, include=[]) + count = len(results["ids"]) + + if count == 0: + return {"success": False, "error": f"Wing not found or empty: {wing}"} + + try: + col.delete(where={"wing": wing}) + logger.info(f"Deleted wing: {wing} ({count} drawers)") + return {"success": True, "wing": wing, "deleted_count": count} + except Exception as e: + return {"success": False, "error": str(e)} + + +def tool_delete_room(wing: str, room: str): + """Delete all drawers in a room. Irreversible.""" + col = _get_collection() + if not col: + return _no_palace() + + where = {"$and": [{"wing": wing}, {"room": room}]} + results = col.get(where=where, include=[]) + count = len(results["ids"]) + + if count == 0: + return {"success": False, "error": f"Room not found or empty: {wing}/{room}"} + + try: + col.delete(where=where) + logger.info(f"Deleted room: {wing}/{room} ({count} drawers)") + return {"success": True, "wing": wing, "room": room, "deleted_count": count} + except Exception as e: + return {"success": False, "error": str(e)} + + # ==================== KNOWLEDGE GRAPH ==================== @@ -674,6 +715,29 @@ def tool_diary_read(agent_name: str, last_n: int = 10): }, "handler": tool_delete_drawer, }, + "mempalace_delete_wing": { + "description": "Delete all drawers in a wing. Irreversible.", + "input_schema": { + "type": "object", + "properties": { + "wing": {"type": "string", "description": "Wing to delete"}, + }, + "required": ["wing"], + }, + "handler": tool_delete_wing, + }, + "mempalace_delete_room": { + "description": "Delete all drawers in a room. Irreversible.", + "input_schema": { + "type": "object", + "properties": { + "wing": {"type": "string", "description": "Wing containing the room"}, + "room": {"type": "string", "description": "Room to delete"}, + }, + "required": ["wing", "room"], + }, + "handler": tool_delete_room, + }, "mempalace_diary_write": { "description": "Write to your personal agent diary in AAAK format. Your observations, thoughts, what you worked on, what matters. Each agent has their own diary with full history. Write in AAAK for compression — e.g. 'SESSION:2026-04-04|built.palace.graph+diary.tools|ALC.req:agent.diaries.in.aaak|★★★'. Use entity codes from the AAAK spec.", "input_schema": { diff --git a/mempalace/searcher.py b/mempalace/searcher.py index 163abd88c5..e8e2eab05b 100644 --- a/mempalace/searcher.py +++ b/mempalace/searcher.py @@ -129,14 +129,16 @@ def search_memories( except Exception as e: return {"error": f"Search error: {e}"} + ids = results["ids"][0] docs = results["documents"][0] metas = results["metadatas"][0] dists = results["distances"][0] hits = [] - for doc, meta, dist in zip(docs, metas, dists): + for drawer_id, doc, meta, dist in zip(ids, docs, metas, dists): hits.append( { + "id": drawer_id, "text": doc, "wing": meta.get("wing", "unknown"), "room": meta.get("room", "unknown"), diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 24258a9159..f1a4c5d5f4 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -58,6 +58,8 @@ def test_tools_list(self): assert "mempalace_search" in names assert "mempalace_add_drawer" in names assert "mempalace_kg_add" in names + assert "mempalace_delete_wing" in names + assert "mempalace_delete_room" in names def test_unknown_tool(self): from mempalace.mcp_server import handle_request @@ -241,6 +243,40 @@ def test_delete_drawer_not_found(self, monkeypatch, config, palace_path, seeded_ result = tool_delete_drawer("nonexistent_drawer") assert result["success"] is False + def test_delete_wing(self, monkeypatch, config, palace_path, seeded_collection, kg): + _patch_mcp_server(monkeypatch, config, kg) + from mempalace.mcp_server import tool_delete_wing + + result = tool_delete_wing("project") + assert result["success"] is True + assert result["deleted_count"] == 3 + assert seeded_collection.count() == 1 + + def test_delete_room(self, monkeypatch, config, palace_path, seeded_collection, kg): + _patch_mcp_server(monkeypatch, config, kg) + from mempalace.mcp_server import tool_delete_room + + result = tool_delete_room("project", "backend") + assert result["success"] is True + assert result["deleted_count"] == 2 + assert seeded_collection.count() == 2 + + def test_delete_wing_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg): + _patch_mcp_server(monkeypatch, config, kg) + from mempalace.mcp_server import tool_delete_wing + + result = tool_delete_wing("nonexistent") + assert result["success"] is False + assert "wing not found" in result["error"].lower() + + def test_delete_room_not_found(self, monkeypatch, config, palace_path, seeded_collection, kg): + _patch_mcp_server(monkeypatch, config, kg) + from mempalace.mcp_server import tool_delete_room + + result = tool_delete_room("project", "nonexistent") + assert result["success"] is False + assert "room not found" in result["error"].lower() + def test_check_duplicate(self, monkeypatch, config, palace_path, seeded_collection, kg): _patch_mcp_server(monkeypatch, config, kg) from mempalace.mcp_server import tool_check_duplicate diff --git a/tests/test_searcher.py b/tests/test_searcher.py index 1c2687d461..a711c1c293 100644 --- a/tests/test_searcher.py +++ b/tests/test_searcher.py @@ -43,3 +43,9 @@ def test_result_fields(self, palace_path, seeded_collection): assert "source_file" in hit assert "similarity" in hit assert isinstance(hit["similarity"], float) + + def test_result_includes_drawer_id(self, palace_path, seeded_collection): + result = search_memories("authentication", palace_path) + hit = result["results"][0] + assert "id" in hit + assert hit["id"].startswith("drawer_")