Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
150 changes: 142 additions & 8 deletions mempalace/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
}
)
Expand All @@ -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,
}
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -303,16 +315,138 @@ 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,
}
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):
Expand Down
65 changes: 59 additions & 6 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -85,7 +91,7 @@ def _no_palace():
}


# ==================== READ TOOLS ====================
# ============= READ TOOLS ====================


def tool_status():
Expand Down Expand Up @@ -272,7 +278,7 @@ def tool_graph_stats():
return graph_stats(col=col)


# ==================== WRITE TOOLS ====================
# ============= WRITE TOOLS ====================


def tool_add_drawer(
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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,
},
}


Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion mempalace/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the MemPalace package version."""

__version__ = "3.0.11"
__version__ = "3.0.12"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down