Skip to content
Merged
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
17 changes: 15 additions & 2 deletions hoard/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ def _call_mcp(config: dict, method: str, params: dict, *, token_override: str |
if isinstance(payload, dict) and payload.get("error"):
message = payload["error"].get("message", payload["error"])
raise click.ClickException(str(message))
return payload.get("result", payload)
result = payload.get("result", payload)
if method == "tools/call" and isinstance(result, dict) and "content" in result:
try:
return json.loads(result["content"][0]["text"])
except (KeyError, IndexError, json.JSONDecodeError):
pass
return result


def _call_admin(config: dict, method: str, params: dict) -> dict:
Expand Down Expand Up @@ -1946,7 +1952,14 @@ def _call_mcp(method: str, params: dict) -> dict:


def _call_tool(name: str, arguments: dict) -> dict:
return _call_mcp("tools/call", {"name": name, "arguments": arguments})
resp = _call_mcp("tools/call", {"name": name, "arguments": arguments})
result = resp.get("result", resp)
if isinstance(result, dict) and "content" in result:
try:
return json.loads(result["content"][0]["text"])
except (KeyError, IndexError, json.JSONDecodeError):
pass
return result


def search(query: str, limit: int = 10) -> dict:
Expand Down
3 changes: 3 additions & 0 deletions hoard/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@
"top_k": 5,
"contradiction_threshold": 0.7,
},
"duplicates": {
"similarity_threshold": 0.85,
},
"sensitivity": {
"sensitive_max_ttl_days": 90,
"restricted_max_ttl_days": 30,
Expand Down
7 changes: 6 additions & 1 deletion hoard/core/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ def _dispatch_jsonrpc(self, payload: Dict[str, Any], conn, limiter, token):
chunks_returned=count_chunks(response),
bytes_returned=len(response_bytes),
)
return {"jsonrpc": "2.0", "id": msg_id, "result": response}
content_result = _wrap_tool_result(response)
return {"jsonrpc": "2.0", "id": msg_id, "result": content_result}
except ValueError as exc:
self._log_access(
tool=tool_name,
Expand Down Expand Up @@ -325,6 +326,10 @@ def run_server(
server.serve_forever()


def _wrap_tool_result(response: Dict[str, Any]) -> Dict[str, Any]:
return {"content": [{"type": "text", "text": json.dumps(response)}]}


def _jsonrpc_error(payload: Dict[str, Any], code: int, message: str) -> Dict[str, Any]:
msg_id = payload.get("id") if isinstance(payload, dict) else None
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
Expand Down
3 changes: 2 additions & 1 deletion hoard/core/mcp/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ def _handle_tools_call(self, msg_id: Any, params: Dict[str, Any]) -> Dict[str, A
response = dispatch_tool(tool_name, arguments, conn, self.config, token)
response_bytes = json.dumps(response).encode("utf-8")
limiter.check_quota(token.name, count_chunks(response), len(response_bytes))
return self._build_result(msg_id, response)
content_result = {"content": [{"type": "text", "text": json.dumps(response)}]}
return self._build_result(msg_id, content_result)
except AuthError as exc:
return self._build_error(msg_id, -32001, str(exc))
except ScopeError as exc:
Expand Down
13 changes: 13 additions & 0 deletions hoard/core/memory/model_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

from typing import Any

_st_cache: dict[str, Any] = {}


def get_sentence_transformer(model_name: str):
"""Return a cached SentenceTransformer instance for the given model name."""
if model_name not in _st_cache:
from sentence_transformers import SentenceTransformer
_st_cache[model_name] = SentenceTransformer(model_name)
return _st_cache[model_name]
11 changes: 7 additions & 4 deletions hoard/core/memory/v2/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@
from typing import Any, Dict, Iterable, List, Optional

from hoard.core.memory.predicates import active_memory_conditions
from hoard.core.search.bm25 import _sanitize_fts_query
from hoard.core.security.auth import TokenInfo


from hoard.core.memory.model_cache import get_sentence_transformer


class MemoryError(Exception):
pass

Expand Down Expand Up @@ -236,7 +240,7 @@ def memory_write(

_insert_event(conn, memory_id=memory_id, event_type="created", actor=actor or source_agent)

_enqueue_job(conn, job_type="embed_memory", memory_id=memory_id, priority=0)
_enqueue_job(conn, job_type="embed_memory", memory_id=memory_id, priority=10)
_enqueue_job(conn, job_type="detect_duplicates", memory_id=memory_id, priority=0)
_enqueue_job(conn, job_type="detect_conflicts", memory_id=memory_id, priority=0)

Expand Down Expand Up @@ -547,7 +551,7 @@ def memory_query(
ORDER BY bm25(memories_fts)
LIMIT ?
""",
[query, *base_params, *tag_params, union_limit * 5],
[_sanitize_fts_query(query), *base_params, *tag_params, union_limit * 5],
).fetchall()

fts_scores: dict[str, float] = {}
Expand All @@ -564,12 +568,11 @@ def memory_query(
)
if vectors_enabled:
try:
from sentence_transformers import SentenceTransformer
import numpy as np

model_cfg = config.get("write", {}).get("embeddings", {}).get("active_model", {})
model_name = model_cfg.get("name", "sentence-transformers/all-MiniLM-L6-v2")
model = SentenceTransformer(model_name)
model = get_sentence_transformer(model_name)
query_vec = model.encode([query], normalize_embeddings=True)[0]

emb_rows = conn.execute(
Expand Down
10 changes: 9 additions & 1 deletion hoard/core/search/bm25.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
from typing import Any, Dict, List


def _sanitize_fts_query(query: str) -> str:
"""Escape FTS5 metacharacters by wrapping each token in double quotes."""
tokens = query.split()
if not tokens:
return query
return " ".join('"' + token.replace('"', '""') + '"' for token in tokens)


def search_chunks_flat(
conn,
query: str,
Expand All @@ -27,7 +35,7 @@ def search_chunks_flat(
" AND entities.tombstoned_at IS NULL"
)

params: list[Any] = [query]
params: list[Any] = [_sanitize_fts_query(query)]
if source:
sql += " AND entities.source = ?"
params.append(source)
Expand Down
10 changes: 9 additions & 1 deletion hoard/core/search/hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@
from hoard.core.search.bm25 import search_chunks_flat
from hoard.core.search.vector import vector_search

_model_cache: dict[str, EmbeddingModel] = {}


def _get_model(model_name: str) -> EmbeddingModel:
if model_name not in _model_cache:
_model_cache[model_name] = EmbeddingModel(model_name)
return _model_cache[model_name]


def hybrid_search(
conn,
Expand Down Expand Up @@ -51,7 +59,7 @@ def hybrid_search(

if vectors_enabled:
try:
model = EmbeddingModel(model_name)
model = _get_model(model_name)
query_vector = model.encode([query])[0]
vector_results = vector_search(
conn,
Expand Down
Loading