memleaf 0.1.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.
- memleaf/__init__.py +35 -0
- memleaf/adapters/__init__.py +15 -0
- memleaf/adapters/antigravity.py +352 -0
- memleaf/adapters/base.py +746 -0
- memleaf/adapters/codex.py +460 -0
- memleaf/adapters/hermes.py +710 -0
- memleaf/budget.py +123 -0
- memleaf/capture.py +311 -0
- memleaf/cli.py +360 -0
- memleaf/compaction.py +878 -0
- memleaf/config.py +164 -0
- memleaf/frontmatter.py +403 -0
- memleaf/host_events.py +1327 -0
- memleaf/inbox.py +292 -0
- memleaf/index.py +275 -0
- memleaf/llm/__init__.py +43 -0
- memleaf/llm/base.py +349 -0
- memleaf/llm/claude_compatible.py +31 -0
- memleaf/llm/gemini.py +35 -0
- memleaf/llm/openai_compatible.py +127 -0
- memleaf/llm/router.py +172 -0
- memleaf/locking.py +134 -0
- memleaf/mcp_server.py +979 -0
- memleaf/memory_writer.py +437 -0
- memleaf/model_discovery.py +746 -0
- memleaf/models.py +285 -0
- memleaf/native_index.py +776 -0
- memleaf/processing.py +2029 -0
- memleaf/prompts.py +306 -0
- memleaf/redaction.py +41 -0
- memleaf/retrieval.py +377 -0
- memleaf/retrieval_gate.py +604 -0
- memleaf/scope_maintenance.py +480 -0
- memleaf/scope_state.py +328 -0
- memleaf/service.py +1420 -0
- memleaf/validation.py +794 -0
- memleaf/vault.py +247 -0
- memleaf-0.1.0.dist-info/METADATA +423 -0
- memleaf-0.1.0.dist-info/RECORD +43 -0
- memleaf-0.1.0.dist-info/WHEEL +5 -0
- memleaf-0.1.0.dist-info/entry_points.txt +3 -0
- memleaf-0.1.0.dist-info/licenses/LICENSE +21 -0
- memleaf-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""Hermes CLI detection and conservative MCP configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from .base import (
|
|
13
|
+
CommandRunner,
|
|
14
|
+
ConfigureResult,
|
|
15
|
+
Detection,
|
|
16
|
+
adapter_environment,
|
|
17
|
+
adapter_home,
|
|
18
|
+
command_is_missing,
|
|
19
|
+
make_backup,
|
|
20
|
+
resolve_executable,
|
|
21
|
+
result_from_detection,
|
|
22
|
+
run_argv,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_MCP_IDLE_TIMEOUT_SECONDS = 60
|
|
27
|
+
_MCP_EXPECTED_TOOL_COUNT = 11
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HermesAdapter:
|
|
31
|
+
"""Use Hermes' CLI and read-only diagnostics instead of rewriting YAML."""
|
|
32
|
+
|
|
33
|
+
agent = "hermes"
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
home: Path | str | None = None,
|
|
38
|
+
env: Mapping[str, str] | None = None,
|
|
39
|
+
runner: CommandRunner | None = None,
|
|
40
|
+
*,
|
|
41
|
+
command_runner: CommandRunner | None = None,
|
|
42
|
+
path: str | Sequence[str] | None = None,
|
|
43
|
+
memleaf_command: Path | str | None = None,
|
|
44
|
+
hermes_home: Path | str | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
if runner is not None and command_runner is not None:
|
|
47
|
+
raise ValueError("provide runner or command_runner, not both")
|
|
48
|
+
self.env = adapter_environment(env)
|
|
49
|
+
effective_home = home if home is not None else self.env.get("HOME")
|
|
50
|
+
self.home = adapter_home(effective_home)
|
|
51
|
+
if path is not None:
|
|
52
|
+
self.env["PATH"] = (
|
|
53
|
+
path if isinstance(path, str) else os.pathsep.join(path)
|
|
54
|
+
)
|
|
55
|
+
self.runner = runner or command_runner
|
|
56
|
+
raw_hermes_home = hermes_home if hermes_home is not None else self.env.get("HERMES_HOME")
|
|
57
|
+
if raw_hermes_home is None:
|
|
58
|
+
self.hermes_home = self.home / ".hermes"
|
|
59
|
+
else:
|
|
60
|
+
configured_home = Path(raw_hermes_home).expanduser()
|
|
61
|
+
if not configured_home.is_absolute():
|
|
62
|
+
configured_home = self.home / configured_home
|
|
63
|
+
self.hermes_home = configured_home.resolve()
|
|
64
|
+
if memleaf_command is None:
|
|
65
|
+
self.memleaf_command = None
|
|
66
|
+
else:
|
|
67
|
+
configured_command = Path(memleaf_command).expanduser()
|
|
68
|
+
if not configured_command.is_absolute():
|
|
69
|
+
configured_command = Path.cwd() / configured_command
|
|
70
|
+
self.memleaf_command = str(configured_command)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def config_path(self) -> Path:
|
|
74
|
+
return self.hermes_home / "config.yaml"
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def known_executable(self) -> Path:
|
|
78
|
+
return self.home / ".local" / "bin" / "hermes"
|
|
79
|
+
|
|
80
|
+
def detect(self) -> Detection:
|
|
81
|
+
config = self.config_path
|
|
82
|
+
executable = resolve_executable(
|
|
83
|
+
"hermes", env=self.env, known_paths=(self.known_executable,)
|
|
84
|
+
)
|
|
85
|
+
config_value = str(config)
|
|
86
|
+
if executable is not None:
|
|
87
|
+
return Detection(
|
|
88
|
+
agent=self.agent,
|
|
89
|
+
detected=True,
|
|
90
|
+
confidence="high",
|
|
91
|
+
reason="executable found",
|
|
92
|
+
executable=executable,
|
|
93
|
+
config_path=config_value,
|
|
94
|
+
status="detected",
|
|
95
|
+
)
|
|
96
|
+
if config.is_file() and not config.is_symlink():
|
|
97
|
+
return Detection(
|
|
98
|
+
agent=self.agent,
|
|
99
|
+
detected=True,
|
|
100
|
+
confidence="medium",
|
|
101
|
+
reason="user configuration found but executable is unavailable",
|
|
102
|
+
executable=None,
|
|
103
|
+
config_path=config_value,
|
|
104
|
+
status="diagnostic",
|
|
105
|
+
)
|
|
106
|
+
if config.is_symlink():
|
|
107
|
+
return Detection(
|
|
108
|
+
agent=self.agent,
|
|
109
|
+
detected=False,
|
|
110
|
+
confidence="low",
|
|
111
|
+
reason="configuration path is a symlink; refusing automatic access",
|
|
112
|
+
executable=None,
|
|
113
|
+
config_path=config_value,
|
|
114
|
+
status="diagnostic",
|
|
115
|
+
)
|
|
116
|
+
return Detection(
|
|
117
|
+
agent=self.agent,
|
|
118
|
+
detected=False,
|
|
119
|
+
confidence="none",
|
|
120
|
+
reason="executable and user configuration were not found",
|
|
121
|
+
executable=None,
|
|
122
|
+
config_path=config_value,
|
|
123
|
+
status="not_detected",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def configure(
|
|
127
|
+
self,
|
|
128
|
+
detection: Detection | Path | str | None = None,
|
|
129
|
+
vault: Path | str | None = None,
|
|
130
|
+
*,
|
|
131
|
+
dry_run: bool = False,
|
|
132
|
+
attempt: bool = False,
|
|
133
|
+
) -> ConfigureResult:
|
|
134
|
+
if detection is not None and not isinstance(detection, Detection) and vault is None:
|
|
135
|
+
vault = detection
|
|
136
|
+
detection = None
|
|
137
|
+
detection = self._coerce_detection(detection, vault)
|
|
138
|
+
if vault is None:
|
|
139
|
+
raise ValueError("vault is required")
|
|
140
|
+
if not detection.detected or detection.confidence != "high":
|
|
141
|
+
return result_from_detection(
|
|
142
|
+
detection,
|
|
143
|
+
status="diagnostic" if attempt else "not_detected",
|
|
144
|
+
reason=(
|
|
145
|
+
"host was not reliably detected; no executable path guessed"
|
|
146
|
+
if attempt
|
|
147
|
+
else detection.reason
|
|
148
|
+
),
|
|
149
|
+
dry_run=dry_run,
|
|
150
|
+
)
|
|
151
|
+
if not detection.executable:
|
|
152
|
+
return result_from_detection(
|
|
153
|
+
detection,
|
|
154
|
+
status="diagnostic",
|
|
155
|
+
reason="detected host has no executable path",
|
|
156
|
+
dry_run=dry_run,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
executable = detection.executable
|
|
160
|
+
list_command = [executable, "mcp", "list"]
|
|
161
|
+
configured_command = self.memleaf_command or "memleaf-mcp"
|
|
162
|
+
add_command = [
|
|
163
|
+
executable,
|
|
164
|
+
"mcp",
|
|
165
|
+
"add",
|
|
166
|
+
"memleaf",
|
|
167
|
+
"--command",
|
|
168
|
+
configured_command,
|
|
169
|
+
"--args",
|
|
170
|
+
"--vault",
|
|
171
|
+
str(Path(vault).expanduser().resolve()),
|
|
172
|
+
]
|
|
173
|
+
if dry_run:
|
|
174
|
+
return result_from_detection(
|
|
175
|
+
detection,
|
|
176
|
+
status="would_configure",
|
|
177
|
+
reason="would query existing entry and invoke official CLI add",
|
|
178
|
+
command=add_command,
|
|
179
|
+
dry_run=True,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
config = Path(detection.config_path) if detection.config_path else self.config_path
|
|
183
|
+
if config.is_symlink():
|
|
184
|
+
return result_from_detection(
|
|
185
|
+
detection,
|
|
186
|
+
status="diagnostic",
|
|
187
|
+
reason="configuration path is a symlink; unchanged",
|
|
188
|
+
command=list_command,
|
|
189
|
+
)
|
|
190
|
+
if config.exists() and not config.is_file():
|
|
191
|
+
return result_from_detection(
|
|
192
|
+
detection,
|
|
193
|
+
status="diagnostic",
|
|
194
|
+
reason="configuration path is not a regular file; unchanged",
|
|
195
|
+
command=list_command,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
listed = run_argv(self.runner, list_command, env=self.env)
|
|
200
|
+
except Exception:
|
|
201
|
+
return result_from_detection(
|
|
202
|
+
detection,
|
|
203
|
+
status="failure",
|
|
204
|
+
reason="could not query existing MCP entries",
|
|
205
|
+
command=list_command,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
state = _state_from_listing(listed.stdout, vault, configured_command)
|
|
209
|
+
if listed.returncode == 0:
|
|
210
|
+
if state == "correct":
|
|
211
|
+
return result_from_detection(
|
|
212
|
+
detection,
|
|
213
|
+
status="already_configured",
|
|
214
|
+
reason="existing memleaf entry is correct",
|
|
215
|
+
command=list_command,
|
|
216
|
+
)
|
|
217
|
+
config_state = _state_from_config(config, vault, configured_command)
|
|
218
|
+
if state == "conflict" and config_state == "correct":
|
|
219
|
+
return result_from_detection(
|
|
220
|
+
detection,
|
|
221
|
+
status="already_configured",
|
|
222
|
+
reason="existing memleaf entry is correct",
|
|
223
|
+
command=list_command,
|
|
224
|
+
)
|
|
225
|
+
if config_state == "correct":
|
|
226
|
+
return result_from_detection(
|
|
227
|
+
detection,
|
|
228
|
+
status="already_configured",
|
|
229
|
+
reason="existing memleaf entry is correct",
|
|
230
|
+
command=list_command,
|
|
231
|
+
)
|
|
232
|
+
if config_state in ("conflict", "unknown") and not self._can_reconfigure_existing_entry(config, vault):
|
|
233
|
+
return result_from_detection(
|
|
234
|
+
detection,
|
|
235
|
+
status="diagnostic",
|
|
236
|
+
reason="existing configuration is unknown or conflicting; unchanged",
|
|
237
|
+
command=list_command,
|
|
238
|
+
)
|
|
239
|
+
elif not command_is_missing(listed):
|
|
240
|
+
return result_from_detection(
|
|
241
|
+
detection,
|
|
242
|
+
status="failure",
|
|
243
|
+
reason="could not establish whether memleaf is already configured",
|
|
244
|
+
command=list_command,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
config_state = _state_from_config(config, vault, configured_command)
|
|
248
|
+
if config_state == "correct":
|
|
249
|
+
return result_from_detection(
|
|
250
|
+
detection,
|
|
251
|
+
status="already_configured",
|
|
252
|
+
reason="existing memleaf entry is correct",
|
|
253
|
+
command=list_command,
|
|
254
|
+
)
|
|
255
|
+
if config_state in ("conflict", "unknown") and not self._can_reconfigure_existing_entry(config, vault):
|
|
256
|
+
return result_from_detection(
|
|
257
|
+
detection,
|
|
258
|
+
status="diagnostic",
|
|
259
|
+
reason="existing configuration is unknown or conflicting; unchanged",
|
|
260
|
+
command=list_command,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
backup: Path | None = None
|
|
264
|
+
try:
|
|
265
|
+
backup = make_backup(config)
|
|
266
|
+
except Exception:
|
|
267
|
+
return result_from_detection(
|
|
268
|
+
detection,
|
|
269
|
+
status="failure",
|
|
270
|
+
reason="could not create configuration backup; unchanged",
|
|
271
|
+
command=add_command,
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
# Hermes prompts after discovery; an empty line means "enable all"
|
|
275
|
+
# for this one official CLI invocation. No other host receives
|
|
276
|
+
# stdin input here, and the payload is never logged or returned.
|
|
277
|
+
add_input = "y\n\n" if self._can_reconfigure_existing_entry(config, vault) else "\n"
|
|
278
|
+
added = run_argv(
|
|
279
|
+
self.runner,
|
|
280
|
+
add_command,
|
|
281
|
+
env=self.env,
|
|
282
|
+
input_text=add_input,
|
|
283
|
+
)
|
|
284
|
+
except Exception:
|
|
285
|
+
return result_from_detection(
|
|
286
|
+
detection,
|
|
287
|
+
status="failure",
|
|
288
|
+
reason="official CLI add failed; backup retained",
|
|
289
|
+
backup_path=backup,
|
|
290
|
+
command=add_command,
|
|
291
|
+
)
|
|
292
|
+
if added.returncode != 0:
|
|
293
|
+
return result_from_detection(
|
|
294
|
+
detection,
|
|
295
|
+
status="failure",
|
|
296
|
+
reason="official CLI add failed; backup retained",
|
|
297
|
+
backup_path=backup,
|
|
298
|
+
command=add_command,
|
|
299
|
+
)
|
|
300
|
+
try:
|
|
301
|
+
postcheck_list = run_argv(self.runner, list_command, env=self.env)
|
|
302
|
+
except Exception:
|
|
303
|
+
postcheck_list = None
|
|
304
|
+
confirmed = bool(
|
|
305
|
+
postcheck_list is not None
|
|
306
|
+
and postcheck_list.returncode == 0
|
|
307
|
+
and _state_from_listing(postcheck_list.stdout, vault, configured_command) == "correct"
|
|
308
|
+
)
|
|
309
|
+
if not confirmed:
|
|
310
|
+
confirmed = _state_from_config(config, vault, configured_command) == "correct"
|
|
311
|
+
if not confirmed:
|
|
312
|
+
return result_from_detection(
|
|
313
|
+
detection,
|
|
314
|
+
status="failure",
|
|
315
|
+
reason="official CLI add returned success but configuration was not confirmed; backup retained",
|
|
316
|
+
backup_path=backup,
|
|
317
|
+
command=add_command,
|
|
318
|
+
)
|
|
319
|
+
return result_from_detection(
|
|
320
|
+
detection,
|
|
321
|
+
status="configured",
|
|
322
|
+
reason="MCP entry added by official CLI",
|
|
323
|
+
changed=True,
|
|
324
|
+
backup_path=backup,
|
|
325
|
+
command=add_command,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def configure_mcp_lifecycle(
|
|
329
|
+
self,
|
|
330
|
+
detection: Detection | None = None,
|
|
331
|
+
*,
|
|
332
|
+
idle_timeout_seconds: int = _MCP_IDLE_TIMEOUT_SECONDS,
|
|
333
|
+
dry_run: bool = False,
|
|
334
|
+
) -> bool:
|
|
335
|
+
"""Set Hermes' supported lazy/recycle options through its CLI."""
|
|
336
|
+
|
|
337
|
+
detection = detection or self.detect()
|
|
338
|
+
executable = detection.executable if detection.detected else None
|
|
339
|
+
if detection.confidence != "high" or not executable:
|
|
340
|
+
return False
|
|
341
|
+
if isinstance(idle_timeout_seconds, bool) or idle_timeout_seconds < 1:
|
|
342
|
+
return False
|
|
343
|
+
if dry_run:
|
|
344
|
+
return True
|
|
345
|
+
commands = (
|
|
346
|
+
[executable, "config", "set", "mcp_servers.memleaf.lazy", "true"],
|
|
347
|
+
[
|
|
348
|
+
executable,
|
|
349
|
+
"config",
|
|
350
|
+
"set",
|
|
351
|
+
"mcp_servers.memleaf.idle_timeout_seconds",
|
|
352
|
+
str(idle_timeout_seconds),
|
|
353
|
+
],
|
|
354
|
+
)
|
|
355
|
+
for command in commands:
|
|
356
|
+
try:
|
|
357
|
+
result = run_argv(self.runner, command, env=self.env)
|
|
358
|
+
except Exception:
|
|
359
|
+
return False
|
|
360
|
+
if result.returncode != 0:
|
|
361
|
+
return False
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
def test_mcp(
|
|
365
|
+
self,
|
|
366
|
+
detection: Detection | None = None,
|
|
367
|
+
*,
|
|
368
|
+
expected_tools: int = _MCP_EXPECTED_TOOL_COUNT,
|
|
369
|
+
) -> bool:
|
|
370
|
+
"""Run the official MCP test and require the expected tool count."""
|
|
371
|
+
|
|
372
|
+
detection = detection or self.detect()
|
|
373
|
+
executable = detection.executable if detection.detected else None
|
|
374
|
+
if detection.confidence != "high" or not executable:
|
|
375
|
+
return False
|
|
376
|
+
if isinstance(expected_tools, bool) or expected_tools < 0:
|
|
377
|
+
return False
|
|
378
|
+
try:
|
|
379
|
+
result = run_argv(
|
|
380
|
+
self.runner,
|
|
381
|
+
[executable, "mcp", "test", "memleaf"],
|
|
382
|
+
env=self.env,
|
|
383
|
+
)
|
|
384
|
+
except Exception:
|
|
385
|
+
return False
|
|
386
|
+
return result.returncode == 0 and _mcp_test_confirms_tools(
|
|
387
|
+
result.stdout,
|
|
388
|
+
expected_tools,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _can_reconfigure_existing_entry(self, path: Path, vault: Path | str) -> bool:
|
|
392
|
+
"""Allow only a known old memleaf entry for this exact vault to update."""
|
|
393
|
+
|
|
394
|
+
if self.memleaf_command is None or not path.exists() or path.is_symlink() or not path.is_file():
|
|
395
|
+
return False
|
|
396
|
+
try:
|
|
397
|
+
text = path.read_text(encoding="utf-8")
|
|
398
|
+
except (OSError, UnicodeError):
|
|
399
|
+
return False
|
|
400
|
+
parsed = _parse_json_or_yaml_mcp(text)
|
|
401
|
+
if not isinstance(parsed, Mapping):
|
|
402
|
+
return False
|
|
403
|
+
entry = parsed.get("memleaf")
|
|
404
|
+
if not isinstance(entry, Mapping):
|
|
405
|
+
return False
|
|
406
|
+
if set(entry) - {"command", "args", "enabled"}:
|
|
407
|
+
return False
|
|
408
|
+
if "enabled" in entry and not _is_boolish_true(entry["enabled"]):
|
|
409
|
+
return False
|
|
410
|
+
command = entry.get("command")
|
|
411
|
+
if not isinstance(command, str) or not _is_known_memleaf_command(
|
|
412
|
+
command,
|
|
413
|
+
home=self.home,
|
|
414
|
+
configured=self.memleaf_command,
|
|
415
|
+
):
|
|
416
|
+
return False
|
|
417
|
+
return entry.get("args") == [
|
|
418
|
+
"--vault",
|
|
419
|
+
str(Path(vault).expanduser().resolve()),
|
|
420
|
+
]
|
|
421
|
+
|
|
422
|
+
def _coerce_detection(
|
|
423
|
+
self,
|
|
424
|
+
detection: Detection | Path | str | None,
|
|
425
|
+
vault: Path | str | None,
|
|
426
|
+
) -> Detection:
|
|
427
|
+
if isinstance(detection, Detection) or detection is None:
|
|
428
|
+
return detection or self.detect()
|
|
429
|
+
if vault is None:
|
|
430
|
+
return self.detect()
|
|
431
|
+
return self.detect()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
Hermes = HermesAdapter
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _state_from_listing(
|
|
438
|
+
text: str,
|
|
439
|
+
vault: Path | str,
|
|
440
|
+
command: str = "memleaf-mcp",
|
|
441
|
+
) -> str:
|
|
442
|
+
"""Classify Hermes' human-readable list output without logging it."""
|
|
443
|
+
|
|
444
|
+
if not _contains_memleaf_name(text):
|
|
445
|
+
return "absent"
|
|
446
|
+
if command in text and "--vault" in text and str(
|
|
447
|
+
Path(vault).expanduser().resolve()
|
|
448
|
+
) in text:
|
|
449
|
+
return "correct"
|
|
450
|
+
return "conflict"
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _contains_memleaf_name(text: str) -> bool:
|
|
454
|
+
return re.search(r"(?<![A-Za-z0-9_.-])memleaf(?![A-Za-z0-9_.-])", text) is not None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _is_known_memleaf_command(
|
|
458
|
+
command: str,
|
|
459
|
+
*,
|
|
460
|
+
home: Path,
|
|
461
|
+
configured: str | None,
|
|
462
|
+
) -> bool:
|
|
463
|
+
if command == "memleaf-mcp":
|
|
464
|
+
return True
|
|
465
|
+
try:
|
|
466
|
+
candidate = Path(command).expanduser()
|
|
467
|
+
except (TypeError, ValueError):
|
|
468
|
+
return False
|
|
469
|
+
if not candidate.is_absolute():
|
|
470
|
+
return False
|
|
471
|
+
known = {(home / ".local" / "bin" / "memleaf-mcp").resolve()}
|
|
472
|
+
if configured is not None:
|
|
473
|
+
known.add(Path(configured).expanduser().resolve())
|
|
474
|
+
return candidate.resolve() in known
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _is_boolish_true(value: Any) -> bool:
|
|
478
|
+
if isinstance(value, bool):
|
|
479
|
+
return value
|
|
480
|
+
return isinstance(value, str) and value.strip().lower() in {"true", "1", "yes", "on"}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _state_from_config(
|
|
484
|
+
path: Path,
|
|
485
|
+
vault: Path | str,
|
|
486
|
+
command: str = "memleaf-mcp",
|
|
487
|
+
) -> str:
|
|
488
|
+
if not path.exists():
|
|
489
|
+
return "absent"
|
|
490
|
+
if path.is_symlink() or not path.is_file():
|
|
491
|
+
return "unknown"
|
|
492
|
+
try:
|
|
493
|
+
text = path.read_text(encoding="utf-8")
|
|
494
|
+
except (OSError, UnicodeError):
|
|
495
|
+
return "unknown"
|
|
496
|
+
parsed = _parse_json_or_yaml_mcp(text)
|
|
497
|
+
if parsed is None:
|
|
498
|
+
return "unknown"
|
|
499
|
+
entry = parsed.get("memleaf")
|
|
500
|
+
if entry is None:
|
|
501
|
+
return "absent"
|
|
502
|
+
if not isinstance(entry, Mapping):
|
|
503
|
+
return "conflict"
|
|
504
|
+
if _entry_matches(entry, vault, command):
|
|
505
|
+
return "correct"
|
|
506
|
+
return "conflict"
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _parse_json_or_yaml_mcp(text: str) -> dict[str, Any] | None:
|
|
510
|
+
try:
|
|
511
|
+
value = json.loads(text)
|
|
512
|
+
except (TypeError, ValueError):
|
|
513
|
+
return _parse_yaml_mcp_servers(text)
|
|
514
|
+
if not isinstance(value, Mapping):
|
|
515
|
+
return None
|
|
516
|
+
servers = value.get("mcp_servers")
|
|
517
|
+
if not isinstance(servers, Mapping):
|
|
518
|
+
return None
|
|
519
|
+
return dict(servers)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _parse_yaml_mcp_servers(text: str) -> dict[str, Any] | None:
|
|
523
|
+
lines = text.lstrip("\ufeff").splitlines()
|
|
524
|
+
root_index: int | None = None
|
|
525
|
+
root_indent = 0
|
|
526
|
+
root_value = ""
|
|
527
|
+
for index, raw in enumerate(lines):
|
|
528
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
529
|
+
continue
|
|
530
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
531
|
+
if indent == 0:
|
|
532
|
+
key, value = _yaml_key_value(raw.strip())
|
|
533
|
+
if key == "mcp_servers":
|
|
534
|
+
root_index = index
|
|
535
|
+
root_indent = indent
|
|
536
|
+
root_value = value
|
|
537
|
+
break
|
|
538
|
+
if root_index is None:
|
|
539
|
+
return {}
|
|
540
|
+
if root_value:
|
|
541
|
+
parsed = _parse_scalar(root_value)
|
|
542
|
+
if isinstance(parsed, Mapping):
|
|
543
|
+
return dict(parsed)
|
|
544
|
+
if parsed in ({}, None):
|
|
545
|
+
return {}
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
child_indent: int | None = None
|
|
549
|
+
entry_lines: list[str] = []
|
|
550
|
+
found = False
|
|
551
|
+
for raw in lines[root_index + 1 :]:
|
|
552
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
553
|
+
continue
|
|
554
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
555
|
+
if indent <= root_indent:
|
|
556
|
+
break
|
|
557
|
+
if child_indent is None:
|
|
558
|
+
child_indent = indent
|
|
559
|
+
if indent != child_indent:
|
|
560
|
+
if found:
|
|
561
|
+
entry_lines.append(raw)
|
|
562
|
+
continue
|
|
563
|
+
key, value = _yaml_key_value(raw.strip())
|
|
564
|
+
if key == "memleaf":
|
|
565
|
+
found = True
|
|
566
|
+
if value:
|
|
567
|
+
parsed = _parse_scalar(value)
|
|
568
|
+
if isinstance(parsed, Mapping):
|
|
569
|
+
return {"memleaf": dict(parsed)}
|
|
570
|
+
return {"memleaf": None}
|
|
571
|
+
entry_lines = []
|
|
572
|
+
elif found:
|
|
573
|
+
break
|
|
574
|
+
if not found:
|
|
575
|
+
return {}
|
|
576
|
+
entry = _parse_yaml_entry(entry_lines, child_indent or 0)
|
|
577
|
+
return {"memleaf": entry}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _parse_yaml_entry(lines: list[str], child_indent: int) -> dict[str, Any] | None:
|
|
581
|
+
command: Any = None
|
|
582
|
+
args: Any = None
|
|
583
|
+
entry: dict[str, Any] = {}
|
|
584
|
+
entry_indent = child_indent + 2
|
|
585
|
+
index = 0
|
|
586
|
+
while index < len(lines):
|
|
587
|
+
raw = lines[index]
|
|
588
|
+
if not raw.strip() or raw.lstrip().startswith("#"):
|
|
589
|
+
index += 1
|
|
590
|
+
continue
|
|
591
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
592
|
+
if indent <= child_indent:
|
|
593
|
+
break
|
|
594
|
+
if indent != entry_indent:
|
|
595
|
+
index += 1
|
|
596
|
+
continue
|
|
597
|
+
key, value = _yaml_key_value(raw.strip())
|
|
598
|
+
if key == "command":
|
|
599
|
+
command = _parse_scalar(value)
|
|
600
|
+
elif key == "args":
|
|
601
|
+
if value:
|
|
602
|
+
args = _parse_scalar(value)
|
|
603
|
+
else:
|
|
604
|
+
values: list[Any] = []
|
|
605
|
+
next_index = index + 1
|
|
606
|
+
while next_index < len(lines):
|
|
607
|
+
nested = lines[next_index]
|
|
608
|
+
if not nested.strip() or nested.lstrip().startswith("#"):
|
|
609
|
+
next_index += 1
|
|
610
|
+
continue
|
|
611
|
+
nested_indent = len(nested) - len(nested.lstrip(" "))
|
|
612
|
+
if nested_indent <= indent:
|
|
613
|
+
break
|
|
614
|
+
stripped = nested.strip()
|
|
615
|
+
if not stripped.startswith("-"):
|
|
616
|
+
break
|
|
617
|
+
values.append(_parse_scalar(stripped[1:].strip()))
|
|
618
|
+
next_index += 1
|
|
619
|
+
args = values
|
|
620
|
+
index = next_index - 1
|
|
621
|
+
else:
|
|
622
|
+
entry[key] = _parse_scalar(value) if value else {}
|
|
623
|
+
index += 1
|
|
624
|
+
if not isinstance(command, str) or not isinstance(args, list):
|
|
625
|
+
return None
|
|
626
|
+
entry.update({"command": command, "args": args})
|
|
627
|
+
return entry
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _yaml_key_value(value: str) -> tuple[str, str]:
|
|
631
|
+
match = re.match(r"^([^:]+):(?:\s*(.*))?$", value)
|
|
632
|
+
if match is None:
|
|
633
|
+
return value.strip(), ""
|
|
634
|
+
return match.group(1).strip().strip("'\""), (match.group(2) or "").strip()
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _parse_scalar(value: str) -> Any:
|
|
638
|
+
value = value.strip()
|
|
639
|
+
if not value:
|
|
640
|
+
return None
|
|
641
|
+
if value in ("null", "Null", "NULL", "~"):
|
|
642
|
+
return None
|
|
643
|
+
if value.startswith('"') and value.endswith('"'):
|
|
644
|
+
try:
|
|
645
|
+
return json.loads(value)
|
|
646
|
+
except ValueError:
|
|
647
|
+
return value[1:-1]
|
|
648
|
+
if value.startswith("'") and value.endswith("'"):
|
|
649
|
+
return value[1:-1].replace("''", "'")
|
|
650
|
+
if value.startswith("[") and value.endswith("]"):
|
|
651
|
+
body = value[1:-1].strip()
|
|
652
|
+
if not body:
|
|
653
|
+
return []
|
|
654
|
+
try:
|
|
655
|
+
parsed = ast.literal_eval(value)
|
|
656
|
+
if isinstance(parsed, list):
|
|
657
|
+
return parsed
|
|
658
|
+
except (SyntaxError, ValueError):
|
|
659
|
+
pass
|
|
660
|
+
return [_parse_scalar(part) for part in _split_flow_items(body)]
|
|
661
|
+
if value.startswith("{") and value.endswith("}"):
|
|
662
|
+
try:
|
|
663
|
+
parsed = ast.literal_eval(value)
|
|
664
|
+
if isinstance(parsed, dict):
|
|
665
|
+
return parsed
|
|
666
|
+
except (SyntaxError, ValueError):
|
|
667
|
+
return None
|
|
668
|
+
return value
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _split_flow_items(value: str) -> list[str]:
|
|
672
|
+
items: list[str] = []
|
|
673
|
+
current: list[str] = []
|
|
674
|
+
quote: str | None = None
|
|
675
|
+
for character in value:
|
|
676
|
+
if character in ("'", '"'):
|
|
677
|
+
if quote is None:
|
|
678
|
+
quote = character
|
|
679
|
+
elif quote == character:
|
|
680
|
+
quote = None
|
|
681
|
+
if character == "," and quote is None:
|
|
682
|
+
items.append("".join(current).strip())
|
|
683
|
+
current = []
|
|
684
|
+
else:
|
|
685
|
+
current.append(character)
|
|
686
|
+
if current:
|
|
687
|
+
items.append("".join(current).strip())
|
|
688
|
+
return items
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _entry_matches(
|
|
692
|
+
entry: Mapping[str, Any],
|
|
693
|
+
vault: Path | str,
|
|
694
|
+
command: str = "memleaf-mcp",
|
|
695
|
+
) -> bool:
|
|
696
|
+
return entry.get("command") == command and entry.get("args") == [
|
|
697
|
+
"--vault",
|
|
698
|
+
str(Path(vault).expanduser().resolve()),
|
|
699
|
+
]
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _mcp_test_confirms_tools(text: str, expected_tools: int) -> bool:
|
|
703
|
+
"""Recognize Hermes' human MCP test summary without returning its output."""
|
|
704
|
+
|
|
705
|
+
normalized = text.casefold()
|
|
706
|
+
count = re.escape(str(expected_tools))
|
|
707
|
+
return bool(
|
|
708
|
+
re.search(rf"\btools?\s+discovered\s*:\s*{count}\b", normalized)
|
|
709
|
+
or re.search(rf"\b{count}\s+tools?\b", normalized)
|
|
710
|
+
)
|