Remove path based full discovery reads

This commit is contained in:
HumbleTurtle
2026-05-22 22:27:32 -03:00
parent 27a37a4938
commit 8d009ad61f
2 changed files with 147 additions and 62 deletions
+77 -60
View File
@@ -49,7 +49,7 @@ import webbrowser
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata", "full"} DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"}
DISCOVERY_DEFAULT_LIMIT = 25 DISCOVERY_DEFAULT_LIMIT = 25
DISCOVERY_COMPACT_MAX_LIMIT = 10_000 DISCOVERY_COMPACT_MAX_LIMIT = 10_000
DISCOVERY_FULL_MAX_LIMIT = 10_000 DISCOVERY_FULL_MAX_LIMIT = 10_000
@@ -5395,11 +5395,15 @@ def normalize_discovery_view(args, default="summary"):
requested = str(args.get("view") or "").strip().lower() requested = str(args.get("view") or "").strip().lower()
warnings = [] warnings = []
if requested: if requested:
if requested == "full" and args.get("_allow_full"):
return requested, warnings
if requested not in DISCOVERY_VIEWS: if requested not in DISCOVERY_VIEWS:
raise RuntimeError("view must be titles, paths, summary, metadata, or full") raise RuntimeError(
"view must be titles, paths, summary, or metadata; full node bodies require nap_node_get by node_id or uri"
)
return requested, warnings return requested, warnings
if as_bool(args.get("include_content"), False): if as_bool(args.get("include_content"), False):
return "full", warnings warnings.append("include_content is ignored; resolve a path and read full content by node_id or uri")
return default, warnings return default, warnings
@@ -5522,8 +5526,6 @@ def metadata_summary(node):
def summarize_node(node, view="summary", preview_chars=240): def summarize_node(node, view="summary", preview_chars=240):
if view == "full":
return node
metadata = node_metadata(node) metadata = node_metadata(node)
status = node_status(node) status = node_status(node)
if view == "titles": if view == "titles":
@@ -5618,7 +5620,7 @@ def discovery_envelope(project_id, items, args, *, view=None, next_cursor=None,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"has_more": bool(has_more) if has_more is not None else bool(next_cursor), "has_more": bool(has_more) if has_more is not None else bool(next_cursor),
"truncated": False, "truncated": False,
"omitted_fields": [] if view == "full" else ["content_text"], "omitted_fields": ["content_text"],
"budget": {"limit": limit, "max_limit": discovery_max_limit(view), "preview_chars": preview_chars}, "budget": {"limit": limit, "max_limit": discovery_max_limit(view), "preview_chars": preview_chars},
} }
if as_bool(args.get("verbose"), False): if as_bool(args.get("verbose"), False):
@@ -7559,10 +7561,28 @@ def get_node_by_path(args, allow_agent=False):
if not allow_agent: if not allow_agent:
reject_agent_namespace_path(path) reject_agent_namespace_path(path)
params = {"path": path} params = {"path": path}
if args.get("view"): payload = request_json("GET", f"/v1/projects/{project_id}/nodes/by-path?{rest_query_string(params)}")
params["view"] = args["view"] if isinstance(payload, dict) and isinstance(payload.get("route"), dict):
node = request_json("GET", f"/v1/projects/{project_id}/nodes/by-path?{rest_query_string(params)}") route = dict(payload["route"])
return decrypt_node_for_return(node, project_id=project_id) if isinstance(payload.get("identity"), dict):
route["identity"] = payload["identity"]
if payload.get("warnings"):
route["warnings"] = payload["warnings"]
return route
node = decrypt_node_for_return(payload, project_id=project_id)
return node_route_reference(node)
def read_node_by_path(args, allow_agent=False):
route = get_node_by_path(args, allow_agent=allow_agent)
if "content_text" in route and "identity" not in route:
return route
identity = route.get("identity") if isinstance(route.get("identity"), dict) else {}
node_id = identity.get("node_id") or route.get("id")
if not node_id:
raise RuntimeError("path resolver did not return a node_id")
project_id = identity.get("project_id") or route.get("project_id") or resolve_project_id(args)
return get_node_by_id({"project_id": project_id, "node_id": node_id, "view": args.get("view", "render")})
def get_node_by_id(args): def get_node_by_id(args):
@@ -7628,13 +7648,14 @@ def node_by_uri(args):
def node_by_path(args): def node_by_path(args):
path = normalize_node_path(args["path"]) path = normalize_node_path(args["path"])
node = get_node_by_path({"path": path, "view": "render"}) route = get_node_by_path({"path": path})
identity = route.get("identity") if isinstance(route.get("identity"), dict) else node_identity_payload(route, project_id=route.get("project_id"), lookup_kind="path", lookup_value=path)
return { return {
"ok": True, "ok": True,
"status": "resolved", "status": "resolved",
"identity": node_identity_payload(node, project_id=node.get("project_id"), lookup_kind="path", lookup_value=path), "identity": identity,
"route": node_route_reference(node), "route": node_route_reference(route),
"warnings": [ "warnings": route.get("warnings") or [
"Path lookup is index/route resolution only. Read node content with identity.canonical_uri or identity.node_id." "Path lookup is index/route resolution only. Read node content with identity.canonical_uri or identity.node_id."
], ],
} }
@@ -7705,7 +7726,7 @@ def guarded_node_patch(args):
def try_get_node_by_path(path, allow_agent=False): def try_get_node_by_path(path, allow_agent=False):
try: try:
return get_node_by_path({"path": path}, allow_agent=allow_agent) return read_node_by_path({"path": path}, allow_agent=allow_agent)
except RuntimeError as exc: except RuntimeError as exc:
if "HTTP 404" in str(exc): if "HTTP 404" in str(exc):
return None return None
@@ -7778,7 +7799,7 @@ def upsert_node(args):
def update_node_by_path(args): def update_node_by_path(args):
reject_agent_namespace_path(args["path"]) reject_agent_namespace_path(args["path"])
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = get_node_by_path({"path": args["path"]}) existing = read_node_by_path({"path": args["path"]})
updated = request_project_write( updated = request_project_write(
"PATCH", "PATCH",
f"/v1/projects/{project_id}/nodes/{existing['id']}", f"/v1/projects/{project_id}/nodes/{existing['id']}",
@@ -7796,7 +7817,7 @@ def update_node_by_path(args):
def archive_node_by_path(args): def archive_node_by_path(args):
reject_agent_namespace_path(args["path"]) reject_agent_namespace_path(args["path"])
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = get_node_by_path({"path": args["path"]}) existing = read_node_by_path({"path": args["path"]})
if args.get("dry_run"): if args.get("dry_run"):
return {"ok": True, "changed": False, "dry_run": True, "archived": False, "paths": [existing["full_path"]]} return {"ok": True, "changed": False, "dry_run": True, "archived": False, "paths": [existing["full_path"]]}
request_project_write( request_project_write(
@@ -7840,8 +7861,8 @@ def link_nodes(args):
reject_agent_namespace_path(args["source_path"], "source_path") reject_agent_namespace_path(args["source_path"], "source_path")
reject_agent_namespace_path(args["target_path"], "target_path") reject_agent_namespace_path(args["target_path"], "target_path")
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
source = get_node_by_path({"path": args["source_path"]}) source = read_node_by_path({"path": args["source_path"]})
target = get_node_by_path({"path": args["target_path"]}) target = read_node_by_path({"path": args["target_path"]})
relation = args.get("relation", "references") relation = args.get("relation", "references")
links = source.get("links") or [] links = source.get("links") or []
if not isinstance(links, list): if not isinstance(links, list):
@@ -8320,6 +8341,7 @@ def list_kanban_cards(args):
"q": "/kanban", "q": "/kanban",
"limit": DISCOVERY_SOURCE_PAGE_LIMIT, "limit": DISCOVERY_SOURCE_PAGE_LIMIT,
"view": "full", "view": "full",
"_allow_full": True,
"include_archived": not active_only, "include_archived": not active_only,
"archived_only": False, "archived_only": False,
} }
@@ -8432,7 +8454,7 @@ def list_kanban_cards(args):
if as_bool(args.get("verbose"), False): if as_bool(args.get("verbose"), False):
response["diagnostics"] = { response["diagnostics"] = {
"source": "nodes", "source": "nodes",
"source_query": {"q": "/kanban", "view": "full", "limit": DISCOVERY_SOURCE_PAGE_LIMIT}, "source_query": {"q": "/kanban", "view": "internal-full", "limit": DISCOVERY_SOURCE_PAGE_LIMIT},
"source_page_count": source_pages, "source_page_count": source_pages,
"source_item_count": len(source_items), "source_item_count": len(source_items),
"filtered_item_count": len(items), "filtered_item_count": len(items),
@@ -8504,7 +8526,7 @@ def pick_next_kanban_card(args):
def kanban_card_by_path(path): def kanban_card_by_path(path):
node = get_node_by_path({"path": normalize_node_path(path), "view": "render"}) node = read_node_by_path({"path": normalize_node_path(path), "view": "render"})
if not normalize_node_path(node.get("full_path") or "").startswith("/kanban/"): if not normalize_node_path(node.get("full_path") or "").startswith("/kanban/"):
raise RuntimeError("kanban command path must reference a /kanban card") raise RuntimeError("kanban command path must reference a /kanban card")
return node return node
@@ -8662,12 +8684,14 @@ def lineage_status(args):
"folder_path": "/plans", "folder_path": "/plans",
"limit": limit, "limit": limit,
"view": "full", "view": "full",
"_allow_full": True,
"include_archived": include_archived, "include_archived": include_archived,
}).get("items", []) }).get("items", [])
kanban_nodes = list_project_nodes({ kanban_nodes = list_project_nodes({
"q": "/kanban", "q": "/kanban",
"limit": limit, "limit": limit,
"view": "full", "view": "full",
"_allow_full": True,
"include_archived": True, "include_archived": True,
}).get("items", []) }).get("items", [])
cards = [node for node in kanban_nodes if is_kanban_card_node_path(node.get("full_path") or "")] cards = [node for node in kanban_nodes if is_kanban_card_node_path(node.get("full_path") or "")]
@@ -8729,7 +8753,7 @@ def plan_to_kanban_card(args):
plan_path = normalize_node_path(args.get("plan_path") or args.get("path") or "") plan_path = normalize_node_path(args.get("plan_path") or args.get("path") or "")
if not is_plan_node_path(plan_path): if not is_plan_node_path(plan_path):
raise RuntimeError("plan_path must be a /plans/<plan-slug> path") raise RuntimeError("plan_path must be a /plans/<plan-slug> path")
plan = get_node_by_path({"path": plan_path}) plan = read_node_by_path({"path": plan_path})
metadata = node_metadata(plan) metadata = node_metadata(plan)
title = str(args.get("title") or metadata.get("title") or plan.get("name") or "").strip() title = str(args.get("title") or metadata.get("title") or plan.get("name") or "").strip()
if not title: if not title:
@@ -8844,7 +8868,7 @@ def add_tags_once(tags, additions):
def find_archived_node_by_path(path): def find_archived_node_by_path(path):
normalized = normalize_node_path(path) normalized = normalize_node_path(path)
result = list_project_nodes({"archived_only": True, "q": normalized, "limit": 200, "view": "full"}) result = list_project_nodes({"archived_only": True, "q": normalized, "limit": 200, "view": "full", "_allow_full": True})
for node in result.get("items", []): for node in result.get("items", []):
if normalize_node_path(node.get("full_path") or "") == normalized: if normalize_node_path(node.get("full_path") or "") == normalized:
return node return node
@@ -8856,7 +8880,7 @@ def get_plan_for_lifecycle(path):
if not normalized.startswith("/plans/"): if not normalized.startswith("/plans/"):
raise RuntimeError("plan lifecycle helpers only operate on /plans/* nodes") raise RuntimeError("plan lifecycle helpers only operate on /plans/* nodes")
try: try:
node = get_node_by_path({"path": normalized}) node = read_node_by_path({"path": normalized})
return node, False return node, False
except RuntimeError as exc: except RuntimeError as exc:
if "HTTP 404" not in str(exc) and "node not found" not in str(exc): if "HTTP 404" not in str(exc) and "node not found" not in str(exc):
@@ -9211,7 +9235,6 @@ def search_memory(args):
mode = str(args.get("mode") or "hybrid").strip().lower() mode = str(args.get("mode") or "hybrid").strip().lower()
if mode not in {"hybrid", "server", "local"}: if mode not in {"hybrid", "server", "local"}:
raise RuntimeError("mode must be hybrid, server, or local") raise RuntimeError("mode must be hybrid, server, or local")
include_content = view == "full"
encryption_state = project_encryption_state(project_id) encryption_state = project_encryption_state(project_id)
collected = [] collected = []
sources = [] sources = []
@@ -9233,13 +9256,7 @@ def search_memory(args):
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", [])
for item in local: for item in local:
node = item node = dict(item)
if include_content:
try:
node = get_node_by_path({"path": item["full_path"]})
except Exception:
pass
node = dict(node)
node["search_source"] = "local" node["search_source"] = "local"
collected.append(node) collected.append(node)
if local: if local:
@@ -9261,7 +9278,7 @@ def search_memory(args):
server = list_project_nodes(server_args).get("items", []) server = list_project_nodes(server_args).get("items", [])
diagnostics["server_queries"].append({"q": server_query, "matched": len(server)}) diagnostics["server_queries"].append({"q": server_query, "matched": len(server)})
for node in server: for node in server:
node = summarize_node(node, view=view) if view != "full" else node node = summarize_node(node, view=view)
node["search_source"] = "server" node["search_source"] = "server"
node["matched_query"] = server_query node["matched_query"] = server_query
collected.append(node) collected.append(node)
@@ -9368,7 +9385,7 @@ def get_memory_context(args):
raise RuntimeError("path or paths is required") raise RuntimeError("path or paths is required")
max_nodes = max(1, min(int(args.get("max_nodes", 20)), 100)) max_nodes = max(1, min(int(args.get("max_nodes", 20)), 100))
depth = max(0, min(int(args.get("depth", 1)), 2)) depth = max(0, min(int(args.get("depth", 1)), 2))
default_view = "full" if as_bool(args.get("include_content"), True) and not args.get("view") else "summary" default_view = "summary"
view, _warnings = normalize_discovery_view(args, default=default_view) view, _warnings = normalize_discovery_view(args, default=default_view)
queue_paths = [normalize_node_path(path) for path in raw_paths] queue_paths = [normalize_node_path(path) for path in raw_paths]
nodes = [] nodes = []
@@ -9382,7 +9399,7 @@ def get_memory_context(args):
continue continue
seen_paths.add(path) seen_paths.add(path)
try: try:
node = get_node_by_path({"path": path}, allow_agent=is_agent_namespace_path(path)) node = read_node_by_path({"path": path}, allow_agent=is_agent_namespace_path(path))
except Exception: except Exception:
continue continue
nodes.append(summarize_node(node, view=view, preview_chars=normalize_int(args.get("preview_chars"), 240, 80, 1000))) nodes.append(summarize_node(node, view=view, preview_chars=normalize_int(args.get("preview_chars"), 240, 80, 1000)))
@@ -9412,7 +9429,7 @@ def find_related_nodes(args):
path = args["path"] path = args["path"]
view, _warnings = normalize_discovery_view(args, default="paths") view, _warnings = normalize_discovery_view(args, default="paths")
limit = max(1, min(int(args.get("limit", 25)), 100)) limit = max(1, min(int(args.get("limit", 25)), 100))
target = get_node_by_path({"path": path}, allow_agent=is_agent_namespace_path(path)) target = read_node_by_path({"path": path}, allow_agent=is_agent_namespace_path(path))
related = [] related = []
try: try:
related.extend(get_backlinks_by_path({"path": target["full_path"]}).get("items", [])) related.extend(get_backlinks_by_path({"path": target["full_path"]}).get("items", []))
@@ -9422,7 +9439,7 @@ def find_related_nodes(args):
for link in links: for link in links:
if isinstance(link, dict) and link.get("path"): if isinstance(link, dict) and link.get("path"):
try: try:
related.append(get_node_by_path({"path": link["path"]}, allow_agent=is_agent_namespace_path(link["path"]))) related.append(read_node_by_path({"path": link["path"]}, allow_agent=is_agent_namespace_path(link["path"])))
except Exception: except Exception:
pass pass
for tag in target.get("tags") or []: for tag in target.get("tags") or []:
@@ -9460,7 +9477,7 @@ def move_node(args):
reject_agent_namespace_path(old_path) reject_agent_namespace_path(old_path)
reject_agent_namespace_path(new_path) reject_agent_namespace_path(new_path)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = get_node_by_path({"path": old_path}) existing = read_node_by_path({"path": old_path})
old_full_path = existing["full_path"] old_full_path = existing["full_path"]
new_full_path = normalize_node_path(new_path) new_full_path = normalize_node_path(new_path)
references_to_rewrite = [ references_to_rewrite = [
@@ -9627,7 +9644,7 @@ def list_agent_nodes(args):
def get_agent_node(args): def get_agent_node(args):
return get_node_by_path({"path": agent_scoped_path(args)}, allow_agent=True) return read_node_by_path({"path": agent_scoped_path(args)}, allow_agent=True)
def upsert_agent_node(args): def upsert_agent_node(args):
@@ -9658,7 +9675,7 @@ def upsert_agent_node(args):
def update_agent_node(args): def update_agent_node(args):
scoped = agent_node_args(args) scoped = agent_node_args(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = get_node_by_path({"path": scoped["path"]}, allow_agent=True) existing = read_node_by_path({"path": scoped["path"]}, allow_agent=True)
updated = request_project_write( updated = request_project_write(
"PATCH", "PATCH",
f"/v1/projects/{project_id}/nodes/{existing['id']}", f"/v1/projects/{project_id}/nodes/{existing['id']}",
@@ -9673,7 +9690,7 @@ def update_agent_node(args):
def archive_agent_node(args): def archive_agent_node(args):
scoped = agent_node_args(args) scoped = agent_node_args(args)
project_id = resolve_project_id(args) project_id = resolve_project_id(args)
existing = get_node_by_path({"path": scoped["path"]}, allow_agent=True) existing = read_node_by_path({"path": scoped["path"]}, allow_agent=True)
request_project_write( request_project_write(
"DELETE", "DELETE",
f"/v1/projects/{project_id}/nodes/{existing['id']}", f"/v1/projects/{project_id}/nodes/{existing['id']}",
@@ -9691,8 +9708,8 @@ def link_agent_node(args):
source_path = agent_scoped_path({"agent_slug": slug, "path": args["source_path"]}) source_path = agent_scoped_path({"agent_slug": slug, "path": args["source_path"]})
target_raw = str(args.get("target_path") or "").strip() target_raw = str(args.get("target_path") or "").strip()
target_path = agent_scoped_path({"agent_slug": slug, "path": target_raw}) if is_agent_namespace_path(target_raw) or not target_raw.startswith("/") else normalize_node_path(target_raw) target_path = agent_scoped_path({"agent_slug": slug, "path": target_raw}) if is_agent_namespace_path(target_raw) or not target_raw.startswith("/") else normalize_node_path(target_raw)
source = get_node_by_path({"path": source_path}, allow_agent=True) source = read_node_by_path({"path": source_path}, allow_agent=True)
target = get_node_by_path({"path": target_path}, allow_agent=is_agent_namespace_path(target_path)) target = read_node_by_path({"path": target_path}, allow_agent=is_agent_namespace_path(target_path))
relation = args.get("relation", "references") relation = args.get("relation", "references")
links = source.get("links") or [] links = source.get("links") or []
if not isinstance(links, list): if not isinstance(links, list):
@@ -10121,7 +10138,7 @@ def start_local_ui(args):
selected_node = None selected_node = None
if selected_path: if selected_path:
try: try:
selected_node = get_node_by_path({"path": selected_path}) selected_node = read_node_by_path({"path": selected_path})
except Exception: except Exception:
selected_node = None selected_node = None
schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", []) schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", [])
@@ -10324,7 +10341,7 @@ def start_local_ui(args):
selected_node = None selected_node = None
if selected_path: if selected_path:
try: try:
selected_node = get_node_by_path({"path": selected_path}) selected_node = read_node_by_path({"path": selected_path})
except Exception: except Exception:
selected_node = None selected_node = None
schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", []) schedules = request_json("GET", f"/v1/projects/{project_id}/schedules").get("items", [])
@@ -10939,12 +10956,12 @@ def raw_tools():
"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."},
"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"], "default": "paths"},
"updated_after": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD lower bound on updated_at."}, "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."}, "updated_before": {"type": "string", "description": "RFC3339 timestamp or YYYY-MM-DD upper bound on updated_at."},
"since": {"type": "string", "description": "Alias for updated_after."}, "since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."}, "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 no-op; read full bodies with nap_node_get by node_id or uri."},
"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"}}]},
"exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
@@ -10970,7 +10987,7 @@ def raw_tools():
"since": {"type": "string", "description": "Alias for updated_after."}, "since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."}, "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": "summary"}, "view": {"type": "string", "enum": ["titles", "paths", "summary", "metadata"], "default": "summary"},
"mode": {"type": "string", "enum": ["hybrid", "server", "local"], "default": "hybrid"}, "mode": {"type": "string", "enum": ["hybrid", "server", "local"], "default": "hybrid"},
"include_archived": {"type": "boolean", "default": False}, "include_archived": {"type": "boolean", "default": False},
"archived_only": {"type": "boolean", "default": False}, "archived_only": {"type": "boolean", "default": False},
@@ -10985,7 +11002,7 @@ def raw_tools():
}, },
{ {
"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' for body text or resolve selected paths with nap_node_by_path before identity reads.", "description": "Structured active memory search by folder, tag, path, or query. Returns path/date rows by default; resolve selected paths with nap_node_by_path before identity reads.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -10998,8 +11015,8 @@ def raw_tools():
"since": {"type": "string", "description": "Alias for updated_after."}, "since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."}, "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"], "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 no-op; read full bodies with nap_node_get by node_id or uri."},
"include_archived": {"type": "boolean", "default": False}, "include_archived": {"type": "boolean", "default": False},
"archived_only": {"type": "boolean", "default": False}, "archived_only": {"type": "boolean", "default": False},
"active_only": {"type": "boolean", "default": False}, "active_only": {"type": "boolean", "default": False},
@@ -11079,8 +11096,8 @@ def raw_tools():
"paths": {"type": "array", "items": {"type": "string"}}, "paths": {"type": "array", "items": {"type": "string"}},
"depth": {"type": "integer", "minimum": 0, "maximum": 2}, "depth": {"type": "integer", "minimum": 0, "maximum": 2},
"max_nodes": {"type": "integer", "minimum": 1, "maximum": 100}, "max_nodes": {"type": "integer", "minimum": 1, "maximum": 100},
"view": {"type": "string", "enum": ["paths", "summary", "metadata", "full"], "default": "full"}, "view": {"type": "string", "enum": ["paths", "summary", "metadata"], "default": "summary"},
"include_content": {"type": "boolean", "default": True, "description": "Legacy; use view='full' for body text. Ignored when view is set."}, "include_content": {"type": "boolean", "default": False, "description": "Legacy no-op; read full bodies with nap_node_get by node_id or uri."},
"preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240}, "preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240},
}, },
"additionalProperties": False, "additionalProperties": False,
@@ -11105,8 +11122,8 @@ def raw_tools():
"properties": { "properties": {
"path": {"type": "string"}, "path": {"type": "string"},
"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"], "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 no-op; read full bodies with nap_node_get by node_id or uri."},
"preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240}, "preview_chars": {"type": "integer", "minimum": 80, "maximum": 1000, "default": 240},
}, },
"additionalProperties": False, "additionalProperties": False,
@@ -11126,8 +11143,8 @@ def raw_tools():
"since": {"type": "string", "description": "Alias for updated_after."}, "since": {"type": "string", "description": "Alias for updated_after."},
"until": {"type": "string", "description": "Alias for updated_before."}, "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"], "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 no-op; read full bodies with nap_node_get by node_id or uri."},
"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"}}]},
"exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]}, "exclude_status": {"oneOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}]},
@@ -11494,8 +11511,8 @@ def raw_tools():
"q": {"type": "string", "description": "Search phrase. Distinctive nouns, paths, tags, and quoted phrases work best; long sentences are decomposed automatically."}, "q": {"type": "string", "description": "Search phrase. Distinctive nouns, paths, tags, and quoted phrases work best; long sentences are decomposed automatically."},
"cursor": {"type": "string"}, "cursor": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 10000}, "limit": {"type": "integer", "minimum": 1, "maximum": 10000},
"view": {"type": "string", "enum": ["paths", "summary", "metadata", "full"], "default": "summary"}, "view": {"type": "string", "enum": ["paths", "summary", "metadata"], "default": "summary"},
"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 no-op; read full bodies with nap_node_get by node_id or uri."},
"include_archived": {"type": "boolean", "default": False}, "include_archived": {"type": "boolean", "default": False},
"archived_only": {"type": "boolean", "default": False}, "archived_only": {"type": "boolean", "default": False},
"active_only": {"type": "boolean", "default": False}, "active_only": {"type": "boolean", "default": False},
@@ -12110,7 +12127,7 @@ def du_memory(args):
def lint_memory(args): def lint_memory(args):
limit = max(1, min(int(args.get("limit", 200)), 500)) limit = max(1, min(int(args.get("limit", 200)), 500))
result = list_project_nodes({"limit": limit, "view": "full"}) result = list_project_nodes({"limit": limit, "view": "full", "_allow_full": True})
warnings = [] warnings = []
for node in result.get("items", []): for node in result.get("items", []):
for link in node.get("links") or []: for link in node.get("links") or []:
+70 -2
View File
@@ -96,8 +96,27 @@ def test_node_by_path_returns_canonical_identity_uri(mod):
assert method == "GET" assert method == "GET"
assert "/nodes/by-path?" in path assert "/nodes/by-path?" in path
assert "path=%2Fdocumentation%2Fproduct%2Fprd" in path assert "path=%2Fdocumentation%2Fproduct%2Fprd" in path
assert "view=render" in path assert "view=render" not in path
return found return {
"identity": {
"node_id": "node-123",
"canonical_uri": "nap://napseer/project-1/node-123",
"project_id": "project-1",
"project_name": "napseer",
"lookup_kind": "path",
"lookup_value": "/documentation/product/prd",
"legacy_full_path": "/documentation/product/prd",
},
"route": {
"id": "node-123",
"project_id": "project-1",
"full_path": "/documentation/product/prd",
"type": "product_spec",
"name": "prd",
"folder_path": "/documentation/product",
},
"warnings": ["Path lookup is index/route resolution only."],
}
mod.request_json = fake_request mod.request_json = fake_request
result = mod.node_by_path({"path": "documentation/product/prd"}) result = mod.node_by_path({"path": "documentation/product/prd"})
@@ -119,6 +138,30 @@ def test_node_by_path_returns_canonical_identity_uri(mod):
assert "Path lookup is index/route resolution only" in result["warnings"][0] assert "Path lookup is index/route resolution only" in result["warnings"][0]
def test_path_resolver_then_read_uses_node_id_route(mod):
calls = []
def fake_request(method, path, payload=None, **kwargs):
calls.append(path)
assert method == "GET"
if "/nodes/by-path?" in path:
assert "view=" not in path
return {
"identity": {"node_id": "node-123", "project_id": "project-1"},
"route": {"id": "node-123", "project_id": "project-1", "full_path": "/documentation/product/prd"},
}
assert path == "/v1/projects/project-1/nodes/node-123"
return {**node("/documentation/product/prd", "active", node_type="product_spec"), "id": "node-123"}
mod.request_json = fake_request
result = mod.read_node_by_path({"path": "/documentation/product/prd"})
assert result["id"] == "node-123"
assert calls == [
"/v1/projects/project-1/nodes/by-path?path=%2Fdocumentation%2Fproduct%2Fprd",
"/v1/projects/project-1/nodes/node-123",
]
def test_node_by_id_returns_canonical_identity_uri(mod): def test_node_by_id_returns_canonical_identity_uri(mod):
mod.AUTH["project_slug"] = "napseer" mod.AUTH["project_slug"] = "napseer"
found = node("/documentation/product/prd", "active", node_type="product_spec") found = node("/documentation/product/prd", "active", node_type="product_spec")
@@ -223,6 +266,31 @@ def test_tools_include_node_by_path_descriptor(mod):
assert all(item["name"] != "nap_cat" for item in mod.tools()) assert all(item["name"] != "nap_cat" for item in mod.tools())
for name in ["nap_discover", "nap_find", "nap_context", "nap_find_related", "nap_recent"]:
schema = next(item for item in mod.tools() if item["name"] == name)["inputSchema"]
view = schema["properties"].get("view")
if view:
assert "full" not in view["enum"]
def test_discovery_rejects_full_content_view(mod):
with pytest.raises(RuntimeError, match="full node bodies require nap_node_get"):
mod.list_project_nodes({"view": "full"})
def test_include_content_is_legacy_noop(mod):
found = node("/plans/found", "planned", content="body must not be returned")
def fake_request(method, path, payload=None, **kwargs):
assert method == "GET"
assert "view=paths" in path
return {"items": [found], "next_cursor": None}
mod.request_json = fake_request
result = mod.list_project_nodes({"include_content": True, "view": "paths"})
assert result["view"] == "paths"
assert "content_text" not in result["items"][0]
def test_recent_memory_defaults_to_paths(mod): def test_recent_memory_defaults_to_paths(mod):
recent = node( recent = node(