feat: mirror tag-vocabulary summary and note-type validation

Ports two changes from the knowledge-base vault this skeleton is extracted
from, keeping the shared scripts byte-identical across the pair:

- `graph_summary` reports `by_tag` and `tag_count`, sized by `topTags`, and
  drops the `by_type`/`by_status` breakdowns. An MCP-only agent could not
  otherwise discover the tag spellings that `graph_search_nodes` matches
  verbatim.
- `generate-graph.py --validate` now enforces a controlled vocabulary for a
  note's `type`, which is where the drift signal `by_type` used to give now
  lives. `AGENT_QUERY_GUIDE.md`, both skills and the-knowing-one quote the
  enforced enum.

`tests/test_generate_graph.py` is new; `make test` is 50 tests. Regenerating
the graph also drops a stale `GLOBAL_AGENTS` node for a file this skeleton
does not carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Manuel Ganter
2026-09-14 11:37:02 +02:00
co-authored by Claude Opus 5
parent 638cbe6b39
commit 1cb6369282
10 changed files with 316 additions and 52 deletions
+41 -19
View File
@@ -25,6 +25,7 @@ import subprocess
import sys
import urllib.error
import urllib.request
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List
@@ -196,6 +197,11 @@ _WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
_DOWNLOAD_TIMEOUT_SECONDS = 60
# graph_summary defaults: enough most-linked notes to spot the hubs, and enough
# tags to cover the vocabulary an agent actually filters on.
_DEFAULT_TOP_LINKED = 10
_DEFAULT_TOP_TAGS = 50
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
"""Turn a vault-relative path into an absolute path inside the repo root.
@@ -245,12 +251,31 @@ def _tool_definitions() -> List[Dict[str, Any]]:
return [
{
"name": "graph_summary",
"description": "Return aggregate counts from index/graph.json.",
"description": (
"Orientation for a cold start: node and edge counts, the repo "
"scopes, the most-linked notes, and the tag vocabulary as "
"`by_tag` (most frequent first) plus `tag_count`, the number of "
"distinct tags. Read `by_tag` before filtering "
"`graph_search_nodes` by `tag` \u2014 that filter matches a tag "
"verbatim, so a guessed spelling returns nothing."
),
"inputSchema": {
"type": "object",
"properties": {
"topLinked": {"type": "integer", "minimum": 1, "maximum": 50}
"topLinked": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "Most-linked notes to return (default 10).",
},
"topTags": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Tags to return, most frequent first (default 50).",
},
},
"additionalProperties": False,
},
},
{
@@ -453,21 +478,16 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
graph = _load_graph(state)
nodes = graph.get("nodes", [])
edges = graph.get("edges", [])
top_n = int(args.get("topLinked", 10))
top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
by_repo: Dict[str, int] = {}
by_type: Dict[str, int] = {}
by_status: Dict[str, int] = {}
for node in nodes:
by_repo[str(node.get("repo", "unknown"))] = (
by_repo.get(str(node.get("repo", "unknown")), 0) + 1
)
by_type[str(node.get("type", "unknown"))] = (
by_type.get(str(node.get("type", "unknown")), 0) + 1
)
by_status[str(node.get("status", "unknown"))] = (
by_status.get(str(node.get("status", "unknown")), 0) + 1
)
by_repo: Counter[str] = Counter(str(node.get("repo", "unknown")) for node in nodes)
tag_counts: Counter[str] = Counter(
str(tag) for node in nodes for tag in (node.get("tags") or [])
)
# Frequent tags first, alphabetical within a tie, so a truncated list is
# both the most useful slice and stable across calls.
ranked_tags = sorted(tag_counts.items(), key=lambda item: (-item[1], item[0]))
sorted_nodes = sorted(
nodes,
@@ -479,9 +499,11 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
"generated_at": graph.get("generated_at"),
"node_count": len(nodes),
"edge_count": len(edges),
"by_repo": by_repo,
"by_type": by_type,
"by_status": by_status,
"by_repo": dict(by_repo),
"tag_count": len(tag_counts),
"by_tag": [
{"tag": tag, "count": count} for tag, count in ranked_tags[:top_tags]
],
"top_linked": [
{
"id": node.get("id"),