memleaf 0.2.23__tar.gz → 0.2.24__tar.gz

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.
Files changed (114) hide show
  1. {memleaf-0.2.23 → memleaf-0.2.24}/CHANGELOG.md +9 -0
  2. {memleaf-0.2.23/src/memleaf.egg-info → memleaf-0.2.24}/PKG-INFO +3 -3
  3. {memleaf-0.2.23 → memleaf-0.2.24}/README.en.md +2 -2
  4. {memleaf-0.2.23 → memleaf-0.2.24}/README.md +2 -2
  5. {memleaf-0.2.23 → memleaf-0.2.24}/pyproject.toml +1 -1
  6. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/cli.py +124 -14
  8. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/hermes_provider/README.md +28 -4
  9. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  10. memleaf-0.2.24/src/memleaf/hermes_runtime.py +349 -0
  11. memleaf-0.2.24/src/memleaf/installer.py +1100 -0
  12. {memleaf-0.2.23 → memleaf-0.2.24/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf.egg-info/SOURCES.txt +2 -0
  14. memleaf-0.2.24/tests/test_hermes_runtime_install.py +642 -0
  15. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_host_events.py +8 -2
  16. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_pypi_install.py +26 -11
  17. memleaf-0.2.23/src/memleaf/installer.py +0 -547
  18. {memleaf-0.2.23 → memleaf-0.2.24}/IMPLEMENTATION_PLAN.md +0 -0
  19. {memleaf-0.2.23 → memleaf-0.2.24}/LICENSE +0 -0
  20. {memleaf-0.2.23 → memleaf-0.2.24}/MANIFEST.in +0 -0
  21. {memleaf-0.2.23 → memleaf-0.2.24}/RELEASE_CHECKLIST.md +0 -0
  22. {memleaf-0.2.23 → memleaf-0.2.24}/examples/README.md +0 -0
  23. {memleaf-0.2.23 → memleaf-0.2.24}/examples/basic_usage.py +0 -0
  24. {memleaf-0.2.23 → memleaf-0.2.24}/examples/mcp_stdio.ndjson +0 -0
  25. {memleaf-0.2.23 → memleaf-0.2.24}/install.ps1 +0 -0
  26. {memleaf-0.2.23 → memleaf-0.2.24}/install.sh +0 -0
  27. {memleaf-0.2.23 → memleaf-0.2.24}/setup.cfg +0 -0
  28. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/__main__.py +0 -0
  29. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/adapters/__init__.py +0 -0
  30. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/adapters/antigravity.py +0 -0
  31. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/adapters/base.py +0 -0
  32. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/adapters/codex.py +0 -0
  33. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/adapters/hermes.py +0 -0
  34. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/budget.py +0 -0
  35. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/capture.py +0 -0
  36. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/compaction.py +0 -0
  37. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/config.py +0 -0
  38. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/credentials.py +0 -0
  39. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/frontmatter.py +0 -0
  40. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/hermes_provider/__init__.py +0 -0
  41. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/host_events.py +0 -0
  42. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/host_runtime.py +0 -0
  43. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/inbox.py +0 -0
  44. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/index.py +0 -0
  45. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/llm/__init__.py +0 -0
  46. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/llm/base.py +0 -0
  47. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/llm/claude_compatible.py +0 -0
  48. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/llm/gemini.py +0 -0
  49. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/llm/openai_compatible.py +0 -0
  50. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/llm/router.py +0 -0
  51. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/locking.py +0 -0
  52. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/mcp_server.py +0 -0
  53. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/memory_writer.py +0 -0
  54. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/model_discovery.py +0 -0
  55. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/models.py +0 -0
  56. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/native_index.py +0 -0
  57. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/native_registration.py +0 -0
  58. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/processing.py +0 -0
  59. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/prompts.py +0 -0
  60. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/validation.py +0 -0
  67. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf/vault.py +0 -0
  68. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.23 → memleaf-0.2.24}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.23 → memleaf-0.2.24}/tests/__init__.py +0 -0
  72. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_cross_turn_dedupe_regressions.py +0 -0
  79. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_extraction_quality_regressions.py +0 -0
  80. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_global_todo_acceptance.py +0 -0
  81. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_global_todo_retrieval.py +0 -0
  82. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_hermes_native_registration.py +0 -0
  83. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_hermes_provider.py +0 -0
  84. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_hermes_stdio_transport.py +0 -0
  85. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_host_runtime_contract.py +0 -0
  86. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_install.py +0 -0
  87. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_maintenance_v2.py +0 -0
  88. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_model_discovery.py +0 -0
  89. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_retrieval_gate.py +0 -0
  90. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_retrieval_v2.py +0 -0
  91. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_session_lineage.py +0 -0
  92. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_a.py +0 -0
  93. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b1.py +0 -0
  94. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b2a.py +0 -0
  95. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b2b.py +0 -0
  96. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3a_commit.py +0 -0
  97. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3a_contract.py +0 -0
  98. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3b_native_context.py +0 -0
  99. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3b_native_index.py +0 -0
  100. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3b_scope.py +0 -0
  101. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3c_retrieval.py +0 -0
  102. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  103. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_c1_mcp.py +0 -0
  104. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_c2_init.py +0 -0
  105. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_stage_c3_packaging.py +0 -0
  106. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_todo_state_recovery.py +0 -0
  107. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_update_target_recovery.py +0 -0
  108. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_upgrade_preserves_vault.py +0 -0
  109. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_v023_scope_correction.py +0 -0
  110. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_v2_gate_limits.py +0 -0
  111. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_v2_host_flow.py +0 -0
  112. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_v2_mcp_flow.py +0 -0
  113. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_v2_nomatch_semantics.py +0 -0
  114. {memleaf-0.2.23 → memleaf-0.2.24}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.24 — 2026-09-04
6
+
7
+ - Make Hermes MCP installation reliably persistent: write `mcp_servers.memleaf` through Hermes' canonical `config set` interface, disable the entry while command/arguments are updated, then read `config.yaml` back and require an exact command plus `--vault` match before reporting success.
8
+ - Detect a same-Vault MCP entry that points at a different absolute memleaf runtime before changing Hermes. Add explicit `--mcp-runtime auto|current|existing` policies, prefer the current Python interpreter's scripts directory over `PATH`, and require an exact version check before retaining another environment.
9
+ - Snapshot Hermes `config.yaml`, `memleaf.json`, and `plugins/memleaf` during installation; on a later failure, attempt every rollback target even when one restore fails, and report the failed paths together with the installation stage and recovery commands.
10
+ - Improve human and JSON diagnostics for MCP persistence, runtime conflicts, lifecycle/test failures, and rollback results; document `memleaf-mcp --vault`, manual YAML, and the difference between Hermes connection discovery and final configuration persistence.
11
+ - Make Windows CI fail immediately after every native command and keep POSIX command-rendering assertions platform-specific, preventing intermediate Windows test failures from being reported as green.
12
+ - Leave memory extraction, CREATE/UPDATE/NO_CHANGE decisions, retrieval, injection, todo handling, and globally shared Vault visibility unchanged.
13
+
5
14
  ## 0.2.23 — 2026-09-03
6
15
 
7
16
  - Add a strict `scope_correction` transaction for explicit cross-project corrections without weakening ordinary UPDATE scope isolation: uniquely recover the wrong active target, reuse its `memory_id` when appropriate, or retire it to history with `invalidated_reason: scope_correction` and `superseded_by` when a correct active survivor already exists.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.23
3
+ Version: 0.2.24
4
4
  Summary: A local-first Markdown memory core for AI agents
5
5
  Author: memleaf contributors
6
6
  License-Expression: MIT
@@ -23,8 +23,8 @@ Dynamic: license-file
23
23
 
24
24
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
25
25
 
26
- > **当前版本:0.2.23。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.23 通过 PyPI 分发。
26
+ > **当前版本:0.2.24。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.24 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -4,8 +4,8 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.23.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.23 is distributed through PyPI.
7
+ > **Version: 0.2.24.**
8
+ > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.24 is distributed through PyPI.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
11
11
  ## Project scope
@@ -4,8 +4,8 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **当前版本:0.2.23。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.23 通过 PyPI 分发。
7
+ > **当前版本:0.2.24。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.24 通过 PyPI 分发。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
11
11
  ## 项目定位
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.23"
7
+ version = "0.2.24"
8
8
  description = "A local-first Markdown memory core for AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  """Local-first Markdown memory core for AI agents."""
2
2
 
3
- __version__ = "0.2.23"
3
+ __version__ = "0.2.24"
4
4
 
5
5
  from .config import DEFAULT_CONFIG, default_config, load_config, save_config
6
6
  from .frontmatter import FrontmatterError, dump_frontmatter, dump_yaml, load_yaml, parse_frontmatter
@@ -6,6 +6,8 @@ import argparse
6
6
  import getpass
7
7
  import json
8
8
  import os
9
+ import shlex
10
+ import subprocess
9
11
  import sys
10
12
  from pathlib import Path
11
13
  from typing import Sequence
@@ -65,6 +67,15 @@ def build_parser() -> argparse.ArgumentParser:
65
67
  help="host to configure (default: hermes)",
66
68
  )
67
69
  install.add_argument("--vault", type=Path, default=None, help="vault directory")
70
+ install.add_argument(
71
+ "--mcp-runtime",
72
+ choices=("auto", "current", "existing"),
73
+ default="auto",
74
+ help=(
75
+ "Hermes only: fail on a second memleaf runtime (auto), migrate to "
76
+ "this installation (current), or retain a version-matched configured runtime (existing)"
77
+ ),
78
+ )
68
79
  install.add_argument("--json", action="store_true", help="emit one JSON result")
69
80
  host_event = commands.add_parser(
70
81
  "host-event",
@@ -87,6 +98,96 @@ def _configure_stdio_utf8() -> None:
87
98
  continue
88
99
 
89
100
 
101
+ def _format_command(command: Sequence[object]) -> str:
102
+ values = [str(item) for item in command]
103
+ return subprocess.list2cmdline(values) if os.name == "nt" else shlex.join(values)
104
+
105
+
106
+ def _print_install_failure(output: dict, *, host: str) -> None:
107
+ print(
108
+ f"memleaf install failed: {output.get('reason', 'unknown error')}",
109
+ file=sys.stderr,
110
+ )
111
+ stage = output.get("stage")
112
+ if isinstance(stage, str) and stage:
113
+ print(f"failed stage: {stage}", file=sys.stderr)
114
+
115
+ if host == "hermes" and output.get("core_version") is not None:
116
+ provider_version = output.get("provider_version")
117
+ if output.get("provider_updated"):
118
+ provider_text = provider_version or "unknown"
119
+ elif provider_version:
120
+ provider_text = f"not changed (rejected candidate {provider_version})"
121
+ else:
122
+ provider_text = "not changed"
123
+ print(
124
+ "memleaf versions: "
125
+ f"core={output.get('core_version') or 'unknown'}, "
126
+ f"Hermes provider={provider_text}",
127
+ file=sys.stderr,
128
+ )
129
+
130
+ runtime = output.get("mcp_runtime")
131
+ if isinstance(runtime, dict):
132
+ config_path = runtime.get("config_path")
133
+ configured = runtime.get("configured_command")
134
+ current = runtime.get("current_command") or runtime.get("expected_command")
135
+ existing_version = runtime.get("existing_version")
136
+ if config_path:
137
+ print(f"Hermes config: {config_path}", file=sys.stderr)
138
+ if configured:
139
+ print(f"configured MCP runtime: {configured}", file=sys.stderr)
140
+ if current:
141
+ print(f"current memleaf runtime: {current}", file=sys.stderr)
142
+ if existing_version:
143
+ print(f"configured MCP version: {existing_version}", file=sys.stderr)
144
+
145
+ mcp = output.get("mcp")
146
+ if isinstance(mcp, dict):
147
+ mcp_reason = mcp.get("reason")
148
+ if isinstance(mcp_reason, str) and mcp_reason and mcp_reason != output.get("reason"):
149
+ print(f"MCP detail: {mcp_reason}", file=sys.stderr)
150
+ config_path = mcp.get("config_path")
151
+ if config_path and not (
152
+ isinstance(runtime, dict) and config_path == runtime.get("config_path")
153
+ ):
154
+ print(f"Hermes config: {config_path}", file=sys.stderr)
155
+ backup_path = mcp.get("backup_path")
156
+ if backup_path:
157
+ print(f"config backup: {backup_path}", file=sys.stderr)
158
+
159
+ rollback = output.get("rollback_status")
160
+ if rollback == "completed":
161
+ print("Hermes configuration was restored to its pre-install state.", file=sys.stderr)
162
+ elif rollback == "failed":
163
+ print(
164
+ "WARNING: automatic rollback failed; inspect the reported Hermes paths before restarting.",
165
+ file=sys.stderr,
166
+ )
167
+
168
+ user_action = output.get("user_action")
169
+ if output.get("user_action_required") and isinstance(user_action, str) and user_action:
170
+ print(f"action required: {user_action}", file=sys.stderr)
171
+
172
+ commands = output.get("recovery_commands")
173
+ if isinstance(commands, list) and commands:
174
+ print("recovery commands:", file=sys.stderr)
175
+ for command in commands:
176
+ if isinstance(command, list) and command:
177
+ print(f" {_format_command(command)}", file=sys.stderr)
178
+
179
+ diagnostic = ["python", "-m", "memleaf", "install", "--host", host]
180
+ if host == "hermes" and isinstance(runtime, dict):
181
+ policy = runtime.get("policy")
182
+ if policy in {"current", "existing"}:
183
+ diagnostic.extend(["--mcp-runtime", policy])
184
+ diagnostic.append("--json")
185
+ print(
186
+ f"Run `{_format_command(diagnostic)}` for the complete diagnostic result.",
187
+ file=sys.stderr,
188
+ )
189
+
190
+
90
191
  def main(argv: Sequence[str] | None = None) -> int:
91
192
  _configure_stdio_utf8()
92
193
  parser = build_parser()
@@ -96,8 +197,22 @@ def main(argv: Sequence[str] | None = None) -> int:
96
197
  output = _init(args)
97
198
  elif args.command == "install":
98
199
  from .installer import install_codex, install_hermes
99
- installer = install_codex if args.host == "codex" else install_hermes
100
- output = installer(vault_path=args.vault)
200
+
201
+ if args.host == "codex":
202
+ if args.mcp_runtime != "auto":
203
+ output = {
204
+ "status": "failure",
205
+ "stage": "arguments",
206
+ "reason": "--mcp-runtime applies only to Hermes installations",
207
+ "vault": str(args.vault) if args.vault is not None else None,
208
+ }
209
+ else:
210
+ output = install_codex(vault_path=args.vault)
211
+ else:
212
+ install_kwargs = {"vault_path": args.vault}
213
+ if args.mcp_runtime != "auto":
214
+ install_kwargs["mcp_runtime"] = args.mcp_runtime
215
+ output = install_hermes(**install_kwargs)
101
216
  elif args.command == "host-event":
102
217
  output = _host_event(args)
103
218
  else: # pragma: no cover - argparse requires a known subcommand.
@@ -116,7 +231,8 @@ def main(argv: Sequence[str] | None = None) -> int:
116
231
  if getattr(args, "json", False):
117
232
  print(json.dumps({"error": "initialization failed"}, ensure_ascii=False))
118
233
  else:
119
- print("memleaf init failed", file=sys.stderr)
234
+ action = getattr(args, "command", "init")
235
+ print(f"memleaf {action} failed unexpectedly", file=sys.stderr)
120
236
  return 1
121
237
 
122
238
  if args.command == "host-event":
@@ -139,22 +255,16 @@ def main(argv: Sequence[str] | None = None) -> int:
139
255
  if output.get("vault_source") == "hermes_config":
140
256
  print("Preserved the Vault from the existing Hermes memleaf configuration.")
141
257
  if args.host == "hermes":
258
+ runtime = output.get("mcp_runtime")
259
+ if isinstance(runtime, dict) and runtime.get("selected_command"):
260
+ print(f"Hermes MCP runtime: {runtime['selected_command']}")
142
261
  print("Restart Hermes to use memleaf.")
143
262
  elif output.get("user_action_required"):
144
263
  print(f"Codex action required: {output.get('user_action')}")
145
264
  if output.get("model", {}).get("status") == "not_configured":
146
265
  print("Configure a memleaf model route before using automatic processing.")
147
266
  else:
148
- if args.host == "hermes" and (
149
- output.get("core_version") is not None
150
- or output.get("provider_version") is not None
151
- ):
152
- print(
153
- "memleaf versions: "
154
- f"core={output.get('core_version') or 'unknown'}, "
155
- f"Hermes provider={output.get('provider_version') or 'unknown'}"
156
- )
157
- print(f"memleaf install failed: {output.get('reason', 'unknown error')}", file=sys.stderr)
267
+ _print_install_failure(output, host=args.host)
158
268
  return 0 if output.get("status") in {"configured", "already_configured"} else 2
159
269
  if args.json:
160
270
  print(json.dumps(output, ensure_ascii=False, sort_keys=True))
@@ -309,7 +419,7 @@ def _prepare_model_route(
309
419
  candidate = discovery.selected
310
420
  diagnostics = list(discovery.diagnostics)
311
421
  if candidate is None:
312
- # An existing direct route is a safe idempotency fallback. It is not
422
+ # An existing direct route is a safe idempotency fallback. It is not
313
423
  # reported as a host discovery result and never appears with its key.
314
424
  candidate = _existing_memleaf_route(requested_vault / "config.yaml")
315
425
  if candidate is None and not dry_run and not non_interactive and sys.stdin.isatty():
@@ -1,8 +1,8 @@
1
1
  # Memleaf memory provider for Hermes
2
2
 
3
3
  This is a Hermes user-level memory-provider plugin for the local memleaf vault.
4
- It is separate from the `memleaf-mcp` MCP server, but uses the same
5
- `~/.memleaf` directory and executable.
4
+ It is separate from the `memleaf-mcp` MCP server, but uses the same Vault and
5
+ must use a version-compatible memleaf runtime.
6
6
 
7
7
  The native provider supplies a bounded Scope Map (no memory titles or bodies),
8
8
  captures visible Hermes turns, and automatically processes complete turns.
@@ -28,12 +28,36 @@ The optional provider config is `~/.hermes/memleaf.json`:
28
28
  ```json
29
29
  {
30
30
  "vault": "~/.memleaf",
31
- "command": "memleaf-mcp",
31
+ "command": "/absolute/path/to/memleaf-mcp",
32
32
  "timeout": 5,
33
- "process_timeout": 300
33
+ "process_timeout": 300,
34
+ "auto_process": true
34
35
  }
35
36
  ```
36
37
 
38
+ The separate persisted MCP entry uses the same executable and Vault:
39
+
40
+ ```yaml
41
+ mcp_servers:
42
+ memleaf:
43
+ command: /absolute/path/to/memleaf-mcp
44
+ args:
45
+ - --vault
46
+ - /absolute/path/to/vault
47
+ enabled: true
48
+ lazy: true
49
+ idle_timeout_seconds: 60
50
+ ```
51
+
52
+ Use `python -m memleaf install` to configure both entries. The installer writes
53
+ the MCP entry through `hermes config set`, reads `config.yaml` back, and tests
54
+ that all 12 tools are discoverable before it reports success. When two memleaf
55
+ virtual environments are present, use `--mcp-runtime current` to migrate to the
56
+ runtime executing the installer or `--mcp-runtime existing` to retain an
57
+ already configured executable after an exact version check. See the
58
+ [Hermes MCP runtime guide](https://github.com/miffyblueboo/memleaf/blob/main/docs/hermes-mcp-runtime.md)
59
+ for the full multi-environment and manual-recovery procedure.
60
+
37
61
  Capture, stats, and context calls use the short `timeout`; model-backed
38
62
  processing uses `process_timeout` so a slow local model does not get mistaken
39
63
  for a dead MCP worker. A failed or timed-out request closes the MCP process;
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.23
2
+ version: 0.2.24
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -0,0 +1,349 @@
1
+ """Read-only inspection of Hermes' persisted memleaf MCP runtime.
2
+
3
+ The installer uses this module before mutating Hermes so an existing MCP entry
4
+ that points at a different virtual environment cannot be silently combined with
5
+ a newly installed MemoryProvider. The parser deliberately reuses the adapter's
6
+ small JSON/YAML reader and never executes a command discovered in user config.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ import json
13
+ import ntpath
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ from .adapters.hermes import _parse_json_or_yaml_mcp
19
+
20
+
21
+ _MEMLEAF_COMMAND_NAMES = frozenset({"memleaf-mcp", "memleaf-mcp.exe"})
22
+ _TRUE_VALUES = frozenset({"true", "1", "yes", "on"})
23
+ _FALSE_VALUES = frozenset({"false", "0", "no", "off"})
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class HermesMcpInspection:
28
+ """Safe, serializable description of one Hermes memleaf MCP entry."""
29
+
30
+ status: str
31
+ reason: str
32
+ config_path: str
33
+ expected_command: str
34
+ configured_command: str | None = None
35
+ configured_vault: str | None = None
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "status": self.status,
40
+ "reason": self.reason,
41
+ "config_path": self.config_path,
42
+ "expected_command": self.expected_command,
43
+ "configured_command": self.configured_command,
44
+ "configured_vault": self.configured_vault,
45
+ }
46
+
47
+
48
+ def _platform_name(platform: str | None) -> str:
49
+ return os.name if platform is None else platform
50
+
51
+
52
+ def _path_text(value: Path | str) -> str:
53
+ return os.path.expandvars(os.path.expanduser(str(value).strip()))
54
+
55
+
56
+ def _host_path_key(value: Path | str, *, platform: str | None = None) -> str:
57
+ """Normalize a host path without pretending a foreign path is local."""
58
+
59
+ text = _path_text(value)
60
+ if _platform_name(platform) == "nt":
61
+ return ntpath.normcase(ntpath.normpath(text.replace("/", "\\")))
62
+ return os.path.normcase(str(Path(text).expanduser().resolve(strict=False)))
63
+
64
+
65
+ def host_paths_equivalent(
66
+ left: Path | str,
67
+ right: Path | str,
68
+ *,
69
+ platform: str | None = None,
70
+ ) -> bool:
71
+ """Return whether two paths identify the same location on the host."""
72
+
73
+ effective_platform = _platform_name(platform)
74
+ if effective_platform == os.name:
75
+ try:
76
+ left_path = Path(left).expanduser()
77
+ right_path = Path(right).expanduser()
78
+ if left_path.exists() and right_path.exists():
79
+ return os.path.samefile(left_path, right_path)
80
+ except (OSError, TypeError, ValueError):
81
+ pass
82
+ try:
83
+ return _host_path_key(left, platform=effective_platform) == _host_path_key(
84
+ right, platform=effective_platform
85
+ )
86
+ except (OSError, TypeError, ValueError):
87
+ return False
88
+
89
+
90
+ def _command_basename(command: str, *, platform: str | None = None) -> str:
91
+ text = command.strip()
92
+ if _platform_name(platform) == "nt":
93
+ return ntpath.basename(text.replace("/", "\\")).casefold()
94
+ return Path(text).name.casefold()
95
+
96
+
97
+ def is_absolute_memleaf_command(
98
+ command: str,
99
+ *,
100
+ platform: str | None = None,
101
+ ) -> bool:
102
+ """Return whether ``command`` is an absolute memleaf-mcp executable path."""
103
+
104
+ if not isinstance(command, str) or not command.strip():
105
+ return False
106
+ text = _path_text(command)
107
+ if _command_basename(text, platform=platform) not in _MEMLEAF_COMMAND_NAMES:
108
+ return False
109
+ if _platform_name(platform) == "nt":
110
+ return ntpath.isabs(text.replace("/", "\\"))
111
+ return Path(text).is_absolute()
112
+
113
+
114
+ def _is_bare_memleaf_command(command: str, *, platform: str | None = None) -> bool:
115
+ text = command.strip()
116
+ return (
117
+ _command_basename(text, platform=platform) in _MEMLEAF_COMMAND_NAMES
118
+ and not is_absolute_memleaf_command(text, platform=platform)
119
+ and "/" not in text
120
+ and "\\" not in text
121
+ )
122
+
123
+
124
+ def _boolish_enabled(value: Any) -> bool | None:
125
+ if value is None:
126
+ return True
127
+ if isinstance(value, bool):
128
+ return value
129
+ if isinstance(value, str):
130
+ normalized = value.strip().casefold()
131
+ if normalized in _TRUE_VALUES:
132
+ return True
133
+ if normalized in _FALSE_VALUES:
134
+ return False
135
+ return None
136
+
137
+
138
+ def _vault_from_args(args: Any) -> str | None:
139
+ if not isinstance(args, Sequence) or isinstance(args, (str, bytes, bytearray)):
140
+ return None
141
+ values = [str(item) for item in args]
142
+ if len(values) == 2 and values[0] == "--vault" and values[1].strip():
143
+ return values[1].strip()
144
+ if len(values) == 1 and values[0].startswith("--vault="):
145
+ value = values[0].split("=", 1)[1].strip()
146
+ return value or None
147
+ return None
148
+
149
+
150
+ def _result(
151
+ status: str,
152
+ reason: str,
153
+ *,
154
+ config_path: Path,
155
+ expected_command: Path | str,
156
+ configured_command: str | None = None,
157
+ configured_vault: str | None = None,
158
+ ) -> HermesMcpInspection:
159
+ return HermesMcpInspection(
160
+ status=status,
161
+ reason=reason,
162
+ config_path=str(config_path),
163
+ expected_command=str(expected_command),
164
+ configured_command=configured_command,
165
+ configured_vault=configured_vault,
166
+ )
167
+
168
+
169
+ def inspect_hermes_mcp(
170
+ config_path: Path | str,
171
+ vault: Path | str,
172
+ expected_command: Path | str,
173
+ *,
174
+ platform: str | None = None,
175
+ ) -> HermesMcpInspection:
176
+ """Inspect ``mcp_servers.memleaf`` without executing configured commands.
177
+
178
+ Status values are:
179
+
180
+ ``absent``
181
+ No memleaf entry exists.
182
+ ``correct``
183
+ The entry uses the requested Vault and the same executable.
184
+ ``legacy``
185
+ The entry uses the requested Vault and a bare ``memleaf-mcp`` command;
186
+ it is safe for the installer to canonicalize to an absolute path.
187
+ ``runtime_conflict``
188
+ The Vault matches, but an absolute memleaf executable from a different
189
+ environment is configured. The installer must require an explicit
190
+ runtime policy before changing either side.
191
+ ``conflict``
192
+ The entry is disabled, points at another Vault, has invalid arguments,
193
+ or names a non-memleaf command.
194
+ ``unknown``
195
+ The configuration cannot be read safely.
196
+ """
197
+
198
+ path = Path(config_path)
199
+ if path.is_symlink():
200
+ return _result(
201
+ "unknown",
202
+ "Hermes config is a symlink; refusing automatic access",
203
+ config_path=path,
204
+ expected_command=expected_command,
205
+ )
206
+ if path.exists() and not path.is_file():
207
+ return _result(
208
+ "unknown",
209
+ "Hermes config is not a regular file",
210
+ config_path=path,
211
+ expected_command=expected_command,
212
+ )
213
+ if not path.exists():
214
+ return _result(
215
+ "absent",
216
+ "no persisted memleaf MCP entry was found",
217
+ config_path=path,
218
+ expected_command=expected_command,
219
+ )
220
+
221
+ try:
222
+ text = path.read_text(encoding="utf-8")
223
+ except (OSError, UnicodeError):
224
+ text = ""
225
+ parsed = None
226
+ else:
227
+ parsed = _parse_json_or_yaml_mcp(text)
228
+ # The adapter's compact parser historically returns None for a valid
229
+ # JSON object that simply has no mcp_servers key. Treat that shape as
230
+ # an absent entry, while retaining fail-closed behavior for malformed
231
+ # or type-invalid configuration.
232
+ if parsed is None:
233
+ try:
234
+ root = json.loads(text)
235
+ except (TypeError, ValueError):
236
+ root = None
237
+ if isinstance(root, Mapping) and root.get("mcp_servers") in (None, {}):
238
+ parsed = {}
239
+ if parsed is None:
240
+ return _result(
241
+ "unknown",
242
+ "Hermes config could not be parsed safely",
243
+ config_path=path,
244
+ expected_command=expected_command,
245
+ )
246
+
247
+ entry = parsed.get("memleaf")
248
+ if entry is None:
249
+ return _result(
250
+ "absent",
251
+ "no persisted memleaf MCP entry was found",
252
+ config_path=path,
253
+ expected_command=expected_command,
254
+ )
255
+ if not isinstance(entry, Mapping):
256
+ return _result(
257
+ "conflict",
258
+ "mcp_servers.memleaf is not an object",
259
+ config_path=path,
260
+ expected_command=expected_command,
261
+ )
262
+
263
+ enabled = _boolish_enabled(entry.get("enabled"))
264
+ command_value = entry.get("command")
265
+ command = command_value.strip() if isinstance(command_value, str) else None
266
+ configured_vault = _vault_from_args(entry.get("args"))
267
+
268
+ if enabled is not True:
269
+ reason = (
270
+ "the existing memleaf MCP entry is disabled"
271
+ if enabled is False
272
+ else "the existing memleaf MCP enabled value is invalid"
273
+ )
274
+ return _result(
275
+ "conflict",
276
+ reason,
277
+ config_path=path,
278
+ expected_command=expected_command,
279
+ configured_command=command,
280
+ configured_vault=configured_vault,
281
+ )
282
+ if not command:
283
+ return _result(
284
+ "conflict",
285
+ "the existing memleaf MCP command is missing or invalid",
286
+ config_path=path,
287
+ expected_command=expected_command,
288
+ configured_vault=configured_vault,
289
+ )
290
+ if configured_vault is None:
291
+ return _result(
292
+ "conflict",
293
+ "the existing memleaf MCP args must contain exactly one --vault path",
294
+ config_path=path,
295
+ expected_command=expected_command,
296
+ configured_command=command,
297
+ )
298
+ if not host_paths_equivalent(configured_vault, vault, platform=platform):
299
+ return _result(
300
+ "conflict",
301
+ "the existing memleaf MCP entry points at a different Vault",
302
+ config_path=path,
303
+ expected_command=expected_command,
304
+ configured_command=command,
305
+ configured_vault=configured_vault,
306
+ )
307
+ if host_paths_equivalent(command, expected_command, platform=platform):
308
+ return _result(
309
+ "correct",
310
+ "the existing memleaf MCP entry uses this runtime and Vault",
311
+ config_path=path,
312
+ expected_command=expected_command,
313
+ configured_command=command,
314
+ configured_vault=configured_vault,
315
+ )
316
+ if _is_bare_memleaf_command(command, platform=platform):
317
+ return _result(
318
+ "legacy",
319
+ "the existing entry uses a PATH-dependent memleaf-mcp command",
320
+ config_path=path,
321
+ expected_command=expected_command,
322
+ configured_command=command,
323
+ configured_vault=configured_vault,
324
+ )
325
+ if is_absolute_memleaf_command(command, platform=platform):
326
+ return _result(
327
+ "runtime_conflict",
328
+ "the existing MCP entry uses the same Vault but a different memleaf runtime",
329
+ config_path=path,
330
+ expected_command=expected_command,
331
+ configured_command=command,
332
+ configured_vault=configured_vault,
333
+ )
334
+ return _result(
335
+ "conflict",
336
+ "the existing MCP command is not a recognized memleaf-mcp executable",
337
+ config_path=path,
338
+ expected_command=expected_command,
339
+ configured_command=command,
340
+ configured_vault=configured_vault,
341
+ )
342
+
343
+
344
+ __all__ = [
345
+ "HermesMcpInspection",
346
+ "host_paths_equivalent",
347
+ "inspect_hermes_mcp",
348
+ "is_absolute_memleaf_command",
349
+ ]