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
9 changes: 7 additions & 2 deletions mempalace/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,14 @@ def tool_get_taxonomy():
return {"taxonomy": taxonomy}


def tool_search(query: str, limit: int = 5, wing: str = None, room: str = None):
def tool_search(query: str, limit: int = 5, wing: str = None, room: str = None, verify: bool = False):
return search_memories(
query,
palace_path=_config.palace_path,
wing=wing,
room=room,
n_results=limit,
verify=verify,
)


Expand Down Expand Up @@ -586,14 +587,18 @@ def tool_diary_read(agent_name: str, last_n: int = 10):
"handler": tool_graph_stats,
},
"mempalace_search": {
"description": "Semantic search. Returns verbatim drawer content with similarity scores.",
"description": "Semantic search. Returns verbatim drawer content with similarity scores. Set verify=true to check results for contradictions using Tardygrada (requires tardygrada binary on PATH).",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search for"},
"limit": {"type": "integer", "description": "Max results (default 5)"},
"wing": {"type": "string", "description": "Filter by wing (optional)"},
"room": {"type": "string", "description": "Filter by room (optional)"},
"verify": {
"type": "boolean",
"description": "Run tardygrada verify-doc on results to detect contradictions (default: false, requires tardygrada binary)",
},
},
"required": ["query"],
},
Expand Down
91 changes: 89 additions & 2 deletions mempalace/searcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"""

import logging
import os
import re
import subprocess
import tempfile
from pathlib import Path

import chromadb
Expand All @@ -18,6 +22,81 @@ class SearchError(Exception):
"""Raised when search cannot proceed (e.g. no palace found)."""


def _verify_with_tardygrada(hits: list) -> dict:
"""
Run tardygrada verify-doc on search results to detect contradictions.
Returns {"contradictions": [...]} or {"contradictions": None, "verify_warning": "..."}.
"""
if not hits:
return {"contradictions": []}

tmp_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".md", delete=False, prefix="mempalace_verify_"
) as f:
for i, hit in enumerate(hits, 1):
f.write(
f"## [{i}] {hit['wing']} / {hit['room']} (similarity: {hit['similarity']})\n"
)
f.write(hit["text"].strip() + "\n\n")
tmp_path = f.name

result = subprocess.run(
["tardygrada", "verify-doc", tmp_path],
capture_output=True,
text=True,
timeout=10,
shell=False,
)
if result.returncode != 0:
return {
"contradictions": None,
"verify_warning": f"tardygrada exited with code {result.returncode}: {result.stderr[:200]}",
}
return {"contradictions": _parse_conflicts(result.stdout)}
except FileNotFoundError:
return {
"contradictions": None,
"verify_warning": "tardygrada binary not found on PATH — install from https://github.com/fabio-rovai/tardygrada",
}
except subprocess.TimeoutExpired:
return {
"contradictions": None,
"verify_warning": "tardygrada verify-doc timeout after 10s",
}
except OSError as exc:
return {
"contradictions": None,
"verify_warning": f"tardygrada execution error: {exc}",
}
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)


def _parse_conflicts(stdout: str) -> list:
"""Parse tardygrada verify-doc output into structured conflict objects."""
conflicts = []
pattern = re.compile(
r'\[CONFLICT\] Lines? (\d+) vs (\d+):\s*\n'
r'\s*"([^"]+)"\s*\n'
r'\s*"([^"]+)"\s*\n'
r'\s*-> [^\n]+\n'
r'\s*Confidence: ([\d.]+)',
re.MULTILINE,
)
for match in pattern.finditer(stdout):
conflicts.append({
"line_a": int(match.group(1)),
"line_b": int(match.group(2)),
"claim_a": match.group(3),
"claim_b": match.group(4),
"confidence": float(match.group(5)),
})
return conflicts


def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5):
"""
Search the palace. Returns verbatim drawer content.
Expand Down Expand Up @@ -91,7 +170,8 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r


def search_memories(
query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5
query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5,
verify: bool = False,
) -> dict:
"""
Programmatic search — returns a dict instead of printing.
Expand Down Expand Up @@ -145,8 +225,15 @@ def search_memories(
}
)

return {
response = {
"query": query,
"filters": {"wing": wing, "room": room},
"results": hits,
}

if verify:
verification = _verify_with_tardygrada(hits)
response["contradictions"] = verification.get("contradictions")
response["verify_warning"] = verification.get("verify_warning")

return response
104 changes: 104 additions & 0 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
test_verify.py — Tests for tardygrada contradiction detection on search results.
"""

import subprocess
from unittest.mock import patch, MagicMock

from mempalace.searcher import search_memories

# Sample tardygrada verify-doc output with a conflict
CONFLICT_OUTPUT = """=== Tardygrada Document Verification ===
File: /tmp/mempalace_verify_abc123.md
Sentences: 4
Triples extracted: 6
Entity groups: 2
Pairs checked: 1

[CONFLICT] Lines 1 vs 3:
"project completed on time"
"project delayed 3 months"
-> Triple conflict: (project, status, completed) vs (project, status, delayed)
Confidence: 0.85

Summary: 1 contradiction found, 1 potential conflict checked, 4 sentences verified
Time: 3ms
"""

CLEAN_OUTPUT = """=== Tardygrada Document Verification ===
File: /tmp/mempalace_verify_abc123.md
Sentences: 4
Triples extracted: 6
Entity groups: 2
Pairs checked: 1

Summary: 0 contradictions found, 1 potential conflict checked, 4 sentences verified
Time: 2ms
"""


class TestVerifyWithTardygrada:
def test_verify_detects_conflicts(self, palace_path, seeded_collection):
mock_result = MagicMock()
mock_result.stdout = CONFLICT_OUTPUT
mock_result.returncode = 0

with patch("mempalace.searcher.subprocess.run", return_value=mock_result):
result = search_memories("authentication", palace_path, verify=True)

assert "contradictions" in result
assert len(result["contradictions"]) == 1
c = result["contradictions"][0]
assert "project completed on time" in c["claim_a"]
assert "project delayed 3 months" in c["claim_b"]
assert c["confidence"] == 0.85

def test_verify_clean_results(self, palace_path, seeded_collection):
mock_result = MagicMock()
mock_result.stdout = CLEAN_OUTPUT
mock_result.returncode = 0

with patch("mempalace.searcher.subprocess.run", return_value=mock_result):
result = search_memories("authentication", palace_path, verify=True)

assert "contradictions" in result
assert result["contradictions"] == []

def test_verify_missing_binary(self, palace_path, seeded_collection):
with patch(
"mempalace.searcher.subprocess.run",
side_effect=FileNotFoundError("tardygrada not found"),
):
result = search_memories("authentication", palace_path, verify=True)

assert result["contradictions"] is None
assert "tardygrada" in result["verify_warning"].lower()

def test_verify_timeout(self, palace_path, seeded_collection):
with patch(
"mempalace.searcher.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="tardygrada", timeout=10),
):
result = search_memories("authentication", palace_path, verify=True)

assert result["contradictions"] is None
assert "timeout" in result["verify_warning"].lower()

def test_verify_default_off(self, palace_path, seeded_collection):
with patch("mempalace.searcher.subprocess.run") as mock_run:
result = search_memories("authentication", palace_path)

mock_run.assert_not_called()
assert "contradictions" not in result

def test_verify_preserves_results(self, palace_path, seeded_collection):
mock_result = MagicMock()
mock_result.stdout = CLEAN_OUTPUT
mock_result.returncode = 0

with patch("mempalace.searcher.subprocess.run", return_value=mock_result):
result = search_memories("authentication", palace_path, verify=True)

assert "results" in result
assert len(result["results"]) > 0
assert result["results"][0]["text"]