refactor: extract kb_framework package, build graph in memory
Scripts become thin CLIs over kb_framework (graph, query, query_tools, vault, instructions) so the hosted read-only connector shares one implementation with the stdio server. The MCP server builds the graph from Markdown on demand and invalidates it on writes; refresh_graph rebuilds in memory and reports validation problems. Pre-commit hook validates only; index/graph.json is no longer read, written or staged (generate-graph.py --write remains for operators). Golden fixtures pin graph and tool payloads to the previous output. Agent and skill docs updated to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+52
-347
@@ -4,7 +4,9 @@
|
||||
This server is the single interface an agent needs to work the vault over stdio
|
||||
with JSON-RPC 2.0. It provides framework operating instructions (read_instructions),
|
||||
and exposes graph and note *querying* (graph_summary,
|
||||
graph_search_nodes, graph_neighbors, read_note, refresh_graph), *writing*
|
||||
graph_search_nodes, graph_neighbors, read_note — all served by the shared
|
||||
`kb_framework` package over a graph built in memory from the vault's Markdown
|
||||
and cached until the next write; refresh_graph rebuilds it), *writing*
|
||||
(write_note for text, capture_raw for verbatim bytes from a local file or an
|
||||
http(s) download) and *publishing* (edit_note, git_commit, git_push), so an
|
||||
agent without shell or filesystem tools can ingest a resource end to end.
|
||||
@@ -27,21 +29,35 @@ 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
|
||||
|
||||
from vault_root import FRAMEWORK_ROOT, parse_vault_args
|
||||
from vault_root import FRAMEWORK_ROOT, parse_vault_args # noqa: E402
|
||||
|
||||
from kb_framework import graph as kb_graph # noqa: E402
|
||||
from kb_framework import query_tools # noqa: E402
|
||||
from kb_framework import vault as kb_vault # noqa: E402
|
||||
|
||||
SERVER_NAME = "knowledge-base-mcp"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass
|
||||
class AppState:
|
||||
"""The selected vault plus the graph built from it, cached until a write."""
|
||||
|
||||
root: Path
|
||||
graph_path: Path
|
||||
graph_cache: Dict[str, Any] | None = None
|
||||
|
||||
def graph(self) -> Dict[str, Any]:
|
||||
if self.graph_cache is None:
|
||||
self.graph_cache = kb_graph.build_graph(self.root).graph
|
||||
return self.graph_cache
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self.graph_cache = None
|
||||
|
||||
|
||||
class _StdinReader:
|
||||
@@ -140,7 +156,6 @@ def _write_message_ndjson(stdout: Any, payload: Dict[str, Any]) -> None:
|
||||
stdout.buffer.flush()
|
||||
|
||||
|
||||
|
||||
def _response(message_id: Any, result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": message_id, "result": result}
|
||||
|
||||
@@ -153,47 +168,9 @@ def _error(message_id: Any, code: int, message: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _load_graph(state: AppState) -> Dict[str, Any]:
|
||||
if not state.graph_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"graph not found at {state.graph_path}. Run scripts/generate-graph.py first."
|
||||
)
|
||||
return json.loads(state.graph_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _nodes_by_id(graph: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
nodes = graph.get("nodes", [])
|
||||
return {str(node.get("id")): node for node in nodes if isinstance(node, dict)}
|
||||
|
||||
|
||||
def _match_filters(node: Dict[str, Any], args: Dict[str, Any]) -> bool:
|
||||
repo = args.get("repo")
|
||||
note_type = args.get("type")
|
||||
status = args.get("status")
|
||||
tag = args.get("tag")
|
||||
query = str(args.get("query", "")).strip().lower()
|
||||
|
||||
if repo and node.get("repo") != repo:
|
||||
return False
|
||||
if note_type and node.get("type") != note_type:
|
||||
return False
|
||||
if status and node.get("status") != status:
|
||||
return False
|
||||
if tag and tag not in (node.get("tags") or []):
|
||||
return False
|
||||
if query:
|
||||
haystacks = [
|
||||
str(node.get("id", "")).lower(),
|
||||
str(node.get("title", "")).lower(),
|
||||
str(node.get("path", "")).lower(),
|
||||
" ".join(str(t).lower() for t in (node.get("tags") or [])),
|
||||
]
|
||||
if not any(query in h for h in haystacks):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
|
||||
# Reserved for operator-written graph dumps (generate-graph.py --write); never a note.
|
||||
_GENERATED_GRAPH_PATH = "index/graph.json"
|
||||
|
||||
# Ceiling for a single capture_raw call; papers and slide decks fit, a video
|
||||
# download that would bloat the vault does not.
|
||||
@@ -202,31 +179,15 @@ _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.
|
||||
|
||||
Rejects empty values, absolute paths and anything escaping the root, so a
|
||||
tool call can never touch a file outside the vault.
|
||||
"""
|
||||
value = str(raw_path or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"'{label}' is required")
|
||||
candidate = Path(value)
|
||||
if candidate.is_absolute():
|
||||
raise ValueError(f"'{label}' must be vault-relative, got absolute '{value}'")
|
||||
root = state.root.resolve()
|
||||
target = (root / candidate).resolve()
|
||||
if target != root and not target.is_relative_to(root):
|
||||
raise ValueError(f"'{label}' resolves outside repository root: '{value}'")
|
||||
return target
|
||||
"""Vault-relative path -> absolute path inside the repo root (never outside)."""
|
||||
return kb_vault.resolve_in_root(state.root, raw_path, label=label)
|
||||
|
||||
|
||||
def _relative_to_root(state: AppState, target: Path) -> str:
|
||||
return target.relative_to(state.root.resolve()).as_posix()
|
||||
return kb_vault.relative_to_root(state.root, target)
|
||||
|
||||
|
||||
def _run_git(state: AppState, args: List[str]) -> subprocess.CompletedProcess:
|
||||
@@ -258,99 +219,15 @@ def _git_result(payload: Dict[str, Any], exit_code: int) -> Dict[str, Any]:
|
||||
|
||||
def _tool_definitions() -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "read_instructions",
|
||||
"description": (
|
||||
"Read framework AGENTS.md and AGENT_QUERY_GUIDE.md before working "
|
||||
"with the vault. These operating documents live outside an "
|
||||
"external vault and do not require a generated graph."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "graph_summary",
|
||||
"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,
|
||||
"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,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "graph_search_nodes",
|
||||
"description": "Search graph nodes by metadata and free text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"tag": {"type": "string"},
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "graph_neighbors",
|
||||
"description": (
|
||||
"Show inbound and outbound links for a node ID. Each neighbor "
|
||||
"includes the edge `type` (relationship, e.g. supersedes, "
|
||||
"depends-on), `anchor_text` (the link's display text), and "
|
||||
"`annotation` (what to expect in that note)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "read_note",
|
||||
"description": (
|
||||
"Read note content by graph node ID or vault-relative path. "
|
||||
"Provide exactly one of `id` or `path`. "
|
||||
"If both or neither are provided, the call returns an error."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Graph node ID (e.g. 'AGENTS')."},
|
||||
"path": {"type": "string", "description": "Vault-relative file path (e.g. 'README.md')."},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
*query_tools.definitions(),
|
||||
{
|
||||
"name": "refresh_graph",
|
||||
"description": "Regenerate graph.json using scripts/generate-graph.py.",
|
||||
"description": (
|
||||
"Rebuild the in-memory graph from the vault's Markdown. With "
|
||||
"validate=true also run the commit gate (unresolved links, "
|
||||
"unknown edge/note types, legacy wiki links) and return an error "
|
||||
"result listing the problems."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -496,187 +373,20 @@ def _text_result(text: str, structured: Dict[str, Any] | None = None) -> Dict[st
|
||||
return result
|
||||
|
||||
|
||||
def _tool_read_instructions() -> Dict[str, Any]:
|
||||
documents = [
|
||||
{"path": name, "content": (FRAMEWORK_ROOT / name).read_text(encoding="utf-8")}
|
||||
for name in ("AGENTS.md", "AGENT_QUERY_GUIDE.md")
|
||||
]
|
||||
payload = {"framework_root": str(FRAMEWORK_ROOT), "documents": documents}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
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", _DEFAULT_TOP_LINKED))
|
||||
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
|
||||
|
||||
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,
|
||||
key=lambda n: int(n.get("inbound_count", 0)),
|
||||
reverse=True,
|
||||
)[:top_n]
|
||||
payload = {
|
||||
"version": graph.get("version"),
|
||||
"generated_at": graph.get("generated_at"),
|
||||
"node_count": len(nodes),
|
||||
"edge_count": len(edges),
|
||||
"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"),
|
||||
"title": node.get("title"),
|
||||
"path": node.get("path"),
|
||||
"inbound_count": node.get("inbound_count", 0),
|
||||
}
|
||||
for node in sorted_nodes
|
||||
],
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_graph_search_nodes(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
graph = _load_graph(state)
|
||||
nodes = graph.get("nodes", [])
|
||||
limit = int(args.get("limit", 30))
|
||||
filtered = [node for node in nodes if _match_filters(node, args)]
|
||||
filtered.sort(key=lambda n: str(n.get("id", "")))
|
||||
sliced = filtered[:limit]
|
||||
payload = {
|
||||
"total_matches": len(filtered),
|
||||
"returned": len(sliced),
|
||||
"items": sliced,
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_graph_neighbors(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
graph = _load_graph(state)
|
||||
node_id = str(args["id"])
|
||||
limit = int(args.get("limit", 50))
|
||||
nodes = _nodes_by_id(graph)
|
||||
if node_id not in nodes:
|
||||
raise ValueError(f"node '{node_id}' not found")
|
||||
|
||||
edges = graph.get("edges", [])
|
||||
outbound_edges = [e for e in edges if e.get("from") == node_id]
|
||||
inbound_edges = [e for e in edges if e.get("to") == node_id]
|
||||
|
||||
def describe(edge_list: List[Dict[str, Any]], direction: str) -> List[Dict[str, Any]]:
|
||||
# neighbor id is the "other end" relative to node_id
|
||||
other_key = "to" if direction == "outbound" else "from"
|
||||
result: List[Dict[str, Any]] = []
|
||||
for edge in edge_list:
|
||||
item_id = str(edge.get(other_key))
|
||||
node = nodes.get(item_id)
|
||||
entry = {
|
||||
"id": node.get("id") if node else item_id,
|
||||
"title": node.get("title") if node else "<missing>",
|
||||
"path": node.get("path") if node else "",
|
||||
"direction": direction,
|
||||
"type": edge.get("type", "related"),
|
||||
"anchor_text": edge.get("anchor_text", ""),
|
||||
"annotation": edge.get("annotation", ""),
|
||||
}
|
||||
result.append(entry)
|
||||
return result[:limit]
|
||||
|
||||
payload = {
|
||||
"node": nodes[node_id],
|
||||
"inbound_count": len(inbound_edges),
|
||||
"outbound_count": len(outbound_edges),
|
||||
"inbound": describe(inbound_edges, "inbound"),
|
||||
"outbound": describe(outbound_edges, "outbound"),
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_read_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
has_id = bool(args.get("id"))
|
||||
has_path = bool(args.get("path"))
|
||||
|
||||
if has_id and has_path:
|
||||
raise ValueError("provide exactly one of 'id' or 'path', not both")
|
||||
if not has_id and not has_path:
|
||||
raise ValueError("exactly one of 'id' or 'path' is required")
|
||||
|
||||
path_value = args.get("path")
|
||||
|
||||
if has_id:
|
||||
nodes = _nodes_by_id(_load_graph(state))
|
||||
node = nodes.get(str(args["id"]))
|
||||
if not node:
|
||||
raise ValueError(f"node '{args['id']}' not found")
|
||||
path_value = node.get("path")
|
||||
|
||||
target = (state.root / str(path_value)).resolve()
|
||||
if not target.is_relative_to(state.root.resolve()):
|
||||
raise ValueError("note path resolves outside repository root")
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"note not found at {target}")
|
||||
|
||||
relative = str(target.relative_to(state.root).as_posix())
|
||||
data = target.read_bytes()
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
payload = {
|
||||
"path": relative,
|
||||
"binary": True,
|
||||
"bytes": len(data),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"hint": (
|
||||
f"{target} is not UTF-8 text (e.g. a PDF or image captured under "
|
||||
"raw/). Open it with a file-reading tool that handles binaries; "
|
||||
"this server returns text only."
|
||||
),
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
payload = {
|
||||
"path": relative,
|
||||
"binary": False,
|
||||
"content": text,
|
||||
}
|
||||
return _text_result(json.dumps(payload, indent=2), structured=payload)
|
||||
|
||||
|
||||
def _tool_refresh_graph(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
command = [
|
||||
sys.executable, str(FRAMEWORK_ROOT / "scripts/generate-graph.py"),
|
||||
"--vault-root", str(state.root), "--write",
|
||||
]
|
||||
if bool(args.get("validate", False)):
|
||||
command.append("--validate")
|
||||
|
||||
proc = subprocess.run(
|
||||
command,
|
||||
cwd=state.root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
"""Rebuild the in-memory graph; with validate=true also apply the commit gate."""
|
||||
build = kb_graph.build_graph(state.root)
|
||||
state.graph_cache = build.graph
|
||||
problems = kb_graph.validation_problems(build) if bool(args.get("validate", False)) else []
|
||||
payload = {
|
||||
"command": command,
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": proc.stdout,
|
||||
"stderr": proc.stderr,
|
||||
"vault_root": str(state.root),
|
||||
"generated_at": build.graph.get("generated_at"),
|
||||
"node_count": len(build.graph.get("nodes", [])),
|
||||
"edge_count": len(build.graph.get("edges", [])),
|
||||
"validated": bool(args.get("validate", False)),
|
||||
"problems": problems,
|
||||
}
|
||||
if proc.returncode != 0:
|
||||
if problems:
|
||||
return {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": json.dumps(payload, indent=2)}],
|
||||
@@ -691,7 +401,7 @@ def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
content = str(args["content"])
|
||||
target = _resolve_in_root(state, args.get("path"))
|
||||
relative = _relative_to_root(state, target)
|
||||
if relative == state.graph_path.relative_to(state.root.resolve()).as_posix():
|
||||
if relative == _GENERATED_GRAPH_PATH:
|
||||
raise ValueError(
|
||||
"index/graph.json is generated \u2014 use refresh_graph instead of writing it"
|
||||
)
|
||||
@@ -706,6 +416,7 @@ def _tool_write_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
state.invalidate()
|
||||
payload = {
|
||||
"path": relative,
|
||||
"created": not existed,
|
||||
@@ -742,6 +453,7 @@ def _tool_edit_note(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
updated = text.replace(old_string, new_string)
|
||||
target.write_text(updated, encoding="utf-8")
|
||||
state.invalidate()
|
||||
payload = {
|
||||
"path": relative,
|
||||
"replacements": occurrences if replace_all else 1,
|
||||
@@ -818,6 +530,7 @@ def _tool_capture_raw(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
source_label = url
|
||||
state.invalidate()
|
||||
|
||||
payload = {
|
||||
"path": relative,
|
||||
@@ -918,16 +631,8 @@ def _tool_git_push(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _call_tool(state: AppState, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if name == "read_instructions":
|
||||
return _tool_read_instructions()
|
||||
if name == "graph_summary":
|
||||
return _tool_graph_summary(state, arguments)
|
||||
if name == "graph_search_nodes":
|
||||
return _tool_graph_search_nodes(state, arguments)
|
||||
if name == "graph_neighbors":
|
||||
return _tool_graph_neighbors(state, arguments)
|
||||
if name == "read_note":
|
||||
return _tool_read_note(state, arguments)
|
||||
if name in query_tools.READ_ONLY_TOOL_NAMES:
|
||||
return query_tools.call(name, arguments, graph=state.graph(), root=state.root)
|
||||
if name == "refresh_graph":
|
||||
return _tool_refresh_graph(state, arguments)
|
||||
if name == "write_note":
|
||||
@@ -949,7 +654,7 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
|
||||
{
|
||||
"uri": "repo://graph",
|
||||
"name": "Repository Graph",
|
||||
"description": "Raw index/graph.json",
|
||||
"description": "The graph built in memory from the vault's Markdown",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
@@ -970,7 +675,7 @@ def _list_resources(state: AppState) -> Dict[str, Any]:
|
||||
|
||||
def _read_resource(state: AppState, uri: str) -> Dict[str, Any]:
|
||||
if uri == "repo://graph":
|
||||
text = state.graph_path.read_text(encoding="utf-8")
|
||||
text = json.dumps(state.graph(), indent=2)
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
@@ -1038,7 +743,7 @@ def _handle_request(state: AppState, message: Dict[str, Any]) -> Dict[str, Any]
|
||||
def main() -> None:
|
||||
args = parse_vault_args(argparse.ArgumentParser(description=__doc__))
|
||||
root = args.vault_root
|
||||
state = AppState(root=root, graph_path=root / "index" / "graph.json")
|
||||
state = AppState(root=root)
|
||||
|
||||
reader = _StdinReader(sys.stdin.buffer)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user