personal memory agent
at main 193 lines 5.4 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4"""Diagnostic collector for support tickets. 5 6Gathers system state — version, OS, active services, recent errors, and 7configuration (secrets stripped) — for the ``user_context`` field on support 8tickets. All collection is local; nothing is transmitted. 9""" 10 11from __future__ import annotations 12 13import json 14import logging 15import os 16import platform 17from pathlib import Path 18from typing import Any 19 20logger = logging.getLogger(__name__) 21 22# Config keys that must never leave the device. 23_SECRET_KEYS = frozenset( 24 { 25 "ANTHROPIC_API_KEY", 26 "OPENAI_API_KEY", 27 "GOOGLE_API_KEY", 28 "REVAI_ACCESS_TOKEN", 29 "PLAUD_ACCESS_TOKEN", 30 "password", 31 "secret", 32 "token", 33 "key", 34 } 35) 36 37 38def _is_secret_key(key: str) -> bool: 39 """Return True if *key* looks like it holds sensitive data.""" 40 lower = key.lower() 41 return any(s in lower for s in ("key", "token", "secret", "password")) 42 43 44def _strip_secrets(obj: Any) -> Any: 45 """Recursively redact values whose keys look secret.""" 46 if isinstance(obj, dict): 47 return { 48 k: "***" if _is_secret_key(k) else _strip_secrets(v) for k, v in obj.items() 49 } 50 if isinstance(obj, list): 51 return [_strip_secrets(v) for v in obj] 52 return obj 53 54 55# -- Individual collectors --------------------------------------------------- 56 57 58def collect_version() -> str | None: 59 """Return the installed solstone version string.""" 60 try: 61 from importlib.metadata import version 62 63 return version("solstone") 64 except Exception: 65 return None 66 67 68def collect_platform() -> dict[str, str]: 69 """Return OS / platform info.""" 70 return { 71 "system": platform.system(), 72 "release": platform.release(), 73 "machine": platform.machine(), 74 "python": platform.python_version(), 75 } 76 77 78def collect_services() -> dict[str, str]: 79 """Check which solstone services are running. 80 81 Looks at PID files under ``journal/health/``. 82 """ 83 from think.utils import get_journal 84 85 journal = get_journal() 86 health_dir = Path(journal) / "health" 87 if not health_dir.is_dir(): 88 return {} 89 90 statuses: dict[str, str] = {} 91 for pid_file in health_dir.glob("*.pid"): 92 service = pid_file.stem 93 try: 94 pid = int(pid_file.read_text().strip()) 95 # Check if process is alive 96 os.kill(pid, 0) 97 statuses[service] = "running" 98 except (ValueError, ProcessLookupError, PermissionError): 99 statuses[service] = "stopped" 100 except OSError: 101 statuses[service] = "unknown" 102 103 return statuses 104 105 106def collect_recent_errors(limit: int = 10) -> list[dict[str, Any]]: 107 """Return the most recent callosum error events from service logs. 108 109 Scans ``journal/health/*.log`` for lines containing ``ERROR``. 110 """ 111 from think.utils import get_journal 112 113 journal = get_journal() 114 health_dir = Path(journal) / "health" 115 if not health_dir.is_dir(): 116 return [] 117 118 errors: list[dict[str, Any]] = [] 119 for log_file in health_dir.glob("*.log"): 120 try: 121 lines = log_file.read_text(errors="replace").splitlines() 122 for line in reversed(lines): 123 if "ERROR" in line and len(errors) < limit: 124 errors.append( 125 { 126 "service": log_file.stem, 127 "message": line.strip()[-500:], # cap length 128 } 129 ) 130 except OSError: 131 continue 132 133 return errors[:limit] 134 135 136def collect_config() -> dict[str, Any]: 137 """Return journal config with secrets stripped.""" 138 from think.utils import get_journal 139 140 journal = get_journal() 141 config_path = Path(journal) / "config" / "config.json" 142 if not config_path.is_file(): 143 return {} 144 145 try: 146 config = json.loads(config_path.read_text()) 147 return _strip_secrets(config) 148 except (json.JSONDecodeError, OSError): 149 return {} 150 151 152# -- Public API -------------------------------------------------------------- 153 154 155def collect_all() -> dict[str, Any]: 156 """Gather all diagnostics and return as a JSON-serialisable dict. 157 158 This is the value for the ``user_context`` field on support tickets. 159 The user sees *exactly* this dict before approving submission. 160 """ 161 diagnostics: dict[str, Any] = {} 162 163 try: 164 diagnostics["version"] = collect_version() 165 except Exception as exc: 166 logger.debug("version collection failed: %s", exc) 167 168 try: 169 diagnostics["platform"] = collect_platform() 170 except Exception as exc: 171 logger.debug("platform collection failed: %s", exc) 172 173 try: 174 diagnostics["services"] = collect_services() 175 except Exception as exc: 176 logger.debug("service collection failed: %s", exc) 177 178 try: 179 diagnostics["recent_errors"] = collect_recent_errors() 180 except Exception as exc: 181 logger.debug("error collection failed: %s", exc) 182 183 try: 184 diagnostics["config"] = collect_config() 185 except Exception as exc: 186 logger.debug("config collection failed: %s", exc) 187 188 return diagnostics 189 190 191def collect_all_json() -> str: 192 """Convenience: return :func:`collect_all` as a formatted JSON string.""" 193 return json.dumps(collect_all(), indent=2, default=str)