perf(mcp): keep local read flows local
This commit is contained in:
@@ -418,6 +418,7 @@ PUBLIC_AUTH_KEYS = {
|
|||||||
"project_id",
|
"project_id",
|
||||||
"project_slug",
|
"project_slug",
|
||||||
"project_name",
|
"project_name",
|
||||||
|
"project_encryption_state",
|
||||||
"project_signing_key_fingerprint",
|
"project_signing_key_fingerprint",
|
||||||
"worker_id",
|
"worker_id",
|
||||||
"agent_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_id": os.environ.get("NAPSEER_PROJECT_ID") or merged.get("project_id"),
|
||||||
"project_slug": merged.get("project_slug"),
|
"project_slug": merged.get("project_slug"),
|
||||||
"project_name": merged.get("project_name"),
|
"project_name": merged.get("project_name"),
|
||||||
|
"project_encryption_state": merged.get("project_encryption_state"),
|
||||||
"project_signing_key_fingerprint": merged.get("project_signing_key_fingerprint"),
|
"project_signing_key_fingerprint": merged.get("project_signing_key_fingerprint"),
|
||||||
"worker_id": merged.get("worker_id"),
|
"worker_id": merged.get("worker_id"),
|
||||||
"agent_id": merged.get("agent_id"),
|
"agent_id": merged.get("agent_id"),
|
||||||
@@ -5831,18 +5833,21 @@ def metadata_summary(node):
|
|||||||
|
|
||||||
|
|
||||||
def summarize_node(node, view="summary", preview_chars=240):
|
def summarize_node(node, view="summary", preview_chars=240):
|
||||||
|
node_id = node.get("node_id") or node.get("id")
|
||||||
if view == "full":
|
if view == "full":
|
||||||
return strip_node_content(node, include_content=True)
|
return strip_node_content(node, include_content=True)
|
||||||
metadata = node_metadata(node)
|
metadata = node_metadata(node)
|
||||||
status = node_status(node)
|
status = node_status(node)
|
||||||
if view == "titles":
|
if view == "titles":
|
||||||
item = {
|
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,
|
"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"),
|
"full_path": node.get("full_path"),
|
||||||
}
|
}
|
||||||
return {key: value for key, value in item.items() if value not in (None, "")}
|
return {key: value for key, value in item.items() if value not in (None, "")}
|
||||||
if view == "paths":
|
if view == "paths":
|
||||||
item = {
|
item = {
|
||||||
|
"node_id": node_id,
|
||||||
"full_path": node.get("full_path"),
|
"full_path": node.get("full_path"),
|
||||||
"type": node.get("type"),
|
"type": node.get("type"),
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -5856,6 +5861,7 @@ def summarize_node(node, view="summary", preview_chars=240):
|
|||||||
|
|
||||||
item = {
|
item = {
|
||||||
"id": node.get("id"),
|
"id": node.get("id"),
|
||||||
|
"node_id": node_id,
|
||||||
"project_id": node.get("project_id"),
|
"project_id": node.get("project_id"),
|
||||||
"full_path": node.get("full_path"),
|
"full_path": node.get("full_path"),
|
||||||
"type": node.get("type"),
|
"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", "title", "TEXT")
|
||||||
ensure_sqlite_column(conn, "local_index_nodes", "status", "TEXT")
|
ensure_sqlite_column(conn, "local_index_nodes", "status", "TEXT")
|
||||||
ensure_sqlite_column(conn, "local_index_nodes", "archived", "INTEGER NOT NULL DEFAULT 0")
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS local_index_edges (
|
CREATE TABLE IF NOT EXISTS local_index_edges (
|
||||||
@@ -6296,6 +6306,27 @@ def local_graph_index_state(project_id, conn=None):
|
|||||||
return read(connection)
|
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):
|
def set_local_graph_index_complete(project_id, complete, conn=None):
|
||||||
completed_at = iso_now() if complete else None
|
completed_at = iso_now() if complete else None
|
||||||
|
|
||||||
@@ -8000,6 +8031,7 @@ def create_project_with_state(args):
|
|||||||
"project_id": project["id"],
|
"project_id": project["id"],
|
||||||
"project_slug": project["slug"],
|
"project_slug": project["slug"],
|
||||||
"project_name": project["name"],
|
"project_name": project["name"],
|
||||||
|
"project_encryption_state": project.get("encryption_state") or "standard",
|
||||||
}
|
}
|
||||||
if AUTH.get("account_mode") == "operator_account":
|
if AUTH.get("account_mode") == "operator_account":
|
||||||
auth_updates["account_mode"] = "operator_project"
|
auth_updates["account_mode"] = "operator_project"
|
||||||
@@ -11199,10 +11231,10 @@ def start_project_heartbeat_thread():
|
|||||||
|
|
||||||
|
|
||||||
def project_encryption_state(project_id):
|
def project_encryption_state(project_id):
|
||||||
try:
|
if str(project_id or "") != str(current_project_id() or ""):
|
||||||
return request_json("GET", f"/v1/projects/{project_id}/encryption/status").get("state") or "standard"
|
return "unknown"
|
||||||
except Exception:
|
state = str(AUTH.get("project_encryption_state") or "").strip().lower()
|
||||||
return "plaintext"
|
return state if state in {"standard", "plaintext", "encrypted", "e2e"} else "unknown"
|
||||||
|
|
||||||
|
|
||||||
def strip_node_content(node, include_content=False):
|
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()
|
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")
|
||||||
|
# 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)
|
encryption_state = project_encryption_state(project_id)
|
||||||
collected = []
|
collected = []
|
||||||
sources = []
|
sources = []
|
||||||
@@ -11274,7 +11309,12 @@ def search_memory(args):
|
|||||||
elif mode in {"hybrid", "local"}:
|
elif mode in {"hybrid", "local"}:
|
||||||
diagnostics["local_index"]["skipped_reason"] = "index_missing"
|
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
|
# The backend already analyzes the raw query into important terms and
|
||||||
# ranks matches. Expanding query variants here (and again inside
|
# ranks matches. Expanding query variants here (and again inside
|
||||||
# list_project_nodes) multiplied one discovery into many serial HTTP
|
# list_project_nodes) multiplied one discovery into many serial HTTP
|
||||||
@@ -11376,7 +11416,7 @@ def discover_memory(args):
|
|||||||
result["awareness"] = {
|
result["awareness"] = {
|
||||||
"default_sort": "relevance" if q else "updated_desc",
|
"default_sort": "relevance" if q else "updated_desc",
|
||||||
"date_fields_in_rows": ["date", "created_at", "updated_at"],
|
"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
|
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_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 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_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_by_path", "when": "You only have an external path with no node_id and need canonical identity."},
|
||||||
{"tool": "nap_node_get", "when": "You have node_id or nap:// URI and need the full body."},
|
{"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()]
|
node_ids = [str(value).strip() for value in (args.get("node_ids") or []) if str(value).strip()]
|
||||||
if args.get("node_id"):
|
if args.get("node_id"):
|
||||||
node_ids.append(str(args["node_id"]).strip())
|
node_ids.append(str(args["node_id"]).strip())
|
||||||
@@ -11434,8 +11474,24 @@ def context_seed_ids(args):
|
|||||||
paths = list(args.get("paths") or [])
|
paths = list(args.get("paths") or [])
|
||||||
if args.get("path"):
|
if args.get("path"):
|
||||||
paths.append(args["path"])
|
paths.append(args["path"])
|
||||||
for path in paths:
|
normalized_paths = unique_preserve_order(normalize_node_path(path) for path in paths)
|
||||||
resolved = node_by_path({"path": normalize_node_path(path)})
|
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")
|
node_id = (resolved.get("identity") or {}).get("node_id")
|
||||||
if node_id:
|
if node_id:
|
||||||
node_ids.append(node_id)
|
node_ids.append(node_id)
|
||||||
@@ -11633,10 +11689,10 @@ def server_memory_context(args, seed_ids):
|
|||||||
|
|
||||||
|
|
||||||
def get_memory_context(args):
|
def get_memory_context(args):
|
||||||
seed_ids = context_seed_ids(args)
|
|
||||||
mode = str(args.get("mode") or "hybrid").strip().lower()
|
mode = str(args.get("mode") or "hybrid").strip().lower()
|
||||||
if mode not in {"hybrid", "local", "server"}:
|
if mode not in {"hybrid", "local", "server"}:
|
||||||
raise RuntimeError("mode must be hybrid, local, or server")
|
raise RuntimeError("mode must be hybrid, local, or server")
|
||||||
|
seed_ids = context_seed_ids(args, mode=mode)
|
||||||
if mode in {"local", "hybrid"}:
|
if mode in {"local", "hybrid"}:
|
||||||
try:
|
try:
|
||||||
if mode == "hybrid":
|
if mode == "hybrid":
|
||||||
@@ -13291,7 +13347,7 @@ def raw_tools():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nap_discover",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13320,7 +13376,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; 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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13807,7 +13863,7 @@ def raw_tools():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nap_node_get",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13846,7 +13902,7 @@ def raw_tools():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nap_node_by_path",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["path"],
|
"required": ["path"],
|
||||||
|
|||||||
@@ -591,6 +591,39 @@ def test_context_and_related_use_identity_inputs(mod):
|
|||||||
mod.find_related_nodes({"path": "/notes/root"})
|
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():
|
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()
|
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
|
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["ok"] is True
|
||||||
assert result["view"] == "paths"
|
assert result["view"] == "paths"
|
||||||
assert result["items"] == [{
|
assert result["items"] == [{
|
||||||
|
"node_id": "id-recent-title",
|
||||||
"full_path": "/notes/recent-title",
|
"full_path": "/notes/recent-title",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
@@ -671,6 +705,7 @@ def test_recent_memory_accepts_date_aliases_and_returns_dates(mod):
|
|||||||
mod.request_json = fake_request
|
mod.request_json = fake_request
|
||||||
result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"})
|
result = mod.recent_memory({"since": "2026-05-05", "until": "2026-05-06"})
|
||||||
assert result["items"] == [{
|
assert result["items"] == [{
|
||||||
|
"node_id": "id-recent-title",
|
||||||
"full_path": "/notes/recent-title",
|
"full_path": "/notes/recent-title",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"status": "active",
|
"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["ok"] is True
|
||||||
assert result["view"] == "paths"
|
assert result["view"] == "paths"
|
||||||
assert result["items"] == [{
|
assert result["items"] == [{
|
||||||
|
"node_id": "id-found",
|
||||||
"full_path": "/plans/found",
|
"full_path": "/plans/found",
|
||||||
"type": "plan",
|
"type": "plan",
|
||||||
"status": "planned",
|
"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 and calls[-1]["q"] == "gateway relay"
|
||||||
assert calls[-1]["view"] == "summary"
|
assert calls[-1]["view"] == "summary"
|
||||||
assert "date" in result["awareness"]["date_fields_in_rows"]
|
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):
|
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"})
|
result = mod.list_project_nodes({"view": "titles"})
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["view"] == "titles"
|
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]
|
assert "content_text" not in result["items"][0]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,12 @@ def run():
|
|||||||
active = mod.list_project_nodes({"folder_path": "/plans", "active_only": True})
|
active = mod.list_project_nodes({"folder_path": "/plans", "active_only": True})
|
||||||
assert [item["full_path"] for item in active["items"]] == ["/plans/active"]
|
assert [item["full_path"] for item in active["items"]] == ["/plans/active"]
|
||||||
status_filtered = mod.list_project_nodes({"folder_path": "/plans", "status": ["completed"], "view": "paths"})
|
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):
|
def search_response(method, path, payload=None, **kwargs):
|
||||||
assert "q=" in path
|
assert "q=" in path
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ def load_module():
|
|||||||
def mod(monkeypatch):
|
def mod(monkeypatch):
|
||||||
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
|
monkeypatch.delenv("NAPSEER_TOOL_PROFILES", raising=False)
|
||||||
module = load_module()
|
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
|
module.CONFIGURED_TOOL_PROFILES = None
|
||||||
return module
|
return module
|
||||||
|
|
||||||
|
|||||||
@@ -74,3 +74,43 @@ def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path
|
|||||||
elapsed = time.monotonic() - started
|
elapsed = time.monotonic() - started
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
assert elapsed < 0.15
|
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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user