personal memory agent
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)