Improve MCP memory discovery tools
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user