brainplug 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
brainplug/brain.py ADDED
@@ -0,0 +1,184 @@
1
+ """Geometry for the brain-shaped point cloud.
2
+
3
+ Coordinates: x = left/right, y = up, z = front (+) / back (-).
4
+ The shape is built from a few ellipsoids (hemispheres, temporal lobes,
5
+ cerebellum) plus a curved brainstem and spinal cord.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ # (center, radii)
13
+ HEMISPHERES = [
14
+ (np.array([-0.30, 0.15, 0.00]), np.array([0.52, 0.62, 0.95])),
15
+ (np.array([0.30, 0.15, 0.00]), np.array([0.52, 0.62, 0.95])),
16
+ ]
17
+ TEMPORAL = [
18
+ (np.array([-0.50, -0.22, 0.18]), np.array([0.30, 0.30, 0.55])),
19
+ (np.array([0.50, -0.22, 0.18]), np.array([0.30, 0.30, 0.55])),
20
+ ]
21
+ CEREBELLUM = (np.array([0.0, -0.40, -0.62]), np.array([0.52, 0.24, 0.32]))
22
+
23
+ # Brainstem + spinal cord as a polyline with a radius per vertex.
24
+ SPINE_PATH = np.array([
25
+ [0.0, -0.30, -0.22],
26
+ [0.0, -0.70, -0.32],
27
+ [0.0, -1.05, -0.40],
28
+ [0.0, -1.40, -0.45],
29
+ [0.0, -1.85, -0.44],
30
+ ])
31
+ SPINE_RADIUS = np.array([0.13, 0.12, 0.09, 0.07, 0.06])
32
+
33
+ CEREBRUM = HEMISPHERES + TEMPORAL
34
+
35
+
36
+ def _in_ellipsoid(p: np.ndarray, center: np.ndarray, radii: np.ndarray) -> np.ndarray:
37
+ return (((p - center) / radii) ** 2).sum(axis=-1) <= 1.0
38
+
39
+
40
+ def inside_cerebrum(p: np.ndarray) -> np.ndarray:
41
+ p = np.atleast_2d(p)
42
+ mask = np.zeros(len(p), dtype=bool)
43
+ for c, r in CEREBRUM:
44
+ mask |= _in_ellipsoid(p, c, r)
45
+ return mask
46
+
47
+
48
+ def sample_cerebrum(n: int, rng: np.random.Generator) -> np.ndarray:
49
+ """Uniform random points inside the cerebrum (rejection sampling)."""
50
+ lo, hi = np.array([-0.85, -0.55, -0.98]), np.array([0.85, 0.80, 0.98])
51
+ out = []
52
+ while sum(len(o) for o in out) < n:
53
+ cand = rng.uniform(lo, hi, size=(n * 3, 3))
54
+ out.append(cand[inside_cerebrum(cand)])
55
+ return np.vstack(out)[:n]
56
+
57
+
58
+ def _folds(d: np.ndarray, freq: float) -> np.ndarray:
59
+ """Cheap pseudo-noise used to fake gyri and sulci on the surface."""
60
+ x, y, z = d[:, 0], d[:, 1], d[:, 2]
61
+ return (np.sin(freq * x + 2.1 * np.sin(1.7 * freq * z))
62
+ * np.sin(freq * 0.9 * y + 1.3 * np.cos(1.3 * freq * x))
63
+ + 0.5 * np.sin(freq * 1.9 * z + 0.7 * y))
64
+
65
+
66
+ def _ellipsoid_surface(n: int, center, radii, rng, freq=9.0, depth=0.05):
67
+ d = rng.normal(size=(n, 3))
68
+ d /= np.linalg.norm(d, axis=1, keepdims=True)
69
+ f = _folds(d, freq)
70
+ # Keep mostly the ridges so the folds read as lines, not noise.
71
+ keep = f > -0.15
72
+ d, f = d[keep], f[keep]
73
+ return center + d * radii * (1.0 + depth * f)[:, None]
74
+
75
+
76
+ def shell(n: int = 15000, seed: int = 3) -> np.ndarray:
77
+ """Decorative outline of the brain: surface points with fake folds."""
78
+ rng = np.random.default_rng(seed)
79
+ parts = []
80
+ for c, r in HEMISPHERES:
81
+ pts = _ellipsoid_surface(int(n * 0.36), c, r, rng)
82
+ # Longitudinal fissure: leave a gap between the hemispheres on top.
83
+ pts = pts[~((np.abs(pts[:, 0]) < 0.05) & (pts[:, 1] > -0.1))]
84
+ # Only keep the outer side of each hemisphere.
85
+ pts = pts[np.sign(pts[:, 0]) == np.sign(c[0])]
86
+ parts.append(pts)
87
+ for c, r in TEMPORAL:
88
+ pts = _ellipsoid_surface(int(n * 0.08), c, r, rng, freq=11.0)
89
+ parts.append(pts[~inside_cerebrum_excluding(pts, (c, r))])
90
+ c, r = CEREBELLUM
91
+ d = rng.normal(size=(int(n * 0.12), 3))
92
+ d /= np.linalg.norm(d, axis=1, keepdims=True)
93
+ folia = 1.0 + 0.06 * np.sin(38 * d[:, 1]) # horizontal stripes
94
+ cer = c + d * r * folia[:, None]
95
+ parts.append(cer[~inside_cerebrum(cer)])
96
+ parts.append(spine_points(int(n * 0.06), rng, jitter=1.0))
97
+ return np.vstack(parts)
98
+
99
+
100
+ def inside_cerebrum_excluding(p: np.ndarray, skip) -> np.ndarray:
101
+ mask = np.zeros(len(p), dtype=bool)
102
+ for c, r in CEREBRUM:
103
+ if c is skip[0]:
104
+ continue
105
+ mask |= _in_ellipsoid(p, c, r)
106
+ return mask
107
+
108
+
109
+ def spine_curve(t: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
110
+ """Point and radius along the spine for t in [0, 1] (0 = bottom, 1 = top)."""
111
+ s = (1.0 - np.asarray(t)) * (len(SPINE_PATH) - 1)
112
+ i = np.clip(np.floor(s).astype(int), 0, len(SPINE_PATH) - 2)
113
+ f = (s - i)[:, None]
114
+ pts = SPINE_PATH[i] * (1 - f) + SPINE_PATH[i + 1] * f
115
+ rad = SPINE_RADIUS[i] * (1 - f[:, 0]) + SPINE_RADIUS[i + 1] * f[:, 0]
116
+ return pts, rad
117
+
118
+
119
+ def spine_points(n: int, rng: np.random.Generator, jitter: float = 0.8,
120
+ t: np.ndarray | None = None) -> np.ndarray:
121
+ t = rng.random(n) if t is None else np.asarray(t)
122
+ pts, rad = spine_curve(t)
123
+ ang = rng.uniform(0, 2 * np.pi, len(t))
124
+ r = rad * np.sqrt(rng.random(len(t))) * jitter
125
+ pts = pts.copy()
126
+ pts[:, 0] += r * np.cos(ang)
127
+ pts[:, 2] += r * np.sin(ang)
128
+ return pts
129
+
130
+
131
+ def anchors(k: int, seed: int = 11) -> np.ndarray:
132
+ """k well-spread points inside the cerebrum (farthest-point sampling)."""
133
+ rng = np.random.default_rng(seed)
134
+ cand = sample_cerebrum(4000, rng)
135
+ # Stay away from the very edge so clusters have room around them.
136
+ cand = cand[inside_cerebrum(cand * 1.18)]
137
+ chosen = [cand[np.argmax(cand[:, 1])]]
138
+ dist = np.linalg.norm(cand - chosen[0], axis=1)
139
+ for _ in range(1, k):
140
+ nxt = cand[np.argmax(dist)]
141
+ chosen.append(nxt)
142
+ dist = np.minimum(dist, np.linalg.norm(cand - nxt, axis=1))
143
+ return np.array(chosen)
144
+
145
+
146
+ def place_cluster(anchor: np.ndarray, local: np.ndarray, radius: float,
147
+ rng: np.random.Generator) -> np.ndarray:
148
+ """Put a cluster's points around its anchor and keep them inside the brain."""
149
+ pts = anchor + local * radius + rng.normal(scale=radius * 0.15, size=local.shape)
150
+ for _ in range(12):
151
+ out = ~inside_cerebrum(pts * 1.02) # small margin, so rounding never pushes a point out
152
+ if not out.any():
153
+ break
154
+ pts[out] = anchor + (pts[out] - anchor) * 0.7
155
+ return pts
156
+
157
+
158
+ # --------------------------------------------------------------------------- #
159
+ # galaxy layout: the same points, arranged as a spiral galaxy
160
+ # --------------------------------------------------------------------------- #
161
+ GALAXY_RADIUS = 1.9
162
+
163
+
164
+ def galaxy(labels: np.ndarray, when: np.ndarray, k: int, rng: np.random.Generator) -> np.ndarray:
165
+ """One spiral arm per topic. ``when`` is 0..1 (oldest..newest): old ideas sit
166
+ near the core and new ones at the rim, so the galaxy grows outwards in time."""
167
+ n = len(labels)
168
+ rho = 0.42 + (GALAXY_RADIUS - 0.42) * np.asarray(when) ** 0.8
169
+ arm = 2 * np.pi * np.asarray(labels) / max(1, k)
170
+ angle = arm + 1.75 * rho + rng.normal(scale=0.13, size=n)
171
+ rho = rho + rng.normal(scale=0.035 + 0.03 * rho, size=n)
172
+ y = rng.normal(scale=0.05, size=n) * (1.25 - 0.4 * rho / GALAXY_RADIUS)
173
+ return np.column_stack([rho * np.cos(angle), y, rho * np.sin(angle)])
174
+
175
+
176
+ def galaxy_core(when: np.ndarray, rng: np.random.Generator) -> np.ndarray:
177
+ """The bright core of the galaxy: every message you wrote, oldest in the centre."""
178
+ n = len(when)
179
+ d = rng.normal(size=(n, 3))
180
+ d /= np.linalg.norm(d, axis=1, keepdims=True) + 1e-9
181
+ r = (0.04 + 0.27 * np.asarray(when) ** 0.6) * rng.random(n) ** 0.35
182
+ pts = d * r[:, None]
183
+ pts[:, 1] *= 0.55
184
+ return pts
brainplug/connect.py ADDED
@@ -0,0 +1,354 @@
1
+ """Connect your AI assistants to your brain, and check that it works.
2
+
3
+ brainplug connect ~/Vault # every assistant found on this computer
4
+ brainplug connect ~/Vault --only cursor,codex
5
+ brainplug connect --print # show what would change, change nothing
6
+ brainplug doctor ~/Vault # check everything, end to end
7
+
8
+ Each assistant's own settings file gets one "brainplug" entry (other entries are
9
+ kept, and the file is backed up first). The command written there is an absolute
10
+ path, because desktop apps do not see the PATH your terminal sees.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from dataclasses import dataclass
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ from . import __version__, memory
27
+
28
+ NAME = "brainplug"
29
+ FIRST_QUESTIONS = (
30
+ '"What do you know about me?"',
31
+ '"When did I first write about <a topic>?"',
32
+ '"What was I working on last spring?"',
33
+ '"Remember that I prefer short answers."',
34
+ )
35
+
36
+
37
+ def _home() -> Path:
38
+ return Path.home()
39
+
40
+
41
+ def _app_config(folder: str) -> Path:
42
+ """Where desktop apps keep their settings on this system."""
43
+ if sys.platform == "darwin":
44
+ return _home() / "Library" / "Application Support" / folder
45
+ if os.name == "nt":
46
+ return Path(os.environ.get("APPDATA") or _home() / "AppData" / "Roaming") / folder
47
+ return Path(os.environ.get("XDG_CONFIG_HOME") or _home() / ".config") / folder
48
+
49
+
50
+ def _codex_home() -> Path:
51
+ return Path(os.environ.get("CODEX_HOME") or _home() / ".codex").expanduser()
52
+
53
+
54
+ @dataclass
55
+ class Client:
56
+ key: str
57
+ name: str
58
+ config: Path | None = None # a JSON settings file with a map of servers
59
+ servers: str = "mcpServers" # the name of that map
60
+ cli: str = "" # a command line tool that registers servers itself
61
+ marker: Path | None = None # its presence means the assistant is installed
62
+
63
+ def found(self) -> bool:
64
+ return bool((self.cli and shutil.which(self.cli)) or (self.marker and self.marker.exists()))
65
+
66
+
67
+ def clients() -> list[Client]:
68
+ home = _home()
69
+ return [
70
+ Client("claude-desktop", "Claude Desktop", _app_config("Claude") / "claude_desktop_config.json",
71
+ marker=_app_config("Claude")),
72
+ Client("claude-code", "Claude Code", cli="claude", marker=home / ".claude"),
73
+ Client("cursor", "Cursor", home / ".cursor" / "mcp.json", marker=home / ".cursor"),
74
+ Client("codex", "Codex", _codex_home() / "config.toml", cli="codex", marker=_codex_home()),
75
+ Client("windsurf", "Windsurf", home / ".codeium" / "windsurf" / "mcp_config.json",
76
+ marker=home / ".codeium" / "windsurf"),
77
+ Client("gemini", "Gemini CLI", home / ".gemini" / "settings.json", marker=home / ".gemini"),
78
+ Client("vscode", "VS Code", _app_config("Code/User") / "mcp.json", servers="servers",
79
+ marker=_app_config("Code/User")),
80
+ Client("lmstudio", "LM Studio", home / ".lmstudio" / "mcp.json", marker=home / ".lmstudio"),
81
+ ]
82
+
83
+
84
+ def server_command(vault: str | Path | None, inputs: list[str] | None = None,
85
+ sessions: bool = False) -> tuple[str, list[str]]:
86
+ """The absolute command that starts this Brainplug's MCP server."""
87
+ args = ["mcp"] + (["--brain", str(Path(vault).expanduser().resolve())] if vault else [])
88
+ args += [str(Path(p).expanduser().resolve()) for p in inputs or []] + (["--sessions"] if sessions else [])
89
+ exe = shutil.which(NAME)
90
+ temporary = exe and re.search(r"[\\/](?:uv[\\/]archive-|\.cache[\\/]uv[\\/]|pipx[\\/]\.cache)", exe)
91
+ if exe and not temporary:
92
+ return os.path.abspath(exe), args
93
+ uvx = shutil.which("uvx")
94
+ if temporary and uvx: # started with uvx: let uvx find it again next time
95
+ return os.path.abspath(uvx), [NAME, *args]
96
+ return sys.executable, ["-m", NAME, *args]
97
+
98
+
99
+ def obsidian_vaults() -> list[Path]:
100
+ """The vaults Obsidian knows about on this computer, most recently opened first."""
101
+ places = [_app_config("obsidian") / "obsidian.json",
102
+ _home() / ".var" / "app" / "md.obsidian.Obsidian" / "config" / "obsidian" / "obsidian.json"]
103
+ found: list[tuple[float, Path]] = []
104
+ for place in places:
105
+ try:
106
+ vaults = json.loads(place.read_text(encoding="utf-8")).get("vaults") or {}
107
+ except (OSError, ValueError, AttributeError):
108
+ continue
109
+ for v in vaults.values():
110
+ if isinstance(v, dict) and v.get("path") and Path(v["path"]).is_dir():
111
+ found.append((float(v.get("ts") or 0), Path(v["path"])))
112
+ return [p for _, p in sorted(found, key=lambda tp: -tp[0])]
113
+
114
+
115
+ # ------------------------------------------------------------------ writing
116
+ def _merge_json(path: Path, servers: str, entry: dict, dry: bool) -> str:
117
+ data: dict = {}
118
+ if path.exists():
119
+ try:
120
+ data = json.loads(path.read_text(encoding="utf-8") or "{}")
121
+ except ValueError:
122
+ return "unreadable"
123
+ if not isinstance(data, dict) or not isinstance(data.get(servers, {}), dict):
124
+ return "unreadable"
125
+ table = data.setdefault(servers, {})
126
+ if table.get(NAME) == entry:
127
+ return "unchanged"
128
+ state = "updated" if NAME in table else "added"
129
+ if dry:
130
+ return f"would be {state}"
131
+ if path.exists():
132
+ shutil.copy2(path, path.with_name(f"{path.name}.{datetime.now():%Y%m%d-%H%M%S}.bak"))
133
+ table[NAME] = entry
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ tmp = path.with_name(path.name + ".tmp")
136
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
137
+ os.replace(tmp, path)
138
+ return state
139
+
140
+
141
+ def _merge_toml(path: Path, command: str, args: list[str], dry: bool) -> str:
142
+ block = f"[mcp_servers.{NAME}]\ncommand = {json.dumps(command)}\nargs = {json.dumps(args)}\n"
143
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
144
+ section = re.compile(rf"(?ms)^\[mcp_servers\.{NAME}\]\n.*?(?=^\[|\Z)")
145
+ old = section.search(text)
146
+ if old and old.group(0).strip() == block.strip():
147
+ return "unchanged"
148
+ new = section.sub(block + "\n", text) if old else (text.rstrip("\n") + "\n\n" if text.strip() else "") + block
149
+ if dry:
150
+ return "would be " + ("updated" if old else "added")
151
+ if path.exists():
152
+ shutil.copy2(path, path.with_name(f"{path.name}.{datetime.now():%Y%m%d-%H%M%S}.bak"))
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ path.write_text(new, encoding="utf-8")
155
+ return "updated" if old else "added"
156
+
157
+
158
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess:
159
+ try:
160
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=60)
161
+ except (subprocess.TimeoutExpired, OSError) as err: # a CLI that hangs or vanished: report it, go on
162
+ return subprocess.CompletedProcess(cmd, 1, "", f"{type(err).__name__}: {err}")
163
+
164
+
165
+ def connect_one(client: Client, command: str, args: list[str], dry: bool = False) -> str:
166
+ """Add Brainplug to one assistant. Returns what happened, in a few words."""
167
+ if client.key in ("claude-code", "codex") and client.cli and shutil.which(client.cli):
168
+ add = [client.cli, "mcp", "add"] + (["--scope", "user"] if client.key == "claude-code" else [])
169
+ add += [NAME, "--", command, *args]
170
+ if dry:
171
+ return "would run: " + " ".join(add)
172
+ _run([client.cli, "mcp", "remove", NAME] + (["--scope", "user"] if client.key == "claude-code" else []))
173
+ done = _run(add)
174
+ return "added" if done.returncode == 0 else f"failed: {(done.stderr or done.stdout).strip()[:200]}"
175
+ if client.key == "codex" and client.config:
176
+ return _merge_toml(client.config, command, args, dry)
177
+ if client.config:
178
+ entry = {"command": command, "args": args}
179
+ if client.servers == "servers":
180
+ entry = {"type": "stdio", **entry}
181
+ return _merge_json(client.config, client.servers, entry, dry)
182
+ return "manual"
183
+
184
+
185
+ def connect(vault: str | Path | None, inputs: list[str] | None = None, only: list[str] | None = None,
186
+ sessions: bool = False, dry: bool = False, say=print) -> int:
187
+ if vault is None:
188
+ known = obsidian_vaults()
189
+ if not known:
190
+ say("Name the folder for your brain, e.g.: brainplug connect ~/Documents/MyVault")
191
+ return 2
192
+ vault = known[0]
193
+ say(f"Using your Obsidian vault {vault}" + (f" (the most recent of {len(known)})" if len(known) > 1 else ""))
194
+ if not Path(vault).expanduser().is_dir():
195
+ say(f"{vault} does not exist. Check the path, or create it first with: brainplug brain \"{vault}\"")
196
+ return 2
197
+ if not dry:
198
+ memory.init(vault)
199
+ command, args = server_command(vault, inputs, sessions)
200
+ say(f"Brainplug {__version__} will start as:\n {command} {' '.join(args)}\n")
201
+ wanted = [c for c in clients() if not only or c.key in only]
202
+ unknown = sorted(set(only or []) - {c.key for c in clients()})
203
+ if unknown:
204
+ say(f"Unknown assistant(s): {', '.join(unknown)}. Known: {', '.join(c.key for c in clients())}.")
205
+ connected = 0
206
+ for client in wanted:
207
+ if not client.found() and not only:
208
+ continue
209
+ state = connect_one(client, command, args, dry)
210
+ connected += state in ("added", "updated", "unchanged")
211
+ where = f" ({client.config})" if client.config and client.key not in ("claude-code",) else ""
212
+ if state == "unreadable":
213
+ state = "its settings file could not be read, so it was left as it is; add this by hand:\n " + json.dumps(
214
+ {client.servers: {NAME: {"command": command, "args": args}}})
215
+ elif state == "manual":
216
+ state = f"run: claude mcp add --scope user {NAME} -- {command} {' '.join(args)}"
217
+ say(f" {client.name:<15} {state}{where if state in ('added', 'updated', 'unchanged') else ''}")
218
+ if not any(c.found() for c in wanted) and not only:
219
+ say(" No assistant found on this computer. Install Claude Desktop, Claude Code, Cursor or Codex, "
220
+ "or pass --only to write a settings file anyway.")
221
+ if connected and not dry:
222
+ say("\nRestart the assistants that were open. Then ask, for example:")
223
+ say(" " + "\n ".join(FIRST_QUESTIONS))
224
+ say(f"\nCheck it any time with: brainplug doctor \"{vault}\"")
225
+ return 0
226
+
227
+
228
+ # ------------------------------------------------------------------- doctor
229
+ def _handshake(command: str, args: list[str], timeout: float = 90.0) -> tuple[bool, str]:
230
+ """Start the server exactly as an assistant would and ask it for the briefing."""
231
+ try:
232
+ import anyio # noqa: F401 (comes with the MCP SDK)
233
+ from mcp import ClientSession, StdioServerParameters
234
+ from mcp.client.stdio import stdio_client
235
+ except ImportError:
236
+ return False, "the MCP SDK is missing: reinstall Brainplug (uv tool install --force brainplug)"
237
+ import asyncio
238
+
239
+ import tempfile
240
+
241
+ log = tempfile.TemporaryFile(mode="w+", encoding="utf-8") # what the server says, to show if it fails
242
+
243
+ async def talk():
244
+ began = time.monotonic()
245
+ params = StdioServerParameters(command=command, args=args, env=dict(os.environ))
246
+ async with stdio_client(params, errlog=log) as (read, write):
247
+ async with ClientSession(read, write) as session:
248
+ await session.initialize()
249
+ tools = [t.name for t in (await session.list_tools()).tools]
250
+ briefing = (await session.call_tool("context", {})).content[0].text
251
+ return time.monotonic() - began, tools, briefing
252
+
253
+ try:
254
+ took, tools, briefing = asyncio.run(asyncio.wait_for(talk(), timeout))
255
+ except Exception as err: # noqa: BLE001 (shown to the person, who can act on it)
256
+ log.seek(0)
257
+ said = " | ".join(line.strip() for line in log.read().splitlines()[-3:] if line.strip())
258
+ return False, f"the server did not answer: {type(err).__name__}: {err}" + (f" (it said: {said})" if said else "")
259
+ finally:
260
+ log.close()
261
+ last = [line for line in briefing.splitlines() if line.strip()][-1:]
262
+ return True, f"answered in {took:.1f} s with {len(tools)} tools; it says: {last[0] if last else '(empty)'}"
263
+
264
+
265
+ def _registered(client: Client) -> tuple[str, list[str]] | None:
266
+ """The command an assistant has for Brainplug, if any (from its settings file)."""
267
+ path = client.config
268
+ if not path or not path.exists():
269
+ return None
270
+ text = path.read_text(encoding="utf-8", errors="ignore")
271
+ if path.suffix == ".toml":
272
+ m = re.search(rf"(?ms)^\[mcp_servers\.{NAME}\]\n(.*?)(?=^\[|\Z)", text)
273
+ if not m:
274
+ return None
275
+ command = re.search(r'^command\s*=\s*(".*")\s*$', m.group(1), re.M)
276
+ args = re.search(r"^args\s*=\s*(\[.*\])\s*$", m.group(1), re.M)
277
+ try:
278
+ return json.loads(command.group(1)) if command else "", json.loads(args.group(1)) if args else []
279
+ except ValueError:
280
+ return "", []
281
+ try:
282
+ entry = (json.loads(text or "{}").get(client.servers) or {}).get(NAME)
283
+ except (ValueError, AttributeError):
284
+ return None
285
+ return (entry.get("command", ""), entry.get("args", [])) if isinstance(entry, dict) else None
286
+
287
+
288
+ def doctor(vault: str | Path | None = None, say=print) -> int:
289
+ problems = 0
290
+
291
+ def check(good: bool, text: str, fix: str = "") -> None:
292
+ nonlocal problems
293
+ problems += not good
294
+ say(f" {'✓' if good else '✗'} {text}" + (f"\n → {fix}" if fix and not good else ""))
295
+
296
+ say(f"Brainplug {__version__} · Python {sys.version.split()[0]} · {sys.platform}")
297
+ check(sys.version_info >= (3, 10), "Python 3.10 or later")
298
+ try:
299
+ import importlib.metadata as md
300
+ check(True, f"MCP SDK {md.version('mcp')}")
301
+ except Exception: # noqa: BLE001
302
+ check(False, "MCP SDK installed", "reinstall Brainplug: uv tool install --force brainplug")
303
+ if vault is None:
304
+ known = obsidian_vaults()
305
+ vault = known[0] if known else None
306
+ if vault is None:
307
+ check(False, "a vault to check", "brainplug doctor ~/path/to/your/vault")
308
+ return 1
309
+ vault = Path(vault).expanduser()
310
+ check(vault.is_dir(), f"vault {vault}", "create the folder, or point to your Obsidian vault")
311
+ if vault.is_dir():
312
+ base = memory.brain_dir(vault)
313
+ check(base.is_dir(), "Brainplug folder in the vault", f'brainplug brain "{vault}"')
314
+ about = memory.profile(vault)
315
+ check(bool(about), '"About me" note filled in', f"write a few lines in {base / memory.PROFILE}")
316
+ try:
317
+ probe = base / memory.MEMORY / ".brainplug-write-check"
318
+ probe.parent.mkdir(parents=True, exist_ok=True)
319
+ probe.write_text("ok", encoding="utf-8")
320
+ probe.unlink()
321
+ check(True, "memories can be saved")
322
+ except OSError as err:
323
+ check(False, "memories can be saved", f"the Memory folder is not writable: {err}")
324
+ waiting = memory.pending(vault)
325
+ if waiting:
326
+ say(f" ! {len(waiting)} preference(s) from assistants wait for your approval: "
327
+ f'brainplug brain "{vault}" --review')
328
+ command, args = server_command(vault)
329
+ ok, said = _handshake(command, args)
330
+ check(ok, f"the server starts and answers: {said}" if ok else "the server starts and answers", said)
331
+ say("Assistants:")
332
+ any_found = False
333
+ for client in clients():
334
+ if not client.found():
335
+ continue
336
+ any_found = True
337
+ if client.key == "claude-code" and shutil.which("claude"):
338
+ listed = _run(["claude", "mcp", "get", NAME])
339
+ check(listed.returncode == 0, "Claude Code has Brainplug", f'brainplug connect "{vault}" --only claude-code')
340
+ continue
341
+ got = _registered(client)
342
+ if got is None:
343
+ check(False, f"{client.name} has Brainplug", f'brainplug connect "{vault}" --only {client.key}')
344
+ continue
345
+ cmd, cmd_args = got
346
+ exists = bool(cmd) and (Path(cmd).exists() or bool(shutil.which(cmd)))
347
+ same_vault = str(vault.resolve()) in [str(Path(a).expanduser()) for a in cmd_args] if cmd_args else False
348
+ check(exists, f"{client.name}: the command exists ({cmd or 'none'})", f'brainplug connect "{vault}" --only {client.key}')
349
+ if exists and not same_vault:
350
+ say(f" ! {client.name} uses another vault: {' '.join(cmd_args)}")
351
+ if not any_found:
352
+ say(" (no assistant found on this computer)")
353
+ say("All good." if not problems else f"{problems} thing(s) to fix, listed above.")
354
+ return 0 if not problems else 1