Improve MCP memory discovery tools

This commit is contained in:
HumbleTurtle
2026-05-20 12:29:24 -03:00
parent 18c67ef27d
commit 5cdbc8f2be
2 changed files with 265 additions and 9 deletions
+214 -9
View File
@@ -467,6 +467,7 @@ PUBLIC_TOOLS = {
"nap_update_self", "nap_update_self",
"nap_apropos", "nap_apropos",
"nap_contract", "nap_contract",
"nap_classify_query",
"nap_lock_acquire", "nap_lock_acquire",
"nap_lock_renew", "nap_lock_renew",
"nap_lock_release", "nap_lock_release",
@@ -475,6 +476,7 @@ PUBLIC_TOOLS = {
"nap_grep_local", "nap_grep_local",
"nap_index_status", "nap_index_status",
"nap_index_clear", "nap_index_clear",
"nap_discover",
"nap_grep", "nap_grep",
"nap_context", "nap_context",
"nap_find_related", "nap_find_related",
@@ -5371,6 +5373,12 @@ def node_status(node):
return str(value).strip() if value not in (None, "") else None return str(value).strip() if value not in (None, "") else None
def node_date(node):
metadata = node_metadata(node)
value = metadata.get("date") or metadata.get("updated_at") or metadata.get("created_at")
return str(value).strip() if value not in (None, "") else None
def node_archived(node): def node_archived(node):
return bool(node.get("archived_at")) or node_status(node) == "archived" return bool(node.get("archived_at")) or node_status(node) == "archived"
@@ -5411,6 +5419,50 @@ def preview_text(value, limit):
return text[: max(0, limit - 3)].rstrip() + "..." return text[: max(0, limit - 3)].rstrip() + "..."
def normalize_datetime_filter(value, *, end_of_day=False):
text = str(value or "").strip()
if not text:
return None
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
suffix = "23:59:59Z" if end_of_day else "00:00:00Z"
return f"{text}T{suffix}"
if text.endswith("Z"):
return text
if re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?", text):
if len(text) == 16:
text = f"{text}:00"
return f"{text}Z"
return text
def normalize_discovery_ranges(args):
normalized = dict(args or {})
if normalized.get("after") and not normalized.get("updated_after"):
normalized["updated_after"] = normalized["after"]
if normalized.get("since") and not normalized.get("updated_after"):
normalized["updated_after"] = normalized["since"]
if normalized.get("before") and not normalized.get("updated_before"):
normalized["updated_before"] = normalized["before"]
if normalized.get("until") and not normalized.get("updated_before"):
normalized["updated_before"] = normalized["until"]
if normalized.get("updated_after"):
normalized["updated_after"] = normalize_datetime_filter(normalized["updated_after"], end_of_day=False)
if normalized.get("updated_before"):
normalized["updated_before"] = normalize_datetime_filter(normalized["updated_before"], end_of_day=True)
return normalized
def node_in_updated_range(node, args):
updated_at = str(node.get("updated_at") or "")
after = args.get("updated_after")
before = args.get("updated_before")
if after and updated_at <= str(after):
return False
if before and updated_at >= str(before):
return False
return True
def metadata_summary(node): def metadata_summary(node):
metadata = node_metadata(node) metadata = node_metadata(node)
if not metadata and isinstance(node.get("metadata_summary"), dict): if not metadata and isinstance(node.get("metadata_summary"), dict):
@@ -5441,6 +5493,8 @@ def summarize_node(node, view="summary", preview_chars=240):
"full_path": node.get("full_path"), "full_path": node.get("full_path"),
"type": node.get("type"), "type": node.get("type"),
"status": status, "status": status,
"date": node_date(node),
"created_at": node.get("created_at"),
"updated_at": node.get("updated_at"), "updated_at": node.get("updated_at"),
} }
if node_archived(node): if node_archived(node):
@@ -5456,6 +5510,8 @@ def summarize_node(node, view="summary", preview_chars=240):
"folder_path": node.get("folder_path"), "folder_path": node.get("folder_path"),
"tags": node.get("tags") or [], "tags": node.get("tags") or [],
"status": status, "status": status,
"date": node_date(node),
"created_at": node.get("created_at"),
"metadata_summary": metadata_summary(node), "metadata_summary": metadata_summary(node),
"updated_at": node.get("updated_at"), "updated_at": node.get("updated_at"),
"links_count": len(node.get("links") or []) if isinstance(node.get("links"), list) else 0, "links_count": len(node.get("links") or []) if isinstance(node.get("links"), list) else 0,
@@ -6034,6 +6090,7 @@ def local_index_diagnostics(project_id, conn=None):
def search_local_index(args): def search_local_index(args):
args = normalize_discovery_ranges(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
query_analysis, variants = fts_query_variants(args.get("q")) query_analysis, variants = fts_query_variants(args.get("q"))
limit = max(1, min(int(args.get("limit", 25)), 100)) limit = max(1, min(int(args.get("limit", 25)), 100))
@@ -6055,11 +6112,22 @@ def search_local_index(args):
bm25(local_index_fts) AS rank bm25(local_index_fts) AS rank
FROM local_index_fts FROM local_index_fts
JOIN local_index_nodes n ON n.node_id = local_index_fts.node_id JOIN local_index_nodes n ON n.node_id = local_index_fts.node_id
WHERE local_index_fts MATCH ? AND n.project_id = ? WHERE local_index_fts MATCH ?
AND n.project_id = ?
AND (? IS NULL OR n.updated_at > ?)
AND (? IS NULL OR n.updated_at < ?)
ORDER BY rank ASC, n.updated_at DESC ORDER BY rank ASC, n.updated_at DESC
LIMIT ? LIMIT ?
""", """,
(variant["query"], project_id, limit), (
variant["query"],
project_id,
args.get("updated_after"),
args.get("updated_after"),
args.get("updated_before"),
args.get("updated_before"),
limit,
),
).fetchall() ).fetchall()
except sqlite3.Error as exc: except sqlite3.Error as exc:
attempts.append({"strategy": variant["strategy"], "matched": 0, "error": str(exc)}) attempts.append({"strategy": variant["strategy"], "matched": 0, "error": str(exc)})
@@ -7661,11 +7729,12 @@ def get_backlinks_by_path(args):
def list_project_nodes(args): def list_project_nodes(args):
args = normalize_discovery_ranges(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
view, view_warnings = normalize_discovery_view(args, default="paths") view, view_warnings = normalize_discovery_view(args, default="paths")
filters = status_filter_from_args(args) filters = status_filter_from_args(args)
limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, discovery_max_limit(view)) limit = normalize_int(args.get("limit"), DISCOVERY_DEFAULT_LIMIT, 1, discovery_max_limit(view))
allowed = {"tag", "folder_path", "q", "limit", "cursor", "updated_before", "updated_after", "type", "include_archived", "archived_only"} allowed = {"tag", "folder_path", "q", "limit", "cursor", "updated_before", "updated_after", "type", "include_archived", "archived_only", "active_only", "status", "exclude_status"}
base_query = {key: value for key, value in args.items() if key in allowed and value not in (None, "")} base_query = {key: value for key, value in args.items() if key in allowed and value not in (None, "")}
base_query["limit"] = limit base_query["limit"] = limit
base_query["view"] = "summary" if view == "titles" else view base_query["view"] = "summary" if view == "titles" else view
@@ -8972,6 +9041,7 @@ def server_search_queries(query_analysis):
def search_memory(args): def search_memory(args):
args = normalize_discovery_ranges(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
q = str(args.get("q") or "").strip() q = str(args.get("q") or "").strip()
if not q: if not q:
@@ -8995,7 +9065,12 @@ def search_memory(args):
if mode in {"hybrid", "local"} and INDEX_PATH.exists(): if mode in {"hybrid", "local"} and INDEX_PATH.exists():
try: try:
local_result = search_local_index({"q": q, "limit": limit}) local_result = search_local_index({
"q": q,
"limit": limit,
"updated_after": args.get("updated_after"),
"updated_before": args.get("updated_before"),
})
diagnostics["local_index"] = local_result.get("index", diagnostics["local_index"]) diagnostics["local_index"] = local_result.get("index", diagnostics["local_index"])
diagnostics["local_strategies"] = local_result.get("search_strategies", []) diagnostics["local_strategies"] = local_result.get("search_strategies", [])
local = local_result.get("items", []) local = local_result.get("items", [])
@@ -9036,7 +9111,8 @@ def search_memory(args):
sources.append("server") sources.append("server")
remaining = max(1, limit - len(unique_nodes(collected))) remaining = max(1, limit - len(unique_nodes(collected)))
items = apply_status_filters(unique_nodes(collected), filters)[:limit] items = [node for node in unique_nodes(collected) if node_in_updated_range(node, args)]
items = apply_status_filters(items, filters)[:limit]
if not items and query_analysis["is_long_query"]: if not items and query_analysis["is_long_query"]:
diagnostics["hint"] = "Long natural-language query produced no matches; retry with quoted exact phrases or 2-5 distinctive nouns if the target memory exists." diagnostics["hint"] = "Long natural-language query produced no matches; retry with quoted exact phrases or 2-5 distinctive nouns if the target memory exists."
response = discovery_envelope( response = discovery_envelope(
@@ -9056,6 +9132,7 @@ def search_memory(args):
def recent_memory(args): def recent_memory(args):
args = normalize_discovery_ranges(args)
limit = max(1, min(int(args.get("limit", 25)), 100)) limit = max(1, min(int(args.get("limit", 25)), 100))
view, _warnings = normalize_discovery_view(args, default="paths") view, _warnings = normalize_discovery_view(args, default="paths")
query = {**args, "limit": limit, "view": view} query = {**args, "limit": limit, "view": view}
@@ -9065,6 +9142,67 @@ def recent_memory(args):
return list_project_nodes(query) return list_project_nodes(query)
def discover_memory(args):
args = normalize_discovery_ranges(args)
q = str(args.get("q") or args.get("keywords") or "").strip()
if q:
result = search_memory({**args, "q": q, "view": args.get("view") or "summary"})
result["tool_intent"] = "search"
else:
result = list_project_nodes({**args, "view": args.get("view") or "summary"})
result["tool_intent"] = "list"
result["range"] = {
"updated_after": args.get("updated_after"),
"updated_before": args.get("updated_before"),
}
result["awareness"] = {
"default_sort": "search_relevance_then_updated_desc" if q else "updated_desc",
"date_fields_in_rows": ["date", "created_at", "updated_at"],
"next_step": "Use nap_context or nap_cat on important paths when the preview is not enough.",
}
return result
def classify_memory_query(args):
args = normalize_discovery_ranges(args or {})
raw = str(args.get("q") or args.get("query") or "").strip()
analysis = analyze_search_query(raw) if raw else {"raw": "", "is_long_query": False, "phrases": [], "terms": [], "simplified_query": ""}
exact_paths = [token for token in raw.split() if token.startswith("/") and len(token) > 1]
recent_intent = any(term in raw.lower().split() for term in ["recent", "latest", "new", "newest", "updated"])
if exact_paths:
primary_tool = "nap_context"
reason = "query contains explicit node path(s)"
elif recent_intent and not raw.strip('"'):
primary_tool = "nap_recent"
reason = "query asks for recent or latest memory"
elif analysis.get("terms") or analysis.get("phrases"):
primary_tool = "nap_discover"
reason = "query has searchable keywords and may need filters or ranges"
else:
primary_tool = "nap_ls"
reason = "no distinctive search terms were detected"
return {
"ok": True,
"classification": {
"primary_tool": primary_tool,
"reason": reason,
"exact_paths": exact_paths,
"recent_intent": recent_intent,
"range": {
"updated_after": args.get("updated_after"),
"updated_before": args.get("updated_before"),
},
},
"query_analysis": analysis,
"suggested_calls": [
{"tool": "nap_context", "when": "You already know one or more canonical paths and need surrounding links/backlinks."},
{"tool": "nap_discover", "when": "You need search plus folder/tag/type/status/date filters in one call."},
{"tool": "nap_recent", "when": "You need latest project changes by time window."},
{"tool": "nap_cat", "when": "You selected a path and need the full body."},
],
}
def get_memory_context(args): def get_memory_context(args):
raw_paths = args.get("paths") or ([args["path"]] if args.get("path") else []) raw_paths = args.get("paths") or ([args["path"]] if args.get("path") else [])
if not raw_paths: if not raw_paths:
@@ -10536,6 +10674,22 @@ def raw_tools():
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{
"name": "nap_classify_query",
"description": "Classify a memory-retrieval request into the best Napseer MCP tool, extracted search terms, exact paths, recent intent, and normalized date range.",
"inputSchema": {
"type": "object",
"properties": {
"q": {"type": "string"},
"query": {"type": "string"},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."},
},
"additionalProperties": False,
},
},
{ {
"name": "nap_lock_acquire", "name": "nap_lock_acquire",
"description": "Acquire a renewable project write lease. All project mutations require lock headers.", "description": "Acquire a renewable project write lease. All project mutations require lock headers.",
@@ -10591,12 +10745,16 @@ def raw_tools():
}, },
{ {
"name": "nap_grep_local", "name": "nap_grep_local",
"description": "Search the local SQLite FTS index. Encrypted nodes are indexed by safe path, name, tags, aliases, and links only.", "description": "Search the local SQLite FTS index with optional updated_at range filters. Encrypted nodes are indexed by safe path, name, tags, aliases, and links only.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["q"], "required": ["q"],
"properties": { "properties": {
"q": {"type": "string", "description": "Search text. Natural language is accepted; distinctive nouns, paths, tags, or quoted phrases produce better matches than full instructions."}, "q": {"type": "string", "description": "Search text. Natural language is accepted; distinctive nouns, paths, tags, or quoted phrases produce better matches than full instructions."},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."},
"limit": {"type": "integer", "minimum": 1, "maximum": 100}, "limit": {"type": "integer", "minimum": 1, "maximum": 100},
}, },
"additionalProperties": False, "additionalProperties": False,
@@ -10614,7 +10772,7 @@ def raw_tools():
}, },
{ {
"name": "nap_grep", "name": "nap_grep",
"description": "Preferred search tool. Hybrid search over server-indexed fields and the local SQLite FTS safe metadata index.", "description": "Preferred search tool. Hybrid search over server-indexed fields and the local SQLite FTS safe metadata index, with optional updated_at ranges.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"required": ["q"], "required": ["q"],
@@ -10623,6 +10781,10 @@ def raw_tools():
"mode": {"type": "string", "enum": ["hybrid", "server", "local"], "default": "hybrid"}, "mode": {"type": "string", "enum": ["hybrid", "server", "local"], "default": "hybrid"},
"limit": {"type": "integer", "minimum": 1, "maximum": 100}, "limit": {"type": "integer", "minimum": 1, "maximum": 100},
"view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "paths"}, "view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "paths"},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."},
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."}, "include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."},
"active_only": {"type": "boolean", "default": False}, "active_only": {"type": "boolean", "default": False},
"status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
@@ -10633,6 +10795,35 @@ def raw_tools():
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{
"name": "nap_discover",
"description": "Intent-shaped project memory discovery. Combine keywords, folder/tag/type/status filters, updated_at ranges, recent sorting, and compact date-aware rows before using nap_context or nap_cat.",
"inputSchema": {
"type": "object",
"properties": {
"q": {"type": "string", "description": "Keywords or natural-language query. Omit to list recent nodes by filters."},
"keywords": {"type": "string", "description": "Alias for q."},
"folder_path": {"type": "string"},
"tag": {"type": "string"},
"type": {"type": "string"},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."},
"limit": {"type": "integer", "minimum": 1, "maximum": 100},
"view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "summary"},
"mode": {"type": "string", "enum": ["hybrid", "server", "local"], "default": "hybrid"},
"include_archived": {"type": "boolean", "default": False},
"archived_only": {"type": "boolean", "default": False},
"active_only": {"type": "boolean", "default": False},
"status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
"exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
"verbose": {"type": "boolean", "default": True},
"preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240},
},
"additionalProperties": False,
},
},
{ {
"name": "nap_find", "name": "nap_find",
"description": "Structured active memory search by folder, tag, path, or query. Returns path/date rows by default; use view='full' or nap_cat for body text.", "description": "Structured active memory search by folder, tag, path, or query. Returns path/date rows by default; use view='full' or nap_cat for body text.",
@@ -10643,6 +10834,10 @@ def raw_tools():
"tag": {"type": "string"}, "tag": {"type": "string"},
"q": {"type": "string"}, "q": {"type": "string"},
"cursor": {"type": "string"}, "cursor": {"type": "string"},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."},
"limit": {"type": "integer", "minimum": 1, "maximum": 10000}, "limit": {"type": "integer", "minimum": 1, "maximum": 10000},
"view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "paths"}, "view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "paths"},
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."}, "include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set."},
@@ -10767,8 +10962,10 @@ def raw_tools():
"folder_path": {"type": "string"}, "folder_path": {"type": "string"},
"tag": {"type": "string"}, "tag": {"type": "string"},
"cursor": {"type": "string"}, "cursor": {"type": "string"},
"updated_before": {"type": "string"}, "updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"updated_after": {"type": "string"}, "updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."},
"limit": {"type": "integer", "minimum": 1, "maximum": 100}, "limit": {"type": "integer", "minimum": 1, "maximum": 100},
"view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "paths"}, "view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata", "full"], "default": "paths"},
"include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set. nap_recent and nap_find default to path/date output."}, "include_content": {"type": "boolean", "default": False, "description": "Legacy; use view='full' for body text. Ignored when view is set. nap_recent and nap_find default to path/date output."},
@@ -11338,6 +11535,8 @@ TOOL_CATEGORIES = {
"nap_apropos", "nap_apropos",
"nap_man", "nap_man",
"nap_contract", "nap_contract",
"nap_classify_query",
"nap_discover",
"nap_grep", "nap_grep",
"nap_find", "nap_find",
"nap_plan_list_active", "nap_plan_list_active",
@@ -11444,6 +11643,8 @@ READ_ONLY_TOOLS = {
"nap_apropos", "nap_apropos",
"nap_man", "nap_man",
"nap_contract", "nap_contract",
"nap_classify_query",
"nap_discover",
"nap_grep_local", "nap_grep_local",
"nap_index_status", "nap_index_status",
"nap_grep", "nap_grep",
@@ -11786,6 +11987,8 @@ def call_tool_impl(name, args):
return man_tool(args) return man_tool(args)
if name == "nap_contract": if name == "nap_contract":
return contract_profile(args) return contract_profile(args)
if name == "nap_classify_query":
return classify_memory_query(args)
if name == "nap_lock_acquire": if name == "nap_lock_acquire":
return acquire_project_lock(args) return acquire_project_lock(args)
if name == "nap_lock_renew": if name == "nap_lock_renew":
@@ -11802,6 +12005,8 @@ def call_tool_impl(name, args):
return index_status(args) return index_status(args)
if name == "nap_index_clear": if name == "nap_index_clear":
return clear_local_index(args) return clear_local_index(args)
if name == "nap_discover":
return discover_memory(args)
if name == "nap_grep": if name == "nap_grep":
return search_memory(args) return search_memory(args)
if name == "nap_find": if name == "nap_find":
@@ -110,6 +110,34 @@ def test_recent_memory_defaults_to_paths(mod):
assert "metadata_summary" not in result["items"][0] assert "metadata_summary" not in result["items"][0]
def test_recent_memory_accepts_date_aliases_and_returns_dates(mod):
recent = node(
"/notes/recent-title",
"active",
metadata={"date": "2026-05-06"},
tags=["recent"],
node_type="note",
)
recent["created_at"] = "2026-05-05T10:00:00Z"
def fake_request(method, path, payload=None, **kwargs):
assert method == "GET"
assert "updated_after=2026-05-05T00%3A00%3A00Z" in path
assert "updated_before=2026-05-06T23%3A59%3A59Z" in path
return {"items": [recent], "next_cursor": None}
mod.request_json = fake_request
result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"})
assert result["items"] == [{
"full_path": "/notes/recent-title",
"type": "note",
"status": "active",
"date": "2026-05-06",
"created_at": "2026-05-05T10:00:00Z",
"updated_at": "2026-05-06T12:00:00Z",
}]
def test_find_defaults_to_paths_and_requests_compact_rest_view(mod): def test_find_defaults_to_paths_and_requests_compact_rest_view(mod):
found = node( found = node(
"/plans/found", "/plans/found",
@@ -137,6 +165,29 @@ def test_find_defaults_to_paths_and_requests_compact_rest_view(mod):
assert "content_text" not in result["items"][0] assert "content_text" not in result["items"][0]
def test_discover_uses_search_when_keywords_present(mod):
calls = []
def fake_search(args):
calls.append(args)
return {"ok": True, "items": [], "view": args.get("view")}
mod.search_memory = fake_search
result = mod.discover_memory({"keywords": "gateway relay", "since": "2026-05-01"})
assert result["tool_intent"] == "search"
assert result["range"]["updated_after"] == "2026-05-01T00:00:00Z"
assert calls and calls[-1]["q"] == "gateway relay"
assert calls[-1]["view"] == "summary"
assert "date" in result["awareness"]["date_fields_in_rows"]
def test_classify_query_points_exact_paths_to_context(mod):
result = mod.classify_memory_query({"q": "explain /documentation/security/overview since May", "since": "2026-05-01"})
assert result["classification"]["primary_tool"] == "nap_context"
assert result["classification"]["exact_paths"] == ["/documentation/security/overview"]
assert result["classification"]["range"]["updated_after"] == "2026-05-01T00:00:00Z"
def test_title_view_requests_summary_rest_view_without_content(mod): def test_title_view_requests_summary_rest_view_without_content(mod):
found = node( found = node(
"/notes/titled", "/notes/titled",