* feat: MCP reliability — inode detection, WAL rotation, metadata cache, search limits Infrastructure hardening for the MCP server: - Detect palace DB replacement via inode tracking (repair command support) - WAL rotation to prevent unbounded WAL growth - _fetch_all_metadata() + _get_cached_metadata() with 60s TTL for taxonomy/status - _MAX_RESULTS cap (100) with limit clamping [1, _MAX_RESULTS] - max_distance parameter for similarity threshold in search - Handle all notifications/* methods, null arguments, method=None - Remove duplicate _client_cache = None declarations - searcher.py max_distance parameter passthrough Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: new MCP tools (get/list/update drawer, hook settings, memories filed), export, normalize New MCP tools: - mempalace_get_drawer: fetch single drawer by ID with full content - mempalace_list_drawers: paginated listing with wing/room filter - mempalace_update_drawer: update content/wing/room on existing drawers - mempalace_hook_settings: get/set hook behavior (silent_save, desktop_toast) - mempalace_memories_filed_away: check latest checkpoint status Also includes: - exporter.py: export palace as browsable markdown files - normalize.py: tool_use/tool_result capture for richer transcript mining - layers.py: updated for new tool integration - config.py: hook settings properties (hook_silent_save, hook_desktop_toast) Depends on PR 3 (reliability) for _MAX_RESULTS, _metadata_cache, WAL logging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: normalize.py handles string messages and Read offset type mismatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: params null guard, L2→cosine docs, empty tool_use_map key guard - Handle explicit null in MCP params (request.get("params") or {}) - Fix search tool description: L2 → cosine distance (collection uses hnsw:space=cosine) - Guard against empty string key in tool_use_map from malformed JSONL entries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: rename ambiguous var 'l' to 'line' (E741 lint) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review findings (5 issues) 1. min_similarity backwards-compat: convert similarity to distance scale (1.0 - similarity) instead of passing raw value as max_distance 2. Restore structured error reporting (error + partial fields) in tool_status, tool_list_wings, tool_list_rooms, tool_get_taxonomy — reverts silent except:pass that dropped #647 security hardening 3. inode cache: remove falsy-zero short-circuit so missing DB file triggers reconnect instead of reusing stale client 4. _fetch_all_metadata: check for empty batch before extending/advancing offset to prevent infinite loop on concurrent deletion 5. KG initialization: only override path when --palace is explicit; default runs use KnowledgeGraph's built-in default path Co-authored-by: jphein <jphein@users.noreply.github.com> --------- Co-authored-by: jp <jp@jphein.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: jphein <jphein@users.noreply.github.com>
This commit is contained in:
+436
-109
@@ -23,12 +23,13 @@ import sys
|
||||
import json
|
||||
import logging
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .config import MempalaceConfig, sanitize_name, sanitize_content
|
||||
from .version import __version__
|
||||
from .palace import get_collection as _get_collection_from_palace
|
||||
import chromadb
|
||||
from .query_sanitizer import sanitize_query
|
||||
from .searcher import search_memories
|
||||
from .palace_graph import traverse, find_tunnels, graph_stats
|
||||
@@ -58,13 +59,17 @@ if _args.palace:
|
||||
os.environ["MEMPALACE_PALACE_PATH"] = os.path.abspath(_args.palace)
|
||||
|
||||
_config = MempalaceConfig()
|
||||
# Only override KG path when --palace is explicitly provided; otherwise use
|
||||
# KnowledgeGraph's default (~/.mempalace/knowledge_graph.sqlite3).
|
||||
if _args.palace:
|
||||
_kg = KnowledgeGraph(db_path=os.path.join(_config.palace_path, "knowledge_graph.sqlite3"))
|
||||
else:
|
||||
_kg = KnowledgeGraph()
|
||||
|
||||
|
||||
_client_cache = None
|
||||
_collection_cache = None
|
||||
_palace_db_inode = 0 # inode of chroma.sqlite3 at cache time
|
||||
|
||||
|
||||
# ==================== WRITE-AHEAD LOG ====================
|
||||
@@ -115,16 +120,43 @@ def _wal_log(operation: str, params: dict, result: dict = None):
|
||||
logger.error(f"WAL write failed: {e}")
|
||||
|
||||
|
||||
def _get_collection(create=False):
|
||||
"""Return the configured collection, caching the wrapper between calls."""
|
||||
global _collection_cache
|
||||
def _get_client():
|
||||
"""Return a ChromaDB PersistentClient, reconnecting if the database changed on disk.
|
||||
|
||||
Detects palace rebuilds (repair/nuke/purge) by checking the inode of
|
||||
chroma.sqlite3. A full rebuild replaces the file, changing the inode.
|
||||
"""
|
||||
global _client_cache, _collection_cache, _palace_db_inode, _metadata_cache, _metadata_cache_time
|
||||
db_path = os.path.join(_config.palace_path, "chroma.sqlite3")
|
||||
try:
|
||||
if create or _collection_cache is None:
|
||||
_collection_cache = _get_collection_from_palace(
|
||||
_config.palace_path,
|
||||
collection_name=_config.collection_name,
|
||||
create=create,
|
||||
current_inode = os.stat(db_path).st_ino
|
||||
except OSError:
|
||||
current_inode = 0
|
||||
|
||||
if _client_cache is None or current_inode != _palace_db_inode:
|
||||
_client_cache = chromadb.PersistentClient(path=_config.palace_path)
|
||||
_collection_cache = None
|
||||
_metadata_cache = None
|
||||
_metadata_cache_time = 0
|
||||
_palace_db_inode = current_inode
|
||||
return _client_cache
|
||||
|
||||
|
||||
def _get_collection(create=False):
|
||||
"""Return the ChromaDB collection, caching the client between calls."""
|
||||
global _collection_cache, _metadata_cache, _metadata_cache_time
|
||||
try:
|
||||
client = _get_client()
|
||||
if create:
|
||||
_collection_cache = client.get_or_create_collection(
|
||||
_config.collection_name, metadata={"hnsw:space": "cosine"}
|
||||
)
|
||||
_metadata_cache = None
|
||||
_metadata_cache_time = 0
|
||||
elif _collection_cache is None:
|
||||
_collection_cache = client.get_collection(_config.collection_name)
|
||||
_metadata_cache = None
|
||||
_metadata_cache_time = 0
|
||||
return _collection_cache
|
||||
except Exception:
|
||||
return None
|
||||
@@ -137,6 +169,49 @@ def _no_palace():
|
||||
}
|
||||
|
||||
|
||||
# ==================== HELPERS ====================
|
||||
|
||||
|
||||
def _fetch_all_metadata(col, where=None):
|
||||
"""Paginate col.get() to avoid the 10K silent truncation limit."""
|
||||
total = col.count()
|
||||
all_meta = []
|
||||
offset = 0
|
||||
while offset < total:
|
||||
kwargs = {"include": ["metadatas"], "limit": 1000, "offset": offset}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = col.get(**kwargs)
|
||||
if not batch["metadatas"]:
|
||||
break
|
||||
all_meta.extend(batch["metadatas"])
|
||||
offset += len(batch["metadatas"])
|
||||
return all_meta
|
||||
|
||||
|
||||
_metadata_cache = None
|
||||
_metadata_cache_time = 0
|
||||
_METADATA_CACHE_TTL = 5.0 # seconds
|
||||
_MAX_RESULTS = 100 # upper bound for search/list limit params
|
||||
|
||||
|
||||
def _get_cached_metadata(col, where=None):
|
||||
"""Return cached metadata if fresh, else fetch and cache."""
|
||||
global _metadata_cache, _metadata_cache_time
|
||||
now = time.time()
|
||||
if (
|
||||
where is None
|
||||
and _metadata_cache is not None
|
||||
and (now - _metadata_cache_time) < _METADATA_CACHE_TTL
|
||||
):
|
||||
return _metadata_cache
|
||||
result = _fetch_all_metadata(col, where=where)
|
||||
if where is None:
|
||||
_metadata_cache = result
|
||||
_metadata_cache_time = now
|
||||
return result
|
||||
|
||||
|
||||
# ==================== READ TOOLS ====================
|
||||
|
||||
|
||||
@@ -147,24 +222,6 @@ def tool_status():
|
||||
count = col.count()
|
||||
wings = {}
|
||||
rooms = {}
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
error_info = None
|
||||
while True:
|
||||
try:
|
||||
batch = col.get(include=["metadatas"], limit=batch_size, offset=offset)
|
||||
rows = batch["metadatas"]
|
||||
for m in rows:
|
||||
w = m.get("wing", "unknown")
|
||||
r = m.get("room", "unknown")
|
||||
wings[w] = wings.get(w, 0) + 1
|
||||
rooms[r] = rooms.get(r, 0) + 1
|
||||
offset += len(rows)
|
||||
if len(rows) < batch_size:
|
||||
break
|
||||
except Exception as e:
|
||||
error_info = f"Partial result, failed at offset {offset}: {str(e)}"
|
||||
break
|
||||
result = {
|
||||
"total_drawers": count,
|
||||
"wings": wings,
|
||||
@@ -173,8 +230,16 @@ def tool_status():
|
||||
"protocol": PALACE_PROTOCOL,
|
||||
"aaak_dialect": AAAK_SPEC,
|
||||
}
|
||||
if error_info:
|
||||
result["error"] = error_info
|
||||
try:
|
||||
all_meta = _get_cached_metadata(col)
|
||||
for m in all_meta:
|
||||
w = m.get("wing", "unknown")
|
||||
r = m.get("room", "unknown")
|
||||
wings[w] = wings.get(w, 0) + 1
|
||||
rooms[r] = rooms.get(r, 0) + 1
|
||||
except Exception as e:
|
||||
logger.exception("tool_status metadata fetch failed")
|
||||
result["error"] = str(e)
|
||||
result["partial"] = True
|
||||
return result
|
||||
|
||||
@@ -217,29 +282,17 @@ def tool_list_wings():
|
||||
if not col:
|
||||
return _no_palace()
|
||||
wings = {}
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
result = {"wings": wings}
|
||||
try:
|
||||
col.count() # verify collection is accessible
|
||||
all_meta = _get_cached_metadata(col)
|
||||
for m in all_meta:
|
||||
w = m.get("wing", "unknown")
|
||||
wings[w] = wings.get(w, 0) + 1
|
||||
except Exception as e:
|
||||
return {"wings": {}, "error": str(e)}
|
||||
while True:
|
||||
try:
|
||||
batch = col.get(include=["metadatas"], limit=batch_size, offset=offset)
|
||||
rows = batch["metadatas"]
|
||||
for m in rows:
|
||||
w = m.get("wing", "unknown")
|
||||
wings[w] = wings.get(w, 0) + 1
|
||||
offset += len(rows)
|
||||
if len(rows) < batch_size:
|
||||
break
|
||||
except Exception as e:
|
||||
return {
|
||||
"wings": wings,
|
||||
"error": f"Partial result, failed at offset {offset}: {str(e)}",
|
||||
"partial": True,
|
||||
}
|
||||
return {"wings": wings}
|
||||
logger.exception("tool_list_wings metadata fetch failed")
|
||||
result["error"] = str(e)
|
||||
result["partial"] = True
|
||||
return result
|
||||
|
||||
|
||||
def tool_list_rooms(wing: str = None):
|
||||
@@ -247,34 +300,18 @@ def tool_list_rooms(wing: str = None):
|
||||
if not col:
|
||||
return _no_palace()
|
||||
rooms = {}
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
where = {"wing": wing} if wing else None
|
||||
result = {"wing": wing or "all", "rooms": rooms}
|
||||
try:
|
||||
col.count() # verify collection is accessible
|
||||
where = {"wing": wing} if wing else None
|
||||
all_meta = _fetch_all_metadata(col, where=where)
|
||||
for m in all_meta:
|
||||
r = m.get("room", "unknown")
|
||||
rooms[r] = rooms.get(r, 0) + 1
|
||||
except Exception as e:
|
||||
return {"wing": wing or "all", "rooms": {}, "error": str(e)}
|
||||
while True:
|
||||
try:
|
||||
kwargs = {"include": ["metadatas"], "limit": batch_size, "offset": offset}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
batch = col.get(**kwargs)
|
||||
rows = batch["metadatas"]
|
||||
for m in rows:
|
||||
r = m.get("room", "unknown")
|
||||
rooms[r] = rooms.get(r, 0) + 1
|
||||
offset += len(rows)
|
||||
if len(rows) < batch_size:
|
||||
break
|
||||
except Exception as e:
|
||||
return {
|
||||
"wing": wing or "all",
|
||||
"rooms": rooms,
|
||||
"error": f"Partial result, failed at offset {offset}: {str(e)}",
|
||||
"partial": True,
|
||||
}
|
||||
return {"wing": wing or "all", "rooms": rooms}
|
||||
logger.exception("tool_list_rooms metadata fetch failed")
|
||||
result["error"] = str(e)
|
||||
result["partial"] = True
|
||||
return result
|
||||
|
||||
|
||||
def tool_get_taxonomy():
|
||||
@@ -282,38 +319,31 @@ def tool_get_taxonomy():
|
||||
if not col:
|
||||
return _no_palace()
|
||||
taxonomy = {}
|
||||
batch_size = 5000
|
||||
offset = 0
|
||||
result = {"taxonomy": taxonomy}
|
||||
try:
|
||||
col.count() # verify collection is accessible
|
||||
all_meta = _get_cached_metadata(col)
|
||||
for m in all_meta:
|
||||
w = m.get("wing", "unknown")
|
||||
r = m.get("room", "unknown")
|
||||
if w not in taxonomy:
|
||||
taxonomy[w] = {}
|
||||
taxonomy[w][r] = taxonomy[w].get(r, 0) + 1
|
||||
except Exception as e:
|
||||
return {"taxonomy": {}, "error": str(e)}
|
||||
while True:
|
||||
try:
|
||||
batch = col.get(include=["metadatas"], limit=batch_size, offset=offset)
|
||||
rows = batch["metadatas"]
|
||||
for m in rows:
|
||||
w = m.get("wing", "unknown")
|
||||
r = m.get("room", "unknown")
|
||||
if w not in taxonomy:
|
||||
taxonomy[w] = {}
|
||||
taxonomy[w][r] = taxonomy[w].get(r, 0) + 1
|
||||
offset += len(rows)
|
||||
if len(rows) < batch_size:
|
||||
break
|
||||
except Exception as e:
|
||||
return {
|
||||
"taxonomy": taxonomy,
|
||||
"error": f"Partial result, failed at offset {offset}: {str(e)}",
|
||||
"partial": True,
|
||||
}
|
||||
return {"taxonomy": taxonomy}
|
||||
logger.exception("tool_get_taxonomy metadata fetch failed")
|
||||
result["error"] = str(e)
|
||||
result["partial"] = True
|
||||
return result
|
||||
|
||||
|
||||
def tool_search(
|
||||
query: str, limit: int = 5, wing: str = None, room: str = None, context: str = None
|
||||
query: str, limit: int = 5, wing: str = None, room: str = None,
|
||||
max_distance: float = 1.5, min_similarity: float = None, context: str = None,
|
||||
):
|
||||
limit = max(1, min(limit, 50))
|
||||
limit = max(1, min(limit, _MAX_RESULTS))
|
||||
# Backwards compat: accept old name
|
||||
# Backwards compat: convert old similarity scale (higher=stricter) to
|
||||
# distance scale (lower=stricter). Similarity 0.8 → distance 0.2.
|
||||
dist = (1.0 - min_similarity) if min_similarity is not None else max_distance
|
||||
# Mitigate system prompt contamination (Issue #333)
|
||||
sanitized = sanitize_query(query)
|
||||
result = search_memories(
|
||||
@@ -322,6 +352,7 @@ def tool_search(
|
||||
wing=wing,
|
||||
room=room,
|
||||
n_results=limit,
|
||||
max_distance=dist,
|
||||
)
|
||||
# Attach sanitizer metadata for transparency
|
||||
if sanitized["was_sanitized"]:
|
||||
@@ -410,6 +441,7 @@ def tool_add_drawer(
|
||||
wing: str, room: str, content: str, source_file: str = None, added_by: str = "mcp"
|
||||
):
|
||||
"""File verbatim content into a wing/room. Checks for duplicates first."""
|
||||
global _metadata_cache
|
||||
try:
|
||||
wing = sanitize_name(wing, "wing")
|
||||
room = sanitize_name(room, "room")
|
||||
@@ -458,6 +490,7 @@ def tool_add_drawer(
|
||||
}
|
||||
],
|
||||
)
|
||||
_metadata_cache = None
|
||||
logger.info(f"Filed drawer: {drawer_id} → {wing}/{room}")
|
||||
return {"success": True, "drawer_id": drawer_id, "wing": wing, "room": room}
|
||||
except Exception as e:
|
||||
@@ -466,6 +499,7 @@ def tool_add_drawer(
|
||||
|
||||
def tool_delete_drawer(drawer_id: str):
|
||||
"""Delete a single drawer by ID."""
|
||||
global _metadata_cache
|
||||
col = _get_collection()
|
||||
if not col:
|
||||
return _no_palace()
|
||||
@@ -487,12 +521,150 @@ def tool_delete_drawer(drawer_id: str):
|
||||
|
||||
try:
|
||||
col.delete(ids=[drawer_id])
|
||||
_metadata_cache = None
|
||||
logger.info(f"Deleted drawer: {drawer_id}")
|
||||
return {"success": True, "drawer_id": drawer_id}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
def tool_get_drawer(drawer_id: str):
|
||||
"""Fetch a single drawer by ID. Returns full content and metadata."""
|
||||
col = _get_collection()
|
||||
if not col:
|
||||
return _no_palace()
|
||||
try:
|
||||
result = col.get(ids=[drawer_id], include=["documents", "metadatas"])
|
||||
if not result["ids"]:
|
||||
return {"error": f"Drawer not found: {drawer_id}"}
|
||||
meta = result["metadatas"][0]
|
||||
doc = result["documents"][0]
|
||||
return {
|
||||
"drawer_id": drawer_id,
|
||||
"content": doc,
|
||||
"wing": meta.get("wing", ""),
|
||||
"room": meta.get("room", ""),
|
||||
"metadata": meta,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def tool_list_drawers(wing: str = None, room: str = None, limit: int = 20, offset: int = 0):
|
||||
"""List drawers with pagination. Optional wing/room filter."""
|
||||
limit = max(1, min(limit, _MAX_RESULTS))
|
||||
offset = max(0, offset)
|
||||
col = _get_collection()
|
||||
if not col:
|
||||
return _no_palace()
|
||||
try:
|
||||
where = None
|
||||
conditions = []
|
||||
if wing:
|
||||
conditions.append({"wing": wing})
|
||||
if room:
|
||||
conditions.append({"room": room})
|
||||
if len(conditions) == 1:
|
||||
where = conditions[0]
|
||||
elif len(conditions) > 1:
|
||||
where = {"$and": conditions}
|
||||
|
||||
kwargs = {"include": ["documents", "metadatas"], "limit": limit, "offset": offset}
|
||||
if where:
|
||||
kwargs["where"] = where
|
||||
result = col.get(**kwargs)
|
||||
|
||||
drawers = []
|
||||
for i, did in enumerate(result["ids"]):
|
||||
meta = result["metadatas"][i]
|
||||
doc = result["documents"][i]
|
||||
drawers.append(
|
||||
{
|
||||
"drawer_id": did,
|
||||
"wing": meta.get("wing", ""),
|
||||
"room": meta.get("room", ""),
|
||||
"content_preview": doc[:200] + "..." if len(doc) > 200 else doc,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"drawers": drawers,
|
||||
"count": len(drawers),
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def tool_update_drawer(drawer_id: str, content: str = None, wing: str = None, room: str = None):
|
||||
"""Update an existing drawer's content and/or metadata."""
|
||||
global _metadata_cache
|
||||
|
||||
if content is None and wing is None and room is None:
|
||||
return {"success": True, "drawer_id": drawer_id, "noop": True}
|
||||
|
||||
col = _get_collection()
|
||||
if not col:
|
||||
return _no_palace()
|
||||
try:
|
||||
existing = col.get(ids=[drawer_id], include=["documents", "metadatas"])
|
||||
if not existing["ids"]:
|
||||
return {"success": False, "error": f"Drawer not found: {drawer_id}"}
|
||||
|
||||
old_meta = existing["metadatas"][0]
|
||||
old_doc = existing["documents"][0]
|
||||
|
||||
new_doc = old_doc
|
||||
if content is not None:
|
||||
try:
|
||||
new_doc = sanitize_content(content)
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
new_meta = dict(old_meta)
|
||||
if wing is not None:
|
||||
try:
|
||||
new_meta["wing"] = sanitize_name(wing, "wing")
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
if room is not None:
|
||||
try:
|
||||
new_meta["room"] = sanitize_name(room, "room")
|
||||
except ValueError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
_wal_log(
|
||||
"update_drawer",
|
||||
{
|
||||
"drawer_id": drawer_id,
|
||||
"old_wing": old_meta.get("wing", ""),
|
||||
"old_room": old_meta.get("room", ""),
|
||||
"new_wing": new_meta.get("wing", ""),
|
||||
"new_room": new_meta.get("room", ""),
|
||||
"content_changed": content is not None,
|
||||
"content_preview": new_doc[:200] if content is not None else None,
|
||||
},
|
||||
)
|
||||
|
||||
update_kwargs = {"ids": [drawer_id]}
|
||||
if content is not None:
|
||||
update_kwargs["documents"] = [new_doc]
|
||||
update_kwargs["metadatas"] = [new_meta]
|
||||
col.update(**update_kwargs)
|
||||
|
||||
_metadata_cache = None
|
||||
|
||||
logger.info(f"Updated drawer: {drawer_id}")
|
||||
return {
|
||||
"success": True,
|
||||
"drawer_id": drawer_id,
|
||||
"wing": new_meta.get("wing", ""),
|
||||
"room": new_meta.get("room", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# ==================== KNOWLEDGE GRAPH ====================
|
||||
|
||||
|
||||
@@ -691,6 +863,71 @@ def tool_diary_read(agent_name: str, last_n: int = 10):
|
||||
return {"error": "Failed to read diary entries"}
|
||||
|
||||
|
||||
def tool_hook_settings(silent_save: bool = None, desktop_toast: bool = None):
|
||||
"""
|
||||
Get or set hook behavior settings.
|
||||
|
||||
- silent_save: True = stop hook saves directly (no MCP clutter),
|
||||
False = legacy blocking MCP calls. Default: True.
|
||||
- desktop_toast: True = show notify-send desktop toast on save,
|
||||
False = terminal-only notification. Default: False.
|
||||
|
||||
Call with no arguments to see current settings.
|
||||
"""
|
||||
from .config import MempalaceConfig
|
||||
|
||||
try:
|
||||
config = MempalaceConfig()
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
changed = []
|
||||
if silent_save is not None:
|
||||
config.set_hook_setting("silent_save", silent_save)
|
||||
changed.append(f"silent_save → {silent_save}")
|
||||
if desktop_toast is not None:
|
||||
config.set_hook_setting("desktop_toast", desktop_toast)
|
||||
changed.append(f"desktop_toast → {desktop_toast}")
|
||||
|
||||
# Re-read to return current state
|
||||
try:
|
||||
config = MempalaceConfig()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"settings": {
|
||||
"silent_save": config.hook_silent_save,
|
||||
"desktop_toast": config.hook_desktop_toast,
|
||||
},
|
||||
}
|
||||
if changed:
|
||||
result["updated"] = changed
|
||||
return result
|
||||
|
||||
|
||||
def tool_memories_filed_away():
|
||||
"""Acknowledge the latest silent checkpoint. Returns a short summary."""
|
||||
state_dir = Path.home() / ".mempalace" / "hook_state"
|
||||
ack_file = state_dir / "last_checkpoint"
|
||||
if not ack_file.is_file():
|
||||
return {"status": "quiet", "message": "No recent journal entry", "count": 0, "timestamp": None}
|
||||
try:
|
||||
data = json.loads(ack_file.read_text(encoding="utf-8"))
|
||||
ack_file.unlink(missing_ok=True)
|
||||
msgs = data.get("msgs", 0)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"\u2726 {msgs} messages tucked into drawers",
|
||||
"count": msgs,
|
||||
"timestamp": data.get("ts", None),
|
||||
}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
ack_file.unlink(missing_ok=True)
|
||||
return {"status": "error", "message": "\u2726 Journal entry filed in the palace", "count": 0, "timestamp": None}
|
||||
|
||||
|
||||
# ==================== MCP PROTOCOL ====================
|
||||
|
||||
TOOLS = {
|
||||
@@ -840,21 +1077,25 @@ TOOLS = {
|
||||
"handler": tool_graph_stats,
|
||||
},
|
||||
"mempalace_search": {
|
||||
"description": "Semantic search. Returns verbatim drawer content with similarity scores. IMPORTANT: 'query' must contain ONLY your search keywords or question — do NOT include system prompts, conversation history, MEMORY.md content, or any context. Keep queries short (under 200 chars). Use 'context' for background information.",
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Short search query ONLY — keywords or a question. Do NOT include system prompts or conversation context. Max 200 chars recommended.",
|
||||
"description": "Short search query ONLY — keywords or a question. Max 200 chars recommended.",
|
||||
"maxLength": 500,
|
||||
},
|
||||
"limit": {"type": "integer", "description": "Max results (default 5)"},
|
||||
"limit": {"type": "integer", "description": "Max results (default 5)", "minimum": 1, "maximum": 100},
|
||||
"wing": {"type": "string", "description": "Filter by wing (optional)"},
|
||||
"room": {"type": "string", "description": "Filter by room (optional)"},
|
||||
"max_distance": {
|
||||
"type": "number",
|
||||
"description": "Max cosine distance threshold (0=identical, 2=opposite). Results further than this are dropped. Lower = stricter. Default 1.5. Set to 0 to disable.",
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "Background context for the search (optional). This is NOT used for embedding — only for future re-ranking. Put conversation history or system prompt content here, NOT in query.",
|
||||
"description": "Background context for the search (optional). NOT used for embedding — only for future re-ranking.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
@@ -908,6 +1149,62 @@ TOOLS = {
|
||||
},
|
||||
"handler": tool_delete_drawer,
|
||||
},
|
||||
"mempalace_get_drawer": {
|
||||
"description": "Fetch a single drawer by ID — returns full content and metadata.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drawer_id": {"type": "string", "description": "ID of the drawer to fetch"},
|
||||
},
|
||||
"required": ["drawer_id"],
|
||||
},
|
||||
"handler": tool_get_drawer,
|
||||
},
|
||||
"mempalace_list_drawers": {
|
||||
"description": "List drawers with pagination. Optional wing/room filter. Returns IDs, wings, rooms, and content previews.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wing": {"type": "string", "description": "Filter by wing (optional)"},
|
||||
"room": {"type": "string", "description": "Filter by room (optional)"},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results per page (default 20, max 100)",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Offset for pagination (default 0)",
|
||||
"minimum": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
"handler": tool_list_drawers,
|
||||
},
|
||||
"mempalace_update_drawer": {
|
||||
"description": "Update an existing drawer's content and/or metadata (wing, room). Fetches existing drawer first; returns error if not found.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"drawer_id": {"type": "string", "description": "ID of the drawer to update"},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New content (optional — omit to keep existing)",
|
||||
},
|
||||
"wing": {
|
||||
"type": "string",
|
||||
"description": "New wing (optional — omit to keep existing)",
|
||||
},
|
||||
"room": {
|
||||
"type": "string",
|
||||
"description": "New room (optional — omit to keep existing)",
|
||||
},
|
||||
},
|
||||
"required": ["drawer_id"],
|
||||
},
|
||||
"handler": tool_update_drawer,
|
||||
},
|
||||
"mempalace_diary_write": {
|
||||
"description": "Write to your personal agent diary in AAAK format. Your observations, thoughts, what you worked on, what matters. Each agent has their own diary with full history. Write in AAAK for compression — e.g. 'SESSION:2026-04-04|built.palace.graph+diary.tools|ALC.req:agent.diaries.in.aaak|★★★'. Use entity codes from the AAAK spec.",
|
||||
"input_schema": {
|
||||
@@ -948,6 +1245,32 @@ TOOLS = {
|
||||
},
|
||||
"handler": tool_diary_read,
|
||||
},
|
||||
"mempalace_hook_settings": {
|
||||
"description": (
|
||||
"Get or set hook behavior. silent_save: True = save directly "
|
||||
"(no MCP clutter), False = legacy blocking. desktop_toast: "
|
||||
"True = show desktop notification. Call with no args to view."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"silent_save": {
|
||||
"type": "boolean",
|
||||
"description": "True = silent direct save, False = blocking MCP calls",
|
||||
},
|
||||
"desktop_toast": {
|
||||
"type": "boolean",
|
||||
"description": "True = show desktop toast via notify-send",
|
||||
},
|
||||
},
|
||||
},
|
||||
"handler": tool_hook_settings,
|
||||
},
|
||||
"mempalace_memories_filed_away": {
|
||||
"description": "Check if a recent palace checkpoint was saved. Returns message count and timestamp.",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"handler": tool_memories_filed_away,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -960,8 +1283,8 @@ SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
|
||||
|
||||
def handle_request(request):
|
||||
method = request.get("method", "")
|
||||
params = request.get("params", {})
|
||||
method = request.get("method") or ""
|
||||
params = request.get("params") or {}
|
||||
req_id = request.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
@@ -982,7 +1305,8 @@ def handle_request(request):
|
||||
}
|
||||
elif method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
elif method == "notifications/initialized":
|
||||
elif method.startswith("notifications/"):
|
||||
# Notifications (no id) never get a response per JSON-RPC spec
|
||||
return None
|
||||
elif method == "tools/list":
|
||||
return {
|
||||
@@ -1040,6 +1364,9 @@ def handle_request(request):
|
||||
"error": {"code": -32000, "message": "Internal tool error"},
|
||||
}
|
||||
|
||||
# Notifications (missing id) must never get a response
|
||||
if req_id is None:
|
||||
return None
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
|
||||
Reference in New Issue
Block a user