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.
@@ -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
+ )