"""Format MCP diagnostic output for reports, Obsidian, and UI.""" from __future__ import annotations import json import re from typing import Any MAX_REPORT_OUTPUT_PER_TOOL = 4_000 MAX_OBSIDIAN_DIAG_CHARS = 120_000 MAX_EVENT_OUTPUT = 12_000 _TABLE_TOOLS = frozenset( { "pfsense_list_firewall_rules", "pfsense_list_port_forwards", "pfsense_list_outbound_nat_mappings", "pfsense_list_aliases", "pfsense_list_interfaces", "proxmox_list_qemu", "proxmox_list_lxc", "proxmox_get_qemu_status", "proxmox_get_qemu_config", } ) def _clip(text: str, limit: int) -> str: if len(text) <= limit: return text return text[: limit - 40] + f"\n\n… ({len(text) - limit + 40} chars truncated)" def _parse_json_payload(text: str) -> Any | None: text = (text or "").strip() if not text: return None try: return json.loads(text) except json.JSONDecodeError: pass start, end = text.find("{"), text.rfind("}") if start != -1 and end > start: try: return json.loads(text[start : end + 1]) except json.JSONDecodeError: return None return None def _cell(val: Any) -> str: if val is None: return "" if isinstance(val, list): return ", ".join(str(x) for x in val) s = str(val) s = re.sub(r"&+", "&", s) return s.replace("|", "\\|").replace("\n", " ") def _firewall_rules_table(rows: list[dict]) -> str: header = "| # | Action | Interface | Protocol | Source | Destination | Description |" sep = "|---|---|---|---|---|---|---|" lines = [header, sep] for i, row in enumerate(rows[:300], start=1): lines.append( "| " + " | ".join( [ str(i), _cell(row.get("type")), _cell(row.get("interface")), _cell(row.get("protocol")), _cell(row.get("source")), _cell(row.get("destination")), _cell(row.get("descr")), ] ) + " |" ) if len(rows) > 300: lines.append(f"\n_Showing 300 of {len(rows)} rules._") return "\n".join(lines) def _port_forwards_table(rows: list[dict]) -> str: header = "| # | Interface | Protocol | Destination | Target | Local port | Description |" sep = "|---|---|---|---|---|---|---|" lines = [header, sep] for i, row in enumerate(rows[:200], start=1): lines.append( "| " + " | ".join( [ str(i), _cell(row.get("interface")), _cell(row.get("protocol")), _cell(row.get("destination")), _cell(row.get("target")), _cell(row.get("local_port") or row.get("destination_port")), _cell(row.get("descr")), ] ) + " |" ) return "\n".join(lines) def _nat_table(rows: list[dict]) -> str: header = "| # | Interface | Source | Destination | Target | Description |" sep = "|---|---|---|---|---|---|" lines = [header, sep] for i, row in enumerate(rows[:200], start=1): lines.append( "| " + " | ".join( [ str(i), _cell(row.get("interface")), _cell(row.get("source")), _cell(row.get("destination")), _cell(row.get("target")), _cell(row.get("descr")), ] ) + " |" ) return "\n".join(lines) def _aliases_table(rows: list[dict]) -> str: header = "| Name | Type | Addresses | Description |" sep = "|---|---|---|---|" lines = [header, sep] for row in rows[:200]: lines.append( "| " + " | ".join( [ _cell(row.get("name")), _cell(row.get("type")), _cell(row.get("address")), _cell(row.get("descr")), ] ) + " |" ) return "\n".join(lines) def _interfaces_table(rows: list[dict]) -> str: header = "| ID | Interface | Enabled | Description | IPv4 |" sep = "|---|---|---|---|---|" lines = [header, sep] for row in rows[:50]: lines.append( "| " + " | ".join( [ _cell(row.get("id")), _cell(row.get("if")), _cell(row.get("enable")), _cell(row.get("descr")), _cell(row.get("ipaddr")), ] ) + " |" ) return "\n".join(lines) def _qemu_list_table(rows: list[dict]) -> str: header = "| VMID | Name | Status | CPU | Memory | Disk |" sep = "|---|---|---|---|---|---|" lines = [header, sep] for row in rows[:100]: mem = row.get("maxmem") or row.get("mem") if isinstance(mem, (int, float)) and mem > 1024 * 1024: mem = f"{mem / (1024 ** 3):.1f} GB" disk = row.get("maxdisk") or row.get("disk") if isinstance(disk, (int, float)) and disk > 1024 * 1024: disk = f"{disk / (1024 ** 3):.1f} GB" lines.append( "| " + " | ".join( [ _cell(row.get("vmid")), _cell(row.get("name")), _cell(row.get("status")), _cell(row.get("cpus") or row.get("cpu")), _cell(mem), _cell(disk), ] ) + " |" ) return "\n".join(lines) def _lxc_list_table(rows: list[dict]) -> str: header = "| VMID | Name | Status |" sep = "|---|---|---|" lines = [header, sep] for row in rows[:100]: lines.append( "| " + " | ".join([_cell(row.get("vmid")), _cell(row.get("name")), _cell(row.get("status"))]) + " |" ) return "\n".join(lines) def _qemu_config_table(data: dict) -> str: lines = ["| Key | Value |", "|---|---|"] for key in sorted(data.keys()): if key.startswith("digest"): continue lines.append(f"| {_cell(key)} | {_cell(data[key])} |") return "\n".join(lines) def _qemu_status_table(data: dict) -> str: if not data: return "_No status data._" lines = ["| Field | Value |", "|---|---|"] for key in ("name", "status", "qmpstatus", "vmid", "uptime", "cpu", "mem", "maxmem", "disk", "maxdisk", "pid"): if key in data: val = data[key] if isinstance(val, (int, float)) and key in ("mem", "maxmem", "disk", "maxdisk") and val > 1024 * 1024: val = f"{val / (1024 ** 3):.2f} GB" lines.append(f"| {_cell(key)} | {_cell(val)} |") for key, val in sorted(data.items()): if key in ("name", "status", "qmpstatus", "vmid", "uptime", "cpu", "mem", "maxmem", "disk", "maxdisk", "pid"): continue lines.append(f"| {_cell(key)} | {_cell(val)} |") return "\n".join(lines) def _rows_from_payload(parsed: Any) -> list[dict]: if isinstance(parsed, list): return [r for r in parsed if isinstance(r, dict)] if isinstance(parsed, dict): data = parsed.get("data") if isinstance(data, list): return [r for r in data if isinstance(r, dict)] return [parsed] return [] def _qm_list_text_table(text: str) -> str: lines = ["| VMID | Name | Status | Mem (MB) | Boot disk (GB) |", "|---|---|---|---|---|"] for row in text.splitlines(): row = row.strip() if not row or row.startswith("VMID") or row.startswith("-"): continue parts = row.split() if not parts or not parts[0].isdigit(): continue vmid, name, status = parts[0], parts[1] if len(parts) > 1 else "", parts[2] if len(parts) > 2 else "" mem = parts[3] if len(parts) > 3 else "" disk = parts[4] if len(parts) > 4 else "" lines.append(f"| {vmid} | {name} | {status} | {mem} | {disk} |") return "\n".join(lines) if len(lines) > 2 else f"```\n{_clip(text, 8000)}\n```" def _qm_config_text_block(text: str) -> str: lines = ["| Key | Value |", "|---|---|"] for raw in text.splitlines(): raw = raw.strip() if not raw or ":" not in raw: continue key, val = raw.split(":", 1) lines.append(f"| {_cell(key.strip())} | {_cell(val.strip())} |") return "\n".join(lines) if len(lines) > 2 else f"```\n{_clip(text, 8000)}\n```" def format_tool_output_markdown(tool: str, output: str) -> str: """Turn raw MCP JSON/text into readable markdown when possible.""" parsed = _parse_json_payload(output) if tool == "proxmox_list_qemu": if isinstance(parsed, (dict, list)): rows = _rows_from_payload(parsed) if rows and rows[0].get("vmid") is not None: return _qemu_list_table(rows) if re.search(r"^\s*\d+\s+\S+\s+(running|stopped)", output, re.MULTILINE): return _qm_list_text_table(output) if tool == "proxmox_list_lxc" and isinstance(parsed, (dict, list)): rows = _rows_from_payload(parsed) if rows: return _lxc_list_table(rows) if tool == "proxmox_get_qemu_config": if isinstance(parsed, dict): inner = parsed.get("data") if isinstance(parsed.get("data"), dict) else parsed if isinstance(inner, dict) and inner: return _qemu_config_table(inner) if ":" in output: return _qm_config_text_block(output) if tool == "proxmox_get_qemu_status" and isinstance(parsed, dict): inner = parsed.get("data") if isinstance(parsed.get("data"), dict) else parsed if isinstance(inner, dict): return _qemu_status_table(inner) if isinstance(parsed, dict): data = parsed.get("data") if isinstance(data, list) and data: if tool == "pfsense_list_firewall_rules": return _firewall_rules_table(data) if tool == "pfsense_list_port_forwards": return _port_forwards_table(data) if tool == "pfsense_list_outbound_nat_mappings": return _nat_table(data) if tool == "pfsense_list_aliases": return _aliases_table(data) if tool == "pfsense_list_interfaces": return _interfaces_table(data) if tool in _TABLE_TOOLS: return f"```json\n{json.dumps(data[:50], indent=2)}\n```" return f"```\n{_clip(output, 20_000)}\n```" def prepare_report_diagnostics(diagnostics: list[dict]) -> list[dict]: """Normalize diagnostics for persistence in task.report.""" out: list[dict] = [] for d in diagnostics: text = d.get("output") or "" formatted = "" tool = d.get("tool") if text: if tool in _TABLE_TOOLS: formatted = format_tool_output_markdown(tool, text) elif tool == "ssh_run": if re.search(r"^\s*\d+\s+\S+\s+(running|stopped)", text, re.MULTILINE): formatted = format_tool_output_markdown("proxmox_list_qemu", text) elif re.match(r"^[a-z][a-z0-9_-]*:", text.strip(), re.IGNORECASE): formatted = format_tool_output_markdown("proxmox_get_qemu_config", text) entry = { "device": d.get("device"), "tool": tool, "ok": d.get("ok"), "intent": d.get("intent"), "vmid": d.get("vmid"), "output_chars": len(text), "raw_available": bool(text), "truncated": len(text) > MAX_REPORT_OUTPUT_PER_TOOL, } if formatted: entry["formatted"] = formatted elif text: entry["output"] = _clip(text, MAX_REPORT_OUTPUT_PER_TOOL) out.append(entry) return out def diagnostics_to_markdown(diagnostics: list[dict]) -> str: """Build Obsidian section from report-style diagnostics.""" if not diagnostics: return "_No live diagnostic output captured._" parts: list[str] = [] total = 0 for d in diagnostics: tool = d.get("tool") or "unknown" device = d.get("device") or "?" ok = d.get("ok") vmid = d.get("vmid") vm_label = f" VM {vmid}" if vmid is not None else "" header = f"### [{device}] {tool}{vm_label} ({'ok' if ok else 'fail'})" body = d.get("formatted") or format_tool_output_markdown(tool, d.get("output") or "") chunk = f"{header}\n\n{body}\n" if total + len(chunk) > MAX_OBSIDIAN_DIAG_CHARS: parts.append("_Additional diagnostic output omitted (size limit)._") break parts.append(chunk) total += len(chunk) return "\n".join(parts)