diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 007ed3c..0b6c473 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -6227,6 +6227,15 @@ def index_connect(): ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS local_index_state ( + project_id TEXT PRIMARY KEY, + graph_complete INTEGER NOT NULL DEFAULT 0, + graph_completed_at TEXT + ) + """ + ) conn.execute( "CREATE INDEX IF NOT EXISTS local_index_edges_source " "ON local_index_edges(project_id, source_node_id)" @@ -6260,6 +6269,52 @@ def index_connect(): return conn +def local_graph_index_state(project_id, conn=None): + def read(connection): + row = connection.execute( + "SELECT graph_complete, graph_completed_at " + "FROM local_index_state WHERE project_id = ?", + (project_id,), + ).fetchone() + return { + "graph_complete": bool(row and row["graph_complete"]), + "graph_completed_at": row["graph_completed_at"] if row else None, + } + + if conn is not None: + return read(conn) + with index_connect() as connection: + return read(connection) + + +def set_local_graph_index_complete(project_id, complete, conn=None): + completed_at = iso_now() if complete else None + + def write(connection): + connection.execute( + """ + INSERT INTO local_index_state ( + project_id, graph_complete, graph_completed_at + ) + VALUES (?, ?, ?) + ON CONFLICT(project_id) DO UPDATE SET + graph_complete = excluded.graph_complete, + graph_completed_at = excluded.graph_completed_at + """, + (project_id, 1 if complete else 0, completed_at), + ) + + if conn is not None: + write(conn) + return + lock = index_file_lock() + try: + with index_connect() as connection: + write(connection) + finally: + release_index_file_lock(lock) + + def flatten_index_values(value): if value is None: return [] @@ -6586,6 +6641,11 @@ def local_index_diagnostics(project_id, conn=None): "sync_recommended": count == 0, } ) + graph_state = local_graph_index_state(project_id, connection) + diagnostics.update(graph_state) + diagnostics["sync_recommended"] = bool( + diagnostics["sync_recommended"] or not graph_state["graph_complete"] + ) return diagnostics if conn is not None: @@ -6682,7 +6742,7 @@ def reindex_project(args): nodes = [] cursor = None while True: - query = {"limit": limit} + query = {"limit": limit, "view": "full"} if cursor: query["cursor"] = cursor page = request_json("GET", f"/v1/projects/{project_id}/nodes?{rest_query_string(query)}") @@ -6693,13 +6753,21 @@ def reindex_project(args): lock = index_file_lock() try: with index_connect() as conn: + set_local_graph_index_complete(project_id, False, conn) conn.execute("DELETE FROM local_index_fts WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM local_index_edges WHERE project_id = ?", (project_id,)) conn.execute("DELETE FROM local_index_nodes WHERE project_id = ?", (project_id,)) finally: release_index_file_lock(lock) for node in nodes: index_node(node) - return {"project_id": project_id, "indexed": len(nodes), "index_path": str(INDEX_PATH)} + set_local_graph_index_complete(project_id, True) + return { + "project_id": project_id, + "indexed": len(nodes), + "index_path": str(INDEX_PATH), + "graph_complete": True, + } def sync_local_index(args): @@ -6709,6 +6777,15 @@ def sync_local_index(args): include_archived = as_bool(args.get("include_archived"), True) lookback_seconds = normalize_int(args.get("lookback_seconds"), 5, 0, 3600) status_before = local_index_diagnostics(project_id) + if not status_before.get("graph_complete"): + result = reindex_project({**args, "project_id": project_id, "limit": limit}) + return { + **result, + "status": "synced", + "mode": "full_reindex", + "reason": "local_graph_index_incomplete", + "index": local_index_diagnostics(project_id), + } latest_node_updated_at = status_before.get("latest_node_updated_at") updated_after = args.get("updated_after") or iso_minus_seconds(latest_node_updated_at, lookback_seconds) nodes = [] @@ -6766,7 +6843,9 @@ def clear_local_index(args): try: with index_connect() as conn: conn.execute("DELETE FROM local_index_fts WHERE project_id = ?", (project_id,)) + conn.execute("DELETE FROM local_index_edges WHERE project_id = ?", (project_id,)) conn.execute("DELETE FROM local_index_nodes WHERE project_id = ?", (project_id,)) + set_local_graph_index_complete(project_id, False, conn) finally: release_index_file_lock(lock) return {"project_id": project_id, "cleared": True} @@ -11000,6 +11079,7 @@ def local_memory_context(args, seed_ids): if direction not in {"outgoing", "incoming", "both"}: raise RuntimeError("direction must be outgoing, incoming, or both") with index_connect() as conn: + graph_state = local_graph_index_state(project_id, conn) def node_row(node_id=None, path=None): if node_id: return conn.execute( @@ -11088,6 +11168,11 @@ def local_memory_context(args, seed_ids): queue = unique_preserve_order(next_ids) if not queue: break + warnings = [] + if not graph_state["graph_complete"]: + warnings.append( + "Local graph index coverage is incomplete; run nap index sync before relying on local-only neighborhoods." + ) return { "ok": True, "seed_node_ids": [row["node_id"] for row in seed_rows], @@ -11099,8 +11184,8 @@ def local_memory_context(args, seed_ids): "truncated": bool(truncation_reasons), "truncation_reasons": truncation_reasons, "source": "local_index", - "freshness": {"indexed_at": indexed_at}, - "warnings": [], + "freshness": {"indexed_at": indexed_at, **graph_state}, + "warnings": warnings, "view": "titles", } @@ -11155,6 +11240,13 @@ def get_memory_context(args): raise RuntimeError("mode must be hybrid, local, or server") if mode in {"local", "hybrid"}: try: + if mode == "hybrid": + project_id = resolve_project_id(args) + graph_state = local_graph_index_state(project_id) + if not graph_state["graph_complete"]: + raise RuntimeError( + "local graph index coverage is incomplete; run nap index sync" + ) return local_memory_context(args, seed_ids) except Exception as exc: if mode == "local": diff --git a/tests/test_mcp_memory_ergonomics_helpers.py b/tests/test_mcp_memory_ergonomics_helpers.py index cbf483e..ebc1416 100644 --- a/tests/test_mcp_memory_ergonomics_helpers.py +++ b/tests/test_mcp_memory_ergonomics_helpers.py @@ -781,7 +781,8 @@ def test_index_status_reports_sync_high_water_mark(mod): status = mod.index_status({}) assert status["count"] == 1 assert status["latest_node_updated_at"] == "2026-05-06T12:00:00Z" - assert status["sync_recommended"] is False + assert status["graph_complete"] is False + assert status["sync_recommended"] is True def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod): @@ -794,6 +795,7 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod): existing["id"] = "existing-node" existing["updated_at"] = "2026-05-06T12:00:00Z" mod.index_node(existing) + mod.set_local_graph_index_complete("project-1", True) fresh = node("/notes/fresh", "active", content="fresh sync needle") fresh["id"] = "fresh-node" @@ -817,6 +819,8 @@ def test_index_sync_fetches_incremental_changes_and_removes_archived_nodes(mod): assert result["seen"] == 2 assert result["indexed"] == 1 assert result["removed"] == 1 + assert result["mode"] == "incremental" + assert result["index"]["graph_complete"] is True assert calls local = mod.search_local_index({"q": "fresh needle"}) assert [item["id"] for item in local["items"]] == ["fresh-node"] diff --git a/tests/test_mcp_memory_surface_efficiency.py b/tests/test_mcp_memory_surface_efficiency.py index dcf03f9..ad9d0a0 100644 --- a/tests/test_mcp_memory_surface_efficiency.py +++ b/tests/test_mcp_memory_surface_efficiency.py @@ -123,6 +123,67 @@ def test_local_context_returns_title_radius_without_node_bodies(mod): assert result["source"] == "local_index" +def test_hybrid_context_falls_back_until_local_graph_coverage_is_complete(mod): + state = tempfile.TemporaryDirectory() + mod._surface_fallback_state = state + mod.AUTH_DIR = pathlib.Path(state.name) + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock" + mod.index_node({ + "id": "root", "project_id": "project-1", "full_path": "/docs/root", + "folder_path": "/docs", "name": "root", "type": "note", + "metadata": {"title": "Root"}, "tags": [], "links": [], + "content_text": "body", "updated_at": "2026-08-16T12:00:00Z", + }) + calls = [] + + def server_context(_args, seed_ids): + calls.append(seed_ids) + return {"ok": True, "nodes": [{"id": "root"}], "edges": [], "source": "server"} + + mod.server_memory_context = server_context + + result = mod.get_memory_context({"node_id": "root", "mode": "hybrid"}) + + assert result["source"] == "server" + assert result["local_fallback"] + assert calls == [["root"]] + + +def test_index_sync_upgrades_pre_graph_index_with_full_reindex(mod): + state = tempfile.TemporaryDirectory() + mod._surface_upgrade_state = state + mod.AUTH_DIR = pathlib.Path(state.name) + mod.INDEX_PATH = mod.AUTH_DIR / "index.sqlite" + mod.INDEX_LOCK_PATH = mod.AUTH_DIR / "index.sqlite.lock" + mod.index_node({ + "id": "old", "project_id": "project-1", "full_path": "/docs/old", + "folder_path": "/docs", "name": "old", "type": "note", + "metadata": {}, "tags": [], "links": [], "content_text": "old", + "updated_at": "2026-08-16T12:00:00Z", + }) + fresh = { + "id": "fresh", "project_id": "project-1", "full_path": "/docs/fresh", + "folder_path": "/docs", "name": "fresh", "type": "note", + "metadata": {}, "tags": [], + "links": [{"node_id": "target", "relation": "references"}], + "content_text": "fresh", "updated_at": "2026-08-16T12:01:00Z", + } + mod.request_json = lambda *_args, **_kwargs: {"items": [fresh], "next_cursor": None} + + result = mod.sync_local_index({}) + + assert result["mode"] == "full_reindex" + assert result["reason"] == "local_graph_index_incomplete" + assert result["graph_complete"] is True + assert result["index"]["graph_complete"] is True + with mod.index_connect() as conn: + assert conn.execute( + "SELECT COUNT(*) FROM local_index_edges WHERE project_id = ?", + ("project-1",), + ).fetchone()[0] == 1 + + def test_discovery_sort_and_returned_item_facets(mod): mod.list_project_nodes = lambda args: { "items": [