From 1b4ce0b1f8956436d7c6e9e5bb1ef314550db83d Mon Sep 17 00:00:00 2001 From: MSL <232237854+milla-jovovich@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:05:55 -0700 Subject: [PATCH] feat: explicit cross-wing tunnels for multi-project agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds active tunnel creation alongside passive tunnel discovery. Passive tunnels (existing): rooms with the same name across wings. Explicit tunnels (new): agent-created links between specific locations. "This API design in project_api relates to the database schema in project_database." New functions in palace_graph.py: - create_tunnel() — link two wing/room pairs with a label - list_tunnels() — list all explicit tunnels, filter by wing - delete_tunnel() — remove a tunnel by ID - follow_tunnels() — from a room, find all connected rooms in other wings with drawer content previews New MCP tools: - mempalace_create_tunnel - mempalace_list_tunnels - mempalace_delete_tunnel - mempalace_follow_tunnels Tunnels stored in ~/.mempalace/tunnels.json (persists across palace rebuilds). Deduplicated by endpoint pair. 689/689 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- mempalace/mcp_server.py | 109 ++++++++++++++++++++++++- mempalace/palace_graph.py | 162 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 1 deletion(-) diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 4e21426..89b74f7 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -35,7 +35,7 @@ from .version import __version__ import chromadb from .query_sanitizer import sanitize_query from .searcher import search_memories -from .palace_graph import traverse, find_tunnels, graph_stats +from .palace_graph import traverse, find_tunnels, graph_stats, create_tunnel, list_tunnels, delete_tunnel, follow_tunnels from .knowledge_graph import KnowledgeGraph @@ -496,6 +496,63 @@ def tool_graph_stats(): return graph_stats(col=col) +def tool_create_tunnel( + source_wing: str, + source_room: str, + target_wing: str, + target_room: str, + label: str = "", + source_drawer_id: str = None, + target_drawer_id: str = None, +): + """Create an explicit cross-wing tunnel between two palace locations. + + Use when you notice content in one project relates to another project. + Example: an API design discussion in project_api connects to the + database schema in project_database. + """ + try: + source_wing = sanitize_name(source_wing, "source_wing") + source_room = sanitize_name(source_room, "source_room") + target_wing = sanitize_name(target_wing, "target_wing") + target_room = sanitize_name(target_room, "target_room") + except ValueError as e: + return {"error": str(e)} + return create_tunnel( + source_wing, source_room, target_wing, target_room, + label=label, + source_drawer_id=source_drawer_id, + target_drawer_id=target_drawer_id, + ) + + +def tool_list_tunnels(wing: str = None): + """List all explicit cross-wing tunnels, optionally filtered by wing.""" + try: + wing = _sanitize_optional_name(wing, "wing") + except ValueError as e: + return {"error": str(e)} + return list_tunnels(wing) + + +def tool_delete_tunnel(tunnel_id: str): + """Delete an explicit tunnel by its ID.""" + if not tunnel_id or not isinstance(tunnel_id, str): + return {"error": "tunnel_id is required"} + return delete_tunnel(tunnel_id) + + +def tool_follow_tunnels(wing: str, room: str): + """Follow explicit tunnels from a room to see connected drawers in other wings.""" + try: + wing = sanitize_name(wing, "wing") + room = sanitize_name(room, "room") + except ValueError as e: + return {"error": str(e)} + col = _get_collection() + return follow_tunnels(wing, room, col=col) + + # ==================== WRITE TOOLS ==================== @@ -1181,6 +1238,56 @@ TOOLS = { "input_schema": {"type": "object", "properties": {}}, "handler": tool_graph_stats, }, + "mempalace_create_tunnel": { + "description": "Create a cross-wing tunnel linking two palace locations. Use when content in one project relates to another — e.g., an API design in project_api connects to a database schema in project_database.", + "input_schema": { + "type": "object", + "properties": { + "source_wing": {"type": "string", "description": "Wing of the source"}, + "source_room": {"type": "string", "description": "Room in the source wing"}, + "target_wing": {"type": "string", "description": "Wing of the target"}, + "target_room": {"type": "string", "description": "Room in the target wing"}, + "label": {"type": "string", "description": "Description of the connection"}, + "source_drawer_id": {"type": "string", "description": "Optional specific drawer ID"}, + "target_drawer_id": {"type": "string", "description": "Optional specific drawer ID"}, + }, + "required": ["source_wing", "source_room", "target_wing", "target_room"], + }, + "handler": tool_create_tunnel, + }, + "mempalace_list_tunnels": { + "description": "List all explicit cross-wing tunnels. Optionally filter by wing.", + "input_schema": { + "type": "object", + "properties": { + "wing": {"type": "string", "description": "Filter tunnels by wing (shows tunnels where wing is source or target)"}, + }, + }, + "handler": tool_list_tunnels, + }, + "mempalace_delete_tunnel": { + "description": "Delete an explicit tunnel by its ID.", + "input_schema": { + "type": "object", + "properties": { + "tunnel_id": {"type": "string", "description": "Tunnel ID to delete"}, + }, + "required": ["tunnel_id"], + }, + "handler": tool_delete_tunnel, + }, + "mempalace_follow_tunnels": { + "description": "Follow tunnels from a room to see what it connects to in other wings. Returns connected rooms with drawer previews.", + "input_schema": { + "type": "object", + "properties": { + "wing": {"type": "string", "description": "Wing to start from"}, + "room": {"type": "string", "description": "Room to follow tunnels from"}, + }, + "required": ["wing", "room"], + }, + "handler": tool_follow_tunnels, + }, "mempalace_search": { "description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY search keywords. Use 'context' for background. Results with cosine distance > max_distance are filtered out.", "input_schema": { diff --git a/mempalace/palace_graph.py b/mempalace/palace_graph.py index 5e2e72e..2792d99 100644 --- a/mempalace/palace_graph.py +++ b/mempalace/palace_graph.py @@ -15,7 +15,11 @@ Enables queries like: No external graph DB needed — built from ChromaDB metadata. """ +import hashlib +import json +import os from collections import defaultdict, Counter +from datetime import datetime from .config import MempalaceConfig from .palace import get_collection as _get_palace_collection @@ -228,3 +232,161 @@ def _fuzzy_match(query: str, nodes: dict, n: int = 5): scored.append((room, 0.5)) scored.sort(key=lambda x: -x[1]) return [r for r, _ in scored[:n]] + + +# ============================================================================= +# EXPLICIT TUNNELS — agent-created cross-wing links +# ============================================================================= +# Passive tunnels are discovered from shared room names across wings. +# Explicit tunnels are created by agents when they notice a connection +# between two specific drawers or rooms in different wings/projects. +# +# Stored as a JSON file at ~/.mempalace/tunnels.json so they persist +# across palace rebuilds (not in ChromaDB which can be recreated). + + +_TUNNEL_FILE = os.path.join(os.path.expanduser("~"), ".mempalace", "tunnels.json") + + +def _load_tunnels(): + """Load explicit tunnels from disk.""" + if os.path.exists(_TUNNEL_FILE): + try: + return json.loads(open(_TUNNEL_FILE).read()) + except Exception: + pass + return [] + + +def _save_tunnels(tunnels): + """Save explicit tunnels to disk.""" + os.makedirs(os.path.dirname(_TUNNEL_FILE), exist_ok=True) + with open(_TUNNEL_FILE, "w") as f: + json.dump(tunnels, f, indent=2) + + +def create_tunnel( + source_wing: str, + source_room: str, + target_wing: str, + target_room: str, + label: str = "", + source_drawer_id: str = None, + target_drawer_id: str = None, +): + """Create an explicit tunnel between two locations in the palace. + + Use when an agent notices a connection between two projects/wings + that wouldn't be found by passive room-name matching. + + Args: + source_wing: Wing of the source (e.g., "project_api") + source_room: Room in the source wing + target_wing: Wing of the target (e.g., "project_database") + target_room: Room in the target wing + label: Description of the connection + source_drawer_id: Optional specific drawer ID + target_drawer_id: Optional specific drawer ID + + Returns: + The created tunnel dict. + """ + tunnel_id = hashlib.sha256( + f"{source_wing}/{source_room}↔{target_wing}/{target_room}".encode() + ).hexdigest()[:16] + + tunnel = { + "id": tunnel_id, + "source": {"wing": source_wing, "room": source_room}, + "target": {"wing": target_wing, "room": target_room}, + "label": label, + "created_at": datetime.now().isoformat(), + } + if source_drawer_id: + tunnel["source"]["drawer_id"] = source_drawer_id + if target_drawer_id: + tunnel["target"]["drawer_id"] = target_drawer_id + + tunnels = _load_tunnels() + + # Dedup — don't create if same endpoints already linked + for existing in tunnels: + if existing.get("id") == tunnel_id: + existing.update(tunnel) # update label/drawers + _save_tunnels(tunnels) + return existing + + tunnels.append(tunnel) + _save_tunnels(tunnels) + return tunnel + + +def list_tunnels(wing: str = None): + """List all explicit tunnels, optionally filtered by wing. + + Returns tunnels where the wing appears as either source or target. + """ + tunnels = _load_tunnels() + if wing: + tunnels = [ + t for t in tunnels + if t["source"]["wing"] == wing or t["target"]["wing"] == wing + ] + return tunnels + + +def delete_tunnel(tunnel_id: str): + """Delete an explicit tunnel by ID.""" + tunnels = _load_tunnels() + tunnels = [t for t in tunnels if t.get("id") != tunnel_id] + _save_tunnels(tunnels) + return {"deleted": tunnel_id} + + +def follow_tunnels(wing: str, room: str, col=None, config=None): + """Follow explicit tunnels from a room — returns connected drawers. + + Given a location (wing/room), finds all tunnels leading from or to it, + and optionally fetches the connected drawer content. + """ + tunnels = _load_tunnels() + connections = [] + + for t in tunnels: + src = t["source"] + tgt = t["target"] + + if src["wing"] == wing and src["room"] == room: + connections.append({ + "direction": "outgoing", + "connected_wing": tgt["wing"], + "connected_room": tgt["room"], + "label": t.get("label", ""), + "drawer_id": tgt.get("drawer_id"), + "tunnel_id": t["id"], + }) + elif tgt["wing"] == wing and tgt["room"] == room: + connections.append({ + "direction": "incoming", + "connected_wing": src["wing"], + "connected_room": src["room"], + "label": t.get("label", ""), + "drawer_id": src.get("drawer_id"), + "tunnel_id": t["id"], + }) + + # If we have a collection, fetch drawer content for connected items + if col and connections: + drawer_ids = [c["drawer_id"] for c in connections if c.get("drawer_id")] + if drawer_ids: + try: + results = col.get(ids=drawer_ids, include=["documents", "metadatas"]) + drawer_map = dict(zip(results["ids"], results["documents"])) + for c in connections: + did = c.get("drawer_id") + if did and did in drawer_map: + c["drawer_preview"] = drawer_map[did][:300] + except Exception: + pass + + return connections