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:
+108
-1
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user