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/__init__.py +3 -0
- brainplug/__main__.py +300 -0
- brainplug/agent.py +749 -0
- brainplug/analysis.py +453 -0
- brainplug/app.py +184 -0
- brainplug/brain.py +184 -0
- brainplug/connect.py +354 -0
- brainplug/demo.py +165 -0
- brainplug/export.py +142 -0
- brainplug/memory.py +356 -0
- brainplug/notion_api.py +135 -0
- brainplug/pipeline.py +95 -0
- brainplug/render.py +47 -0
- brainplug/sources.py +1193 -0
- brainplug/stopwords.py +33 -0
- brainplug/sync.py +109 -0
- brainplug/templates/app.html +212 -0
- brainplug/templates/viewer.html +1050 -0
- brainplug/vendor/INTER_LICENSE +93 -0
- brainplug/vendor/THREE_LICENSE +21 -0
- brainplug/vendor/inter-latin.woff2 +0 -0
- brainplug/vendor/three.module.min.js +6 -0
- brainplug-0.4.0.dist-info/METADATA +364 -0
- brainplug-0.4.0.dist-info/RECORD +28 -0
- brainplug-0.4.0.dist-info/WHEEL +5 -0
- brainplug-0.4.0.dist-info/entry_points.txt +2 -0
- brainplug-0.4.0.dist-info/licenses/LICENSE +21 -0
- brainplug-0.4.0.dist-info/top_level.txt +1 -0
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
|