diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index dfd381a..9270f57 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -418,6 +418,7 @@ PUBLIC_AUTH_KEYS = { "project_id", "project_slug", "project_name", + "project_encryption_state", "project_signing_key_fingerprint", "worker_id", "agent_id", @@ -5478,6 +5479,7 @@ def load_auth(public_override=None, secret_override=None): "project_id": os.environ.get("NAPSEER_PROJECT_ID") or merged.get("project_id"), "project_slug": merged.get("project_slug"), "project_name": merged.get("project_name"), + "project_encryption_state": merged.get("project_encryption_state"), "project_signing_key_fingerprint": merged.get("project_signing_key_fingerprint"), "worker_id": merged.get("worker_id"), "agent_id": merged.get("agent_id"), @@ -5831,18 +5833,21 @@ def metadata_summary(node): def summarize_node(node, view="summary", preview_chars=240): + node_id = node.get("node_id") or node.get("id") if view == "full": return strip_node_content(node, include_content=True) metadata = node_metadata(node) status = node_status(node) if view == "titles": item = { + "node_id": node_id, "title": node.get("title") or metadata.get("title") or node.get("name") or pathlib.PurePosixPath(str(node.get("full_path") or "")).name, "full_path": node.get("full_path"), } return {key: value for key, value in item.items() if value not in (None, "")} if view == "paths": item = { + "node_id": node_id, "full_path": node.get("full_path"), "type": node.get("type"), "status": status, @@ -5856,6 +5861,7 @@ def summarize_node(node, view="summary", preview_chars=240): item = { "id": node.get("id"), + "node_id": node_id, "project_id": node.get("project_id"), "full_path": node.get("full_path"), "type": node.get("type"), @@ -6221,6 +6227,10 @@ def index_connect(): ensure_sqlite_column(conn, "local_index_nodes", "title", "TEXT") ensure_sqlite_column(conn, "local_index_nodes", "status", "TEXT") ensure_sqlite_column(conn, "local_index_nodes", "archived", "INTEGER NOT NULL DEFAULT 0") + conn.execute( + "CREATE INDEX IF NOT EXISTS local_index_nodes_project_path " + "ON local_index_nodes(project_id, full_path, archived)" + ) conn.execute( """ CREATE TABLE IF NOT EXISTS local_index_edges ( @@ -6296,6 +6306,27 @@ def local_graph_index_state(project_id, conn=None): return read(connection) +def local_index_node_ids_by_path(project_id, paths, require_complete=True): + """Resolve paths inside one coherent local-index snapshot.""" + if not INDEX_PATH.exists(): + return {}, {"graph_complete": False, "graph_completed_at": None} + normalized_paths = unique_preserve_order(normalize_node_path(path) for path in paths) + with index_connect() as connection: + graph_state = local_graph_index_state(project_id, connection) + if require_complete and not graph_state["graph_complete"]: + return {}, graph_state + resolved = {} + for path in normalized_paths: + row = connection.execute( + "SELECT node_id FROM local_index_nodes " + "WHERE project_id = ? AND full_path = ? AND archived = 0", + (project_id, path), + ).fetchone() + if row is not None: + resolved[path] = row["node_id"] + return resolved, graph_state + + def set_local_graph_index_complete(project_id, complete, conn=None): completed_at = iso_now() if complete else None @@ -8000,6 +8031,7 @@ def create_project_with_state(args): "project_id": project["id"], "project_slug": project["slug"], "project_name": project["name"], + "project_encryption_state": project.get("encryption_state") or "standard", } if AUTH.get("account_mode") == "operator_account": auth_updates["account_mode"] = "operator_project" @@ -11199,10 +11231,10 @@ def start_project_heartbeat_thread(): def project_encryption_state(project_id): - try: - return request_json("GET", f"/v1/projects/{project_id}/encryption/status").get("state") or "standard" - except Exception: - return "plaintext" + if str(project_id or "") != str(current_project_id() or ""): + return "unknown" + state = str(AUTH.get("project_encryption_state") or "").strip().lower() + return state if state in {"standard", "plaintext", "encrypted", "e2e"} else "unknown" def strip_node_content(node, include_content=False): @@ -11240,6 +11272,9 @@ def search_memory(args): mode = str(args.get("mode") or "hybrid").strip().lower() if mode not in {"hybrid", "server", "local"}: raise RuntimeError("mode must be hybrid, server, or local") + # Encryption mode is durable local project metadata. Do not spend one + # public-API round trip discovering it before every local FTS query. An + # unknown value is deliberately treated as local-first below. encryption_state = project_encryption_state(project_id) collected = [] sources = [] @@ -11274,7 +11309,12 @@ def search_memory(args): elif mode in {"hybrid", "local"}: diagnostics["local_index"]["skipped_reason"] = "index_missing" - if mode in {"hybrid", "server"} and (mode == "server" or encryption_state == "plaintext" or len(collected) < limit): + local_complete = bool((diagnostics.get("local_index") or {}).get("graph_complete")) + server_needed = mode == "server" or ( + mode == "hybrid" + and (not local_complete or len(unique_nodes(collected)) < limit) + ) + if server_needed: # The backend already analyzes the raw query into important terms and # ranks matches. Expanding query variants here (and again inside # list_project_nodes) multiplied one discovery into many serial HTTP @@ -11376,7 +11416,7 @@ def discover_memory(args): result["awareness"] = { "default_sort": "relevance" if q else "updated_desc", "date_fields_in_rows": ["date", "created_at", "updated_at"], - "next_step": "Use nap_node_by_path on important paths, then read by identity with nap_node_get when the preview is not enough.", + "next_step": "Read a selected row directly with nap_node_get using item.node_id. Use nap_node_by_path only when a path came from outside discovery and has no node_id.", } return result @@ -11416,13 +11456,13 @@ def classify_memory_query(args): {"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_discover", "when": "You need latest project changes; omit q and use sort=updated_desc."}, - {"tool": "nap_node_by_path", "when": "You selected a path and need to resolve it to node_id / nap:// URI before reading."}, - {"tool": "nap_node_get", "when": "You have node_id or nap:// URI and need the full body."}, + {"tool": "nap_node_by_path", "when": "You only have an external path with no node_id and need canonical identity."}, + {"tool": "nap_node_get", "when": "A discovery row already supplied node_id, or you have a nap:// URI, and need the full body."}, ], } -def context_seed_ids(args): +def context_seed_ids(args, mode="hybrid"): node_ids = [str(value).strip() for value in (args.get("node_ids") or []) if str(value).strip()] if args.get("node_id"): node_ids.append(str(args["node_id"]).strip()) @@ -11434,8 +11474,24 @@ def context_seed_ids(args): paths = list(args.get("paths") or []) if args.get("path"): paths.append(args["path"]) - for path in paths: - resolved = node_by_path({"path": normalize_node_path(path)}) + normalized_paths = unique_preserve_order(normalize_node_path(path) for path in paths) + for path in normalized_paths: + reject_agent_namespace_path(path) + locally_resolved = {} + if normalized_paths and mode in {"local", "hybrid"}: + project_id = resolve_project_id(args) + locally_resolved, _graph_state = local_index_node_ids_by_path( + project_id, + normalized_paths, + require_complete=mode == "hybrid", + ) + for path in normalized_paths: + if path in locally_resolved: + node_ids.append(locally_resolved[path]) + continue + if mode == "local": + raise RuntimeError("seed path is not present in the local memory index") + resolved = node_by_path({"path": path}) node_id = (resolved.get("identity") or {}).get("node_id") if node_id: node_ids.append(node_id) @@ -11633,10 +11689,10 @@ def server_memory_context(args, seed_ids): def get_memory_context(args): - seed_ids = context_seed_ids(args) mode = str(args.get("mode") or "hybrid").strip().lower() if mode not in {"hybrid", "local", "server"}: raise RuntimeError("mode must be hybrid, local, or server") + seed_ids = context_seed_ids(args, mode=mode) if mode in {"local", "hybrid"}: try: if mode == "hybrid": @@ -13291,7 +13347,7 @@ def raw_tools(): }, { "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 resolving paths with nap_node_by_path or reading identity with nap_node_get.", + "description": "Intent-shaped project memory discovery. Compact rows carry node_id; pass a selected row's node_id directly to nap_node_get. Use nap_node_by_path only for an external path that has no identity.", "inputSchema": { "type": "object", "properties": { @@ -13320,7 +13376,7 @@ def raw_tools(): }, { "name": "nap_find", - "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.", + "description": "Structured active memory search by folder, tag, path, or query. Compact rows carry node_id for direct nap_node_get reads.", "inputSchema": { "type": "object", "properties": { @@ -13807,7 +13863,7 @@ def raw_tools(): }, { "name": "nap_node_get", - "description": "Read one node by canonical node_id or nap:// URI. Paths are indexes/routes only; resolve a path with nap_node_by_path, then read by node_id or uri. Use view='edit' before nap_node_patch to get revision and read_fingerprint.", + "description": "Read one node by canonical node_id or nap:// URI. Discovery rows already carry node_id; use nap_node_by_path only when an external path has no identity. Use view='edit' before nap_node_patch to get revision and read_fingerprint.", "inputSchema": { "type": "object", "properties": { @@ -13846,7 +13902,7 @@ def raw_tools(): }, { "name": "nap_node_by_path", - "description": "Resolve a path index/route to canonical node identity. This does not read full node content; use returned node_id or nap:// URI with nap_node_get, nap_node_by_id, or nap_node_by_uri.", + "description": "Resolve an external path index/route that has no node_id to canonical identity. Discovery rows already carry node_id and should go directly to nap_node_get. This tool does not read full node content.", "inputSchema": { "type": "object", "required": ["path"], diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index 65d92a3..189aeb8 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -591,6 +591,39 @@ def test_context_and_related_use_identity_inputs(mod): mod.find_related_nodes({"path": "/notes/root"}) +def test_hybrid_context_resolves_path_inside_complete_local_snapshot(mod): + with tempfile.TemporaryDirectory() as tmp: + state_dir = pathlib.Path(tmp) + mod.AUTH_DIR = state_dir + mod.INDEX_PATH = state_dir / "index.sqlite" + mod.INDEX_LOCK_PATH = state_dir / "index.lock" + indexed = node("/notes/local-root", "active", node_type="note") + indexed["id"] = "local-root-123" + mod.index_node(indexed) + mod.set_local_graph_index_complete("project-1", True) + + def unexpected_request(*_args, **_kwargs): + raise AssertionError("complete local context must not make an HTTP request") + + mod.request_json = unexpected_request + result = mod.get_memory_context({ + "path": "/notes/local-root", + "depth": 0, + "mode": "hybrid", + }) + + assert result["source"] == "local_index" + assert result["seed_node_ids"] == ["local-root-123"] + assert result["items"][0]["full_path"] == "/notes/local-root" + with mod.index_connect() as conn: + plan = conn.execute( + "EXPLAIN QUERY PLAN SELECT node_id FROM local_index_nodes " + "WHERE project_id = ? AND full_path = ? AND archived = 0", + ("project-1", "/notes/local-root"), + ).fetchall() + assert any("local_index_nodes_project_path" in row["detail"] for row in plan) + + def test_local_helper_ui_forms_submit_identity_for_existing_node_actions(): source = (pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py").read_text() assert "name='node_id' value='{html.escape(item.get('id') or '')}'" in source @@ -643,6 +676,7 @@ def test_recent_memory_defaults_to_paths(mod): assert result["ok"] is True assert result["view"] == "paths" assert result["items"] == [{ + "node_id": "id-recent-title", "full_path": "/notes/recent-title", "type": "note", "status": "active", @@ -671,6 +705,7 @@ def test_recent_memory_accepts_date_aliases_and_returns_dates(mod): mod.request_json = fake_request result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"}) assert result["items"] == [{ + "node_id": "id-recent-title", "full_path": "/notes/recent-title", "type": "note", "status": "active", @@ -699,6 +734,7 @@ def test_find_defaults_to_paths_and_requests_compact_rest_view(mod): assert result["ok"] is True assert result["view"] == "paths" assert result["items"] == [{ + "node_id": "id-found", "full_path": "/plans/found", "type": "plan", "status": "planned", @@ -721,6 +757,8 @@ def test_discover_uses_search_when_keywords_present(mod): assert calls and calls[-1]["q"] == "gateway relay" assert calls[-1]["view"] == "summary" assert "date" in result["awareness"]["date_fields_in_rows"] + assert "item.node_id" in result["awareness"]["next_step"] + assert "outside discovery" in result["awareness"]["next_step"] def test_classify_query_points_exact_paths_to_context(mod): @@ -748,7 +786,11 @@ def test_title_view_requests_summary_rest_view_without_content(mod): result = mod.list_project_nodes({"view": "titles"}) assert result["ok"] is True assert result["view"] == "titles" - assert result["items"] == [{"title": "Readable Title", "full_path": "/notes/titled"}] + assert result["items"] == [{ + "node_id": "id-titled", + "title": "Readable Title", + "full_path": "/notes/titled", + }] assert "content_text" not in result["items"][0] diff --git a/tests/test_mcp_memory_node_encryption.py b/tests/test_mcp_memory_node_encryption.py index ef66241..b5da3f3 100644 --- a/tests/test_mcp_memory_node_encryption.py +++ b/tests/test_mcp_memory_node_encryption.py @@ -296,7 +296,12 @@ def run(): active = mod.list_project_nodes({"folder_path": "/plans", "active_only": True}) assert [item["full_path"] for item in active["items"]] == ["/plans/active"] status_filtered = mod.list_project_nodes({"folder_path": "/plans", "status": ["completed"], "view": "paths"}) - assert status_filtered["items"] == [{"full_path": "/plans/done", "type": "note", "status": "completed"}] + assert status_filtered["items"] == [{ + "node_id": "done", + "full_path": "/plans/done", + "type": "note", + "status": "completed", + }] def search_response(method, path, payload=None, **kwargs): assert "q=" in path diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py index 346d41f..422fb39 100644 --- a/tests/test_mcp_memory_surface_efficiency.py +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -24,6 +24,9 @@ def load_module(): def mod(monkeypatch): monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False) module = load_module() + module._auth_state = tempfile.TemporaryDirectory() + module.AUTH_PATH = pathlib.Path(module._auth_state.name) / "auth.json" + module.AUTH = {"account_id": "acct-1", "token": "token"} module.CONFIGURED_TOOL_PROFILES = None return module diff --git a/tests/test_mcp_search_latency.py b/tests/test_mcp_search_latency.py index 864e80e..a6f5e3a 100644 --- a/tests/test_mcp_search_latency.py +++ b/tests/test_mcp_search_latency.py @@ -74,3 +74,43 @@ def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path elapsed = time.monotonic() - started assert len(calls) == 1 assert elapsed < 0.15 + + +def test_complete_local_search_skips_remote_encryption_preflight_and_search(tmp_path): + mod = load_module() + mod.INDEX_PATH = tmp_path / "index.sqlite" + mod.INDEX_PATH.touch() + mod.resolve_project_id = lambda _args: "project-1" + mod.project_encryption_state = lambda _project_id: "unknown" + items = [ + { + "id": f"node-{index}", + "project_id": "project-1", + "full_path": f"/notes/result-{index}", + "name": f"result-{index}", + "type": "note", + "tags": [], + "updated_at": "2026-08-22T00:00:00Z", + } + for index in range(5) + ] + mod.local_index_diagnostics = lambda _project_id: { + "exists": True, + "graph_complete": True, + } + mod.search_local_index = lambda _args: { + "items": items, + "query": {"terms": ["result"]}, + "search_strategies": [], + "index": {"exists": True, "graph_complete": True}, + } + + def unexpected_request(*_args, **_kwargs): + raise AssertionError("complete local search must not make an HTTP request") + + mod.request_json = unexpected_request + result = mod.search_memory({"q": "result", "limit": 5, "view": "summary"}) + + assert result["sources"] == ["local"] + assert len(result["items"]) == 5 + assert all(item["node_id"].startswith("node-") for item in result["items"])