"""Shared helpers for knowtation MCP tools and resources.""" from __future__ import annotations import json import pathlib import urllib.error import urllib.parse import urllib.request from datetime import datetime, timezone from typing import Any from muse.core.object_store import read_object from muse.core.repo import read_repo_id from muse.core.store import ( get_commit_snapshot_manifest, read_commit, read_current_branch, resolve_commit_ref, ) from muse.core.validation import clamp_int, sanitize_display from muse.plugins.code._query import walk_commits_bfs from muse.plugins.knowtation._query import NOTE_EXTENSIONS, group_by_project, is_note from muse.plugins.knowtation.code_intel import describe_link from muse.plugins.knowtation.events import MemoryEventKind, is_valid_event_kind from muse.plugins.knowtation.hooks import DEFAULT_PORT, _resolve_port from muse.plugins.knowtation.link_index import LinkIndex, _normalise_rel_path, build_link_index from muse.plugins.knowtation.parser import parse_frontmatter from muse.plugins.knowtation.plugin import _ALWAYS_IGNORE_DIRS from muse.plugins.knowtation.prime_context import build_prime_context from muse.plugins.knowtation.stats import collect_vault_stats from muse.plugins.knowtation.symbols import extract_symbols _MAX_HUB_RESPONSE_BYTES = 256 * 1024 _STRUCTURAL_DIRS = frozenset({"templates", "meta"}) _IGNORE_DIRS = _ALWAYS_IGNORE_DIRS | _STRUCTURAL_DIRS def _note_body_and_frontmatter(raw: bytes) -> tuple[dict[str, Any], str]: """Return ``(frontmatter_dict, body_text)`` from raw note bytes.""" text = raw.decode("utf-8", errors="replace") parsed = parse_frontmatter(raw) if parsed is None: return {}, text if text.startswith("---"): parts = text.split("---", 2) body = parts[2].lstrip("\n") if len(parts) >= 3 else text else: body = text return parsed.to_dict(), body class PathValidationError(ValueError): """Raised when a vault-relative path fails grammar checks.""" def __init__(self, reason: str, detail: str | None = None) -> None: self.reason = reason super().__init__(detail or reason) def validate_note_path(path: str) -> str: """Validate vault-relative note path grammar (§KD-5a.0).""" if not path or not isinstance(path, str): raise PathValidationError("PATH_INVALID", "path must be a non-empty string") if path.startswith("/") or "\\" in path: raise PathValidationError("PATH_INVALID", "absolute paths are not allowed") if ".." in path.split("/"): raise PathValidationError("PATH_INVALID", "path must not contain '..' segments") normalised = _normalise_rel_path("", path.replace("\\", "/")) if normalised is None: raise PathValidationError("PATH_INVALID", "path escapes vault root") lower = normalised.lower() if not any(lower.endswith(ext) for ext in NOTE_EXTENSIONS): raise PathValidationError("NOT_A_NOTE", "path is not a markdown note") return normalised def resolve_ref(root: pathlib.Path, ref: str | None) -> Any: """Resolve a commit ref via the store layer.""" repo_id = read_repo_id(root) branch = read_current_branch(root) commit = resolve_commit_ref(root, repo_id, branch, ref if ref and ref != "HEAD" else None) if commit is None: raise PathValidationError("REF_NOT_FOUND", f"ref not found: {ref or 'HEAD'}") return commit def read_note_at_ref( root: pathlib.Path, path: str, ref: str = "HEAD", *, fmt: str = "json", ) -> dict[str, Any]: """Read a note at *ref* — backing for get-note and vault resources.""" normalised = validate_note_path(path) commit = resolve_ref(root, ref) manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} content_hash = manifest.get(normalised) if not content_hash: return { "path": normalised, "ref": ref or "HEAD", "exists": False, "frontmatter": {}, "body": "", "symbols": [], "content_hash": "", } raw = read_object(root, content_hash) if raw is None: raise PathValidationError("NOTE_NOT_FOUND", "note object missing from store") parsed_fm, body_text = _note_body_and_frontmatter(raw) symbols = [s.to_dict() for s in extract_symbols(normalised, raw)] payload = { "path": normalised, "ref": ref or "HEAD", "exists": True, "frontmatter": parsed_fm, "body": body_text if fmt == "json" else raw.decode("utf-8", errors="replace"), "symbols": symbols, "content_hash": content_hash, } return payload def link_index_to_json(index: LinkIndex) -> dict[str, Any]: """Serialise a :class:`LinkIndex` for ``knowtation://graph``.""" forward: dict[str, list[dict[str, Any]]] = {} for src, links in index.forward.items(): forward[src] = [describe_link(link) for link in links] backward: dict[str, list[dict[str, Any]]] = {} for tgt, links in index.backward.items(): backward[tgt] = [describe_link(link) for link in links] entities = {label: sorted(paths) for label, paths in index.entity_index.items()} return { "notes_indexed": index.notes_indexed, "forward": forward, "backward": backward, "broken_links": [describe_link(link) for link in index.broken_links], "entities": entities, } def list_vault_notes(root: pathlib.Path) -> dict[str, Any]: """Build ``knowtation://vault`` listing payload.""" stats = collect_vault_stats(root) notes: list[dict[str, Any]] = [] for dirpath, dirnames, filenames in __import__("os").walk(root): dirnames[:] = [d for d in dirnames if d not in _IGNORE_DIRS] for name in filenames: if not is_note(name): continue full = pathlib.Path(dirpath) / name try: rel = full.relative_to(root).as_posix() except ValueError: continue try: raw = full.read_bytes() fm_dict, _body = _note_body_and_frontmatter(raw) except OSError: continue notes.append( { "path": rel, "title": str(fm_dict.get("title") or pathlib.Path(rel).stem), "project": fm_dict.get("project"), "tags": fm_dict.get("tags"), "source_type": fm_dict.get("source_type") or fm_dict.get("source"), } ) notes.sort(key=lambda n: n["path"]) return {"notes": notes, "stats": stats} def recent_memory_events(root: pathlib.Path, *, limit: int = 20) -> dict[str, Any]: """Last *limit* commits carrying ``metadata.event_type``.""" branch = read_current_branch(root) head = resolve_ref(root, "HEAD") commits, _ = walk_commits_bfs(root, head.commit_id, 500) events: list[dict[str, Any]] = [] for commit in commits: event_type = commit.metadata.get("event_type") if not event_type: continue if not is_valid_event_kind(str(event_type)): continue events.append( { "commit_id": commit.commit_id, "committed_at": commit.committed_at.isoformat(), "message": sanitize_display(commit.message), "event_type": str(event_type), "agent_id": sanitize_display(commit.agent_id or "") or None, "model_id": sanitize_display(commit.model_id or "") or None, } ) if len(events) >= limit: break return {"events": events, "count": len(events)} def projects_summary(root: pathlib.Path) -> dict[str, Any]: """Aggregate project note counts + last modified mtimes.""" head = resolve_ref(root, "HEAD") manifest = get_commit_snapshot_manifest(root, head.commit_id) or {} groups = group_by_project(manifest) stats = collect_vault_stats(root) projects: list[dict[str, Any]] = [] for project in stats.get("projects") or sorted(groups): paths = groups.get(project, []) last_mod = 0.0 for p in paths: try: last_mod = max(last_mod, (root / p).stat().st_mtime) except OSError: continue lm_iso = ( datetime.fromtimestamp(last_mod, tz=timezone.utc).isoformat() if last_mod else None ) projects.append( { "project": project, "note_count": len(paths) if paths else 0, "last_modified": lm_iso, } ) return {"projects": projects} def enrich_prime( root: pathlib.Path, *, max_commits: int = 100, hot_note_count: int = 10, ) -> dict[str, Any]: """``build_prime_context`` plus ``active_projects`` / ``suggested_next``.""" ctx = build_prime_context(root, max_commits=max_commits, hot_note_count=hot_note_count) payload = ctx.to_dict() stats = collect_vault_stats(root) payload["active_projects"] = list(stats.get("projects") or []) suggested: list[str] = [] for hot in payload.get("hot_notes") or []: path = hot.get("path") if isinstance(hot, dict) else None if isinstance(path, str): suggested.append(path) payload["suggested_next"] = suggested[:5] return payload def create_hub_proposal( root: pathlib.Path, *, path: str, title: str, proposed_body: str | None = None, proposed_frontmatter: dict[str, Any] | None = None, base_ref: str = "HEAD", scope: str = "personal", rationale: str | None = None, ) -> dict[str, Any]: """POST a proposal to the local Knowtation hub (§KD-5a.B tool 8).""" normalised = validate_note_path(path) port, port_err = _resolve_port() if port is None: raise PathValidationError("HUB_UNREACHABLE", port_err or "invalid hub port") note = read_note_at_ref(root, normalised, base_ref) if not note.get("exists"): raise PathValidationError("NOTE_NOT_FOUND", "note not found at base_ref") intent_parts = [title.strip()] if rationale: intent_parts.append(rationale.strip()) body_payload: dict[str, Any] = { "path": normalised, "body": proposed_body if proposed_body is not None else note.get("body", ""), "frontmatter": proposed_frontmatter if proposed_frontmatter is not None else note.get("frontmatter", {}), "intent": "\n\n".join(intent_parts), "base_state_id": note.get("content_hash") or "", "labels": [f"scope:{scope}"], "source": "muse-mcp", } data = json.dumps(body_payload).encode("utf-8") url = f"http://localhost:{port}/api/v1/proposals" request = urllib.request.Request( url=url, data=data, method="POST", headers={ "Content-Type": "application/json", "User-Agent": "muse-mcp-propose/1.0", "Content-Length": str(len(data)), }, ) try: with urllib.request.urlopen(request, timeout=10.0) as resp: # noqa: S310 raw = resp.read(_MAX_HUB_RESPONSE_BYTES + 1) if len(raw) > _MAX_HUB_RESPONSE_BYTES: raise PathValidationError("HUB_UNREACHABLE", "hub response too large") result = json.loads(raw.decode("utf-8")) except urllib.error.HTTPError as exc: if exc.code in (401, 403): raise PathValidationError("SCOPE_UNAUTHORIZED", "hub rejected scope") from exc if exc.code == 400: raise PathValidationError("VALIDATION_FAILED", "hub validation failed") from exc raise PathValidationError("HUB_UNREACHABLE", "hub request failed") from exc except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: raise PathValidationError("HUB_UNREACHABLE", "hub unreachable") from exc proposal_id = str(result.get("id") or result.get("proposal_id") or result.get("proposalId") or "") if not proposal_id: raise PathValidationError("VALIDATION_FAILED", "hub returned no proposal id") return { "proposal_id": proposal_id, "status": "created", "hub_url": f"/proposals/{proposal_id}", "path": normalised, "base_ref": base_ref, "scope": scope, } def consolidate_receipt(root: pathlib.Path, **_kwargs: Any) -> dict[str, Any]: """Dispatch consolidation hook and return MCP receipt (§KD-5a.A(b)).""" from muse.plugins.knowtation.hooks import KnowtationMergeHook hook = KnowtationMergeHook() result = hook.pre_merge_hook( root, ours_commit_id="", theirs_commit_id="", branch="", consolidate=True, ) port, _ = _resolve_port() return { "hook_name": result.hook_name, "fired": result.fired, "out_of_band": result.out_of_band, "message": sanitize_display(result.message or ""), "error": sanitize_display(result.error) if result.error else None, "dispatched_at": datetime.now(tz=timezone.utc).isoformat(), "hub_port": port if port is not None else DEFAULT_PORT, } def clamp_tool_int(value: Any, default: int, lo: int, hi: int, name: str) -> int: try: n = int(value) except (TypeError, ValueError): n = default return clamp_int(n, lo, hi, name) __all__ = [ "PathValidationError", "clamp_tool_int", "consolidate_receipt", "create_hub_proposal", "enrich_prime", "link_index_to_json", "list_vault_notes", "projects_summary", "read_note_at_ref", "recent_memory_events", "resolve_ref", "validate_note_path", ]