feat: explicit cross-wing tunnels for multi-project agents

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) <noreply@anthropic.com>
This commit is contained in:
MSL
2026-04-13 02:05:55 -07:00
committed by Igor Lins e Silva
parent f72ffbbcb2
commit 1b4ce0b1f8
2 changed files with 270 additions and 1 deletions
+108 -1
View File
@@ -35,7 +35,7 @@ from .version import __version__
import chromadb import chromadb
from .query_sanitizer import sanitize_query from .query_sanitizer import sanitize_query
from .searcher import search_memories 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 from .knowledge_graph import KnowledgeGraph
@@ -496,6 +496,63 @@ def tool_graph_stats():
return graph_stats(col=col) 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 ==================== # ==================== WRITE TOOLS ====================
@@ -1181,6 +1238,56 @@ TOOLS = {
"input_schema": {"type": "object", "properties": {}}, "input_schema": {"type": "object", "properties": {}},
"handler": tool_graph_stats, "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": { "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.", "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": { "input_schema": {
+162
View File
@@ -15,7 +15,11 @@ Enables queries like:
No external graph DB needed — built from ChromaDB metadata. No external graph DB needed — built from ChromaDB metadata.
""" """
import hashlib
import json
import os
from collections import defaultdict, Counter from collections import defaultdict, Counter
from datetime import datetime
from .config import MempalaceConfig from .config import MempalaceConfig
from .palace import get_collection as _get_palace_collection 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.append((room, 0.5))
scored.sort(key=lambda x: -x[1]) scored.sort(key=lambda x: -x[1])
return [r for r, _ in scored[:n]] 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