diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0794c955ce..97f44630b7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "mempalace", "source": "./.claude-plugin", "description": "AI memory system — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, guided setup.", - "version": "3.0.11", + "version": "3.0.12", "author": { "name": "milla-jovovich" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 295b247069..7d927d43c9 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mempalace", - "version": "3.0.11", + "version": "3.0.12", "description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.", "author": { "name": "milla-jovovich" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 480a21f0e4..7d1a09efad 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mempalace", - "version": "3.0.11", + "version": "3.0.12", "description": "Give your AI a memory — mine projects and conversations into a searchable palace. 19 MCP tools, auto-save hooks, and guided setup.", "author": { "name": "milla-jovovich" diff --git a/mempalace/knowledge_graph.py b/mempalace/knowledge_graph.py index 226c92da54..08d8893200 100644 --- a/mempalace/knowledge_graph.py +++ b/mempalace/knowledge_graph.py @@ -51,6 +51,17 @@ def __init__(self, db_path: str = None): self.db_path = db_path or DEFAULT_KG_PATH Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) self._init_db() + self._apply_migrations() + + def _apply_migrations(self): + conn = self._conn() + try: + conn.execute("ALTER TABLE triples ADD COLUMN pheromone_level REAL DEFAULT 0.0") + except sqlite3.OperationalError: + pass # Column already exists + finally: + conn.commit() + conn.close() def _init_db(self): conn = self._conn() @@ -204,16 +215,18 @@ def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing" query += " AND (t.valid_from IS NULL OR t.valid_from <= ?) AND (t.valid_to IS NULL OR t.valid_to >= ?)" params.extend([as_of, as_of]) for row in conn.execute(query, params).fetchall(): + # To protect against schema version variance: + # 0: id, 1: sub, 2: pred, 3: obj, 4: from, 5: to, 6: conf, 7: src_closet or pheromone + # Last column is always the JOINed name. results.append( { "direction": "outgoing", "subject": name, "predicate": row[2], - "object": row[10], # obj_name + "object": row[-1], # obj_name is the last column "valid_from": row[4], "valid_to": row[5], "confidence": row[6], - "source_closet": row[7], "current": row[5] is None, } ) @@ -228,13 +241,12 @@ def query_entity(self, name: str, as_of: str = None, direction: str = "outgoing" results.append( { "direction": "incoming", - "subject": row[10], # sub_name + "subject": row[-1], # sub_name is the last column "predicate": row[2], "object": name, "valid_from": row[4], "valid_to": row[5], "confidence": row[6], - "source_closet": row[7], "current": row[5] is None, } ) @@ -262,9 +274,9 @@ def query_relationship(self, predicate: str, as_of: str = None): for row in conn.execute(query, params).fetchall(): results.append( { - "subject": row[10], + "subject": row[-2], # sub_name "predicate": pred, - "object": row[11], + "object": row[-1], # obj_name "valid_from": row[4], "valid_to": row[5], "current": row[5] is None, @@ -303,9 +315,9 @@ def timeline(self, entity_name: str = None): conn.close() return [ { - "subject": r[10], + "subject": r[-2], "predicate": r[2], - "object": r[11], + "object": r[-1], "valid_from": r[4], "valid_to": r[5], "current": r[5] is None, @@ -313,6 +325,128 @@ def timeline(self, entity_name: str = None): for r in rows ] + # ── Pheromone Traversal (STAN) ──────────────────────────────────────── + + def evaporate_pheromones(self, decay_rate: float = 0.1): + """Apply evaporation decay to all active pheromone trails.""" + conn = self._conn() + conn.execute( + "UPDATE triples SET pheromone_level = ROUND(pheromone_level * (1.0 - ?), 2) WHERE pheromone_level > 0", + (decay_rate,) + ) + conn.commit() + conn.close() + + def deposit_pheromone(self, subject: str, predicate: str, obj: str, amount: float = 1.0, max_pheromone: float = 100.0): + """Deposit pheromone on a specific edge to reinforce a successful path.""" + sub_id = self._entity_id(subject) + obj_id = self._entity_id(obj) + pred = predicate.lower().replace(" ", "_") + + conn = self._conn() + conn.execute( + """UPDATE triples + SET pheromone_level = MIN(COALESCE(pheromone_level, 0.0) + ?, ?) + WHERE subject=? AND predicate=? AND object=? AND valid_to IS NULL""", + (amount, max_pheromone, sub_id, pred, obj_id), + ) + conn.commit() + conn.close() + + def get_neighbors_with_pheromones(self, entity_id: str): + """Get all connected nodes for graph traversal with their pheromone levels.""" + conn = self._conn() + neighbors = [] + + # Outgoing + for row in conn.execute( + "SELECT object, predicate, COALESCE(pheromone_level, 0.0) FROM triples WHERE subject = ? AND valid_to IS NULL", + (entity_id,) + ).fetchall(): + neighbors.append({ + "node": row[0], + "predicate": row[1], + "pheromone": row[2], + "direction": "outgoing" + }) + + # Incoming + for row in conn.execute( + "SELECT subject, predicate, COALESCE(pheromone_level, 0.0) FROM triples WHERE object = ? AND valid_to IS NULL", + (entity_id,) + ).fetchall(): + neighbors.append({ + "node": row[0], + "predicate": row[1], + "pheromone": row[2], + "direction": "incoming" + }) + + conn.close() + return neighbors + + def stigmergic_astar(self, start_entity: str, target_entity: str, influence: float = 0.7, max_depth: int = 5): + """ + Find optimal path leveraging stigmergic edge weights from STAN. + cost_eff = 1.0 / (1 + pheromone * influence) + """ + import heapq + + start_id = self._entity_id(start_entity) + target_id = self._entity_id(target_entity) + + # Queue: (cost_so_far, current_node, path_history, depth) + # Using simple Dijkstra approach mapped with STAN's edge weights, + # since pure semantic distance heuristics (ChromaDB) require injection. + queue = [(0.0, start_id, [], 0)] + g_scores = {start_id: 0.0} + visited = set() + + while queue: + current_cost, current_node, path, depth = heapq.heappop(queue) + + if current_node == target_id: + return path + + if depth >= max_depth: + continue + + if current_node in visited: + continue + + visited.add(current_node) + + for neighbor in self.get_neighbors_with_pheromones(current_node): + next_node = neighbor["node"] + + if next_node in visited: + continue + + # STAN Core Formula: cost_effective = w / (1 + tau * alpha) + # w = 1.0 base weight. tau = pheromone. alpha = influence parameter (0.7 default) + tau = neighbor["pheromone"] + edge_cost = 1.0 / (1.0 + (tau * influence)) + + tentative_g = current_cost + edge_cost + + if tentative_g < g_scores.get(next_node, float('inf')): + g_scores[next_node] = tentative_g + + # Store path as lightweight tuples to avoid O(n^2) scaling + # and correctly map back into memory. + step = { + "from": current_node, + "to": next_node, + "predicate": neighbor["predicate"], + "direction": neighbor["direction"], + "pheromone": tau, + "cost": edge_cost + } + + heapq.heappush(queue, (tentative_g, next_node, path + [step], depth + 1)) + + return None + # ── Stats ───────────────────────────────────────────────────────────── def stats(self): diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index b447249141..e0ecf5e65f 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ MemPalace MCP Server — read/write palace access for Claude Code -================================================================ +========================================================= Install: claude mcp add mempalace -- python -m mempalace.mcp_server [--palace /path/to/palace] Tools (read): @@ -33,6 +33,12 @@ from .knowledge_graph import KnowledgeGraph + + +_kg = KnowledgeGraph() +_pheromone_decay_tick = 0 + + logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr) logger = logging.getLogger("mempalace_mcp") @@ -85,7 +91,7 @@ def _no_palace(): } -# ==================== READ TOOLS ==================== +# ============= READ TOOLS ==================== def tool_status(): @@ -272,7 +278,7 @@ def tool_graph_stats(): return graph_stats(col=col) -# ==================== WRITE TOOLS ==================== +# ============= WRITE TOOLS ==================== def tool_add_drawer( @@ -331,7 +337,7 @@ def tool_delete_drawer(drawer_id: str): return {"success": False, "error": str(e)} -# ==================== KNOWLEDGE GRAPH ==================== +# ============= KNOWLEDGE GRAPH ==================== def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"): @@ -371,7 +377,23 @@ def tool_kg_stats(): return _kg.stats() -# ==================== AGENT DIARY ==================== +def tool_kg_stigmergic_astar(start_entity: str, target_entity: str, influence: float = 0.7, max_depth: int = 5): + """Find optimal learned path leveraging stigmergic pheromone edge weights.""" + return {"path": _kg.stigmergic_astar(start_entity, target_entity, influence=influence, max_depth=max_depth)} + +def tool_kg_deposit_pheromone(subject: str, predicate: str, object: str, amount: float = 1.0): + """Deposit pheromone on a successful path to influence future A* queries.""" + _kg.deposit_pheromone(subject, predicate, object, amount=amount) + + # Tick decay + global _pheromone_decay_tick + _pheromone_decay_tick += 1 + if _pheromone_decay_tick % 50 == 0: + _kg.evaporate_pheromones(decay_rate=0.1) + + return {"success": True, "fact": f"{subject} → {predicate} → {object}", "amount": amount} + +# ============= AGENT DIARY ==================== def tool_diary_write(agent_name: str, entry: str, topic: str = "general"): @@ -465,7 +487,7 @@ def tool_diary_read(agent_name: str, last_n: int = 10): return {"error": str(e)} -# ==================== MCP PROTOCOL ==================== +# ============= MCP PROTOCOL ==================== TOOLS = { "mempalace_status": { @@ -714,6 +736,34 @@ def tool_diary_read(agent_name: str, last_n: int = 10): }, "handler": tool_diary_read, }, + "mempalace_kg_stigmergic_astar": { + "description": "Find an optimal path between two concepts using STAN (Stigmergic A* Navigation). Uses pheromone trails accumulated from past traversals to find fast paths.", + "input_schema": { + "type": "object", + "properties": { + "start_entity": {"type": "string", "description": "Starting concept or node"}, + "target_entity": {"type": "string", "description": "Target concept or node"}, + "influence": {"type": "number", "description": "How much pheromones discount cost (default 0.7)"}, + "max_depth": {"type": "integer", "description": "Maximum depth to search (default 5)"} + }, + "required": ["start_entity", "target_entity"], + }, + "handler": tool_kg_stigmergic_astar, + }, + "mempalace_kg_deposit_pheromone": { + "description": "Deposit pheromone on a successful edge to reinforce it for future A* pathfinding queries.", + "input_schema": { + "type": "object", + "properties": { + "subject": {"type": "string", "description": "Start entity of the edge"}, + "predicate": {"type": "string", "description": "Relationship type"}, + "object": {"type": "string", "description": "End entity of the edge"}, + "amount": {"type": "number", "description": "Amount to deposit (default 1.0)"} + }, + "required": ["subject", "predicate", "object"], + }, + "handler": tool_kg_deposit_pheromone, + }, } @@ -765,6 +815,9 @@ def handle_request(request): tool_args[key] = int(value) elif declared_type == "number" and not isinstance(value, (int, float)): tool_args[key] = float(value) + + # Apply specific tool routing if parameters were named dynamically + # (Though **tool_args unpacks them naturally). try: result = TOOLS[tool_name]["handler"](**tool_args) return { diff --git a/mempalace/version.py b/mempalace/version.py index 6bf70fc5d5..b2a0d20d65 100644 --- a/mempalace/version.py +++ b/mempalace/version.py @@ -1,3 +1,3 @@ """Single source of truth for the MemPalace package version.""" -__version__ = "3.0.11" +__version__ = "3.0.12" diff --git a/pyproject.toml b/pyproject.toml index 00e5c93cb0..c5ee2c5727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mempalace" -version = "3.0.11" +version = "3.0.12" description = "Give your AI a memory — mine projects and conversations into a searchable palace. No API key required." readme = "README.md" requires-python = ">=3.9"