memleaf 0.2.16__tar.gz → 0.2.18__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 (108) hide show
  1. {memleaf-0.2.16 → memleaf-0.2.18}/CHANGELOG.md +17 -0
  2. {memleaf-0.2.16/src/memleaf.egg-info → memleaf-0.2.18}/PKG-INFO +3 -3
  3. {memleaf-0.2.16 → memleaf-0.2.18}/README.en.md +2 -2
  4. {memleaf-0.2.16 → memleaf-0.2.18}/README.md +2 -2
  5. {memleaf-0.2.16 → memleaf-0.2.18}/install.sh +17 -0
  6. {memleaf-0.2.16 → memleaf-0.2.18}/pyproject.toml +1 -1
  7. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/__init__.py +1 -1
  8. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/hermes_provider/__init__.py +42 -6
  9. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  10. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/installer.py +15 -0
  11. memleaf-0.2.18/src/memleaf/native_registration.py +123 -0
  12. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/processing.py +414 -47
  13. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/prompts.py +22 -3
  14. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/validation.py +13 -1
  15. {memleaf-0.2.16 → memleaf-0.2.18/src/memleaf.egg-info}/PKG-INFO +3 -3
  16. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf.egg-info/SOURCES.txt +4 -0
  17. memleaf-0.2.18/tests/test_cross_turn_dedupe_regressions.py +195 -0
  18. memleaf-0.2.18/tests/test_hermes_native_registration.py +144 -0
  19. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_hermes_provider.py +91 -3
  20. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_maintenance_v2.py +4 -2
  21. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_pypi_install.py +5 -5
  22. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_c1_mcp.py +1 -1
  23. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_c3_packaging.py +1 -1
  24. memleaf-0.2.18/tests/test_todo_state_recovery.py +165 -0
  25. {memleaf-0.2.16 → memleaf-0.2.18}/IMPLEMENTATION_PLAN.md +0 -0
  26. {memleaf-0.2.16 → memleaf-0.2.18}/LICENSE +0 -0
  27. {memleaf-0.2.16 → memleaf-0.2.18}/MANIFEST.in +0 -0
  28. {memleaf-0.2.16 → memleaf-0.2.18}/RELEASE_CHECKLIST.md +0 -0
  29. {memleaf-0.2.16 → memleaf-0.2.18}/examples/README.md +0 -0
  30. {memleaf-0.2.16 → memleaf-0.2.18}/examples/basic_usage.py +0 -0
  31. {memleaf-0.2.16 → memleaf-0.2.18}/examples/mcp_stdio.ndjson +0 -0
  32. {memleaf-0.2.16 → memleaf-0.2.18}/install.ps1 +0 -0
  33. {memleaf-0.2.16 → memleaf-0.2.18}/setup.cfg +0 -0
  34. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/__main__.py +0 -0
  35. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/adapters/__init__.py +0 -0
  36. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/adapters/antigravity.py +0 -0
  37. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/adapters/base.py +0 -0
  38. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/adapters/codex.py +0 -0
  39. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/adapters/hermes.py +0 -0
  40. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/budget.py +0 -0
  41. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/capture.py +0 -0
  42. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/cli.py +0 -0
  43. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/compaction.py +0 -0
  44. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/config.py +0 -0
  45. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/credentials.py +0 -0
  46. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/frontmatter.py +0 -0
  47. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/hermes_provider/README.md +0 -0
  48. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/host_events.py +0 -0
  49. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/host_runtime.py +0 -0
  50. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/inbox.py +0 -0
  51. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/index.py +0 -0
  52. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/llm/__init__.py +0 -0
  53. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/llm/base.py +0 -0
  54. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/llm/claude_compatible.py +0 -0
  55. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/llm/gemini.py +0 -0
  56. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/llm/openai_compatible.py +0 -0
  57. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/llm/router.py +0 -0
  58. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/locking.py +0 -0
  59. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/mcp_server.py +0 -0
  60. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/memory_writer.py +0 -0
  61. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/model_discovery.py +0 -0
  62. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/models.py +0 -0
  63. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/native_index.py +0 -0
  64. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/redaction.py +0 -0
  65. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/retrieval.py +0 -0
  66. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/retrieval_gate.py +0 -0
  67. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/scope_maintenance.py +0 -0
  68. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/scope_state.py +0 -0
  69. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/service.py +0 -0
  70. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf/vault.py +0 -0
  71. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf.egg-info/dependency_links.txt +0 -0
  72. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf.egg-info/entry_points.txt +0 -0
  73. {memleaf-0.2.16 → memleaf-0.2.18}/src/memleaf.egg-info/top_level.txt +0 -0
  74. {memleaf-0.2.16 → memleaf-0.2.18}/tests/__init__.py +0 -0
  75. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_admission_noise.py +0 -0
  76. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_codex_install.py +0 -0
  77. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_codex_native_cli.py +0 -0
  78. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_context_budget.py +0 -0
  79. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_credential_safety.py +0 -0
  80. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_cross_host_acceptance.py +0 -0
  81. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_extraction_quality_regressions.py +0 -0
  82. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_hermes_stdio_transport.py +0 -0
  83. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_host_events.py +0 -0
  84. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_host_runtime_contract.py +0 -0
  85. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_install.py +0 -0
  86. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_model_discovery.py +0 -0
  87. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_retrieval_gate.py +0 -0
  88. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_retrieval_v2.py +0 -0
  89. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_session_lineage.py +0 -0
  90. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_a.py +0 -0
  91. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b1.py +0 -0
  92. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b2a.py +0 -0
  93. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b2b.py +0 -0
  94. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3a_commit.py +0 -0
  95. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3a_contract.py +0 -0
  96. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3b_native_context.py +0 -0
  97. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3b_native_index.py +0 -0
  98. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3b_scope.py +0 -0
  99. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3c_retrieval.py +0 -0
  100. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  101. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_stage_c2_init.py +0 -0
  102. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_update_target_recovery.py +0 -0
  103. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_upgrade_preserves_vault.py +0 -0
  104. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_v2_gate_limits.py +0 -0
  105. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_v2_host_flow.py +0 -0
  106. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_v2_mcp_flow.py +0 -0
  107. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_v2_nomatch_semantics.py +0 -0
  108. {memleaf-0.2.16 → memleaf-0.2.18}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.18 — 2026-09-02
6
+
7
+ - Register Hermes native `MEMORY.md` and `USER.md` as private, read-only native sources during supported install/upgrade flows while preserving custom native source configuration.
8
+ - Harden cross-turn deduplication and UPDATE/NO_CHANGE decisions: split independent future-use clauses, reuse one unambiguous same-scope active target, treat exact active duplicates as deterministic no-ops, and keep crash replay idempotent.
9
+ - Preserve the existing bounded Scope Map / metadata-directory retrieval flow without adding extra scope scans during automatic deduplication.
10
+ - Distinguish Hermes controlled read audit states: record `FOUND_READ` only after a current-token successful read, and use `FOUND_NO_READ_UNDETERMINED` when a FOUND result has no provable controlled read instead of overstating Soft Gate guarantees.
11
+
12
+ ## 0.2.17 — 2026-09-02
13
+
14
+ - Preserve explicit user-confirmed completion or cancellation of one uniquely
15
+ related active todo as an in-place state update, even when the gate omits the
16
+ candidate or the same message asks what remains.
17
+ - Keep todo-state recovery deterministic and bounded: reuse the existing
18
+ memory ID, anchor `completed_at` to the user event, archive the old version,
19
+ and reject queries, negation, future intent, uncertainty, assistant-only
20
+ claims, and ambiguous targets.
21
+
5
22
  ## 0.2.16 — 2026-09-02
6
23
 
7
24
  - Keep read-only operational queries, derived overdue counters, and one-time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.16
3
+ Version: 0.2.18
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.16。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 PyPI 分发。
26
+ > **当前版本:0.2.18。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 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.16.**
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.16 is distributed through PyPI.
7
+ > **Version: 0.2.18.**
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.18 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.16。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 PyPI 分发。
7
+ > **当前版本:0.2.18。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.18 通过 PyPI 分发。
9
9
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
10
10
 
11
11
  ## 项目定位
@@ -347,6 +347,23 @@ PY
347
347
  die "Hermes memleaf MCP configuration/test failed"
348
348
  fi
349
349
 
350
+ # Register Hermes' own MEMORY.md and USER.md as private, read-only native
351
+ # sources only after both provider and MCP checks have succeeded. Missing
352
+ # files remain a valid indexed state and are never created by memleaf.
353
+ "$venv_path/bin/python" - "$vault_path" "$hermes_home" <<'PY'
354
+ from __future__ import annotations
355
+
356
+ import sys
357
+ from pathlib import Path
358
+
359
+ from memleaf.native_registration import ensure_hermes_native_sources
360
+ from memleaf.vault import Vault
361
+
362
+ vault = Vault(Path(sys.argv[1]).expanduser().resolve())
363
+ hermes_home = Path(sys.argv[2]).expanduser().resolve()
364
+ ensure_hermes_native_sources(vault, hermes_home)
365
+ PY
366
+
350
367
  # The MCP status is written only after its own official test succeeds. This
351
368
  # merge changes no Provider or other host fields.
352
369
  "$venv_path/bin/python" - "$vault_path" <<'PY'
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memleaf"
7
- version = "0.2.16"
7
+ version = "0.2.18"
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.16"
3
+ __version__ = "0.2.18"
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
@@ -1181,6 +1181,7 @@ class MemleafMemoryProvider(MemoryProvider):
1181
1181
  # under the original session id after the chain is restored.
1182
1182
  self._deferred_process_sessions: "OrderedDict[str, None]" = OrderedDict()
1183
1183
  self._last_retrieval_observation = "unknown"
1184
+ self._last_retrieval_audit = "SEARCH_UNKNOWN"
1184
1185
 
1185
1186
  @property
1186
1187
  def name(self) -> str:
@@ -1873,6 +1874,7 @@ class MemleafMemoryProvider(MemoryProvider):
1873
1874
  self._pending_lineage.clear()
1874
1875
  self._deferred_process_sessions.clear()
1875
1876
  self._last_retrieval_observation = "unknown"
1877
+ self._last_retrieval_audit = "SEARCH_UNKNOWN"
1876
1878
  self._version_warning_emitted = False
1877
1879
  if self._client is not None:
1878
1880
  self._client.close()
@@ -2117,6 +2119,7 @@ class MemleafMemoryProvider(MemoryProvider):
2117
2119
  turn_id: str = "",
2118
2120
  vault_root: Optional[Path] = None,
2119
2121
  seen_call_keys: Any = None,
2122
+ audit_state: Optional[dict[str, Any]] = None,
2120
2123
  ) -> str:
2121
2124
  """Observe explicit host MCP calls in public messages.
2122
2125
 
@@ -2152,6 +2155,7 @@ class MemleafMemoryProvider(MemoryProvider):
2152
2155
 
2153
2156
  read_results_used: set[int] = set()
2154
2157
  read_sequence = 0
2158
+ controlled_reads = 0
2155
2159
  read_ordinal = 0
2156
2160
  for call in calls:
2157
2161
  if call.get("name") != "mcp__memleaf__read":
@@ -2175,6 +2179,8 @@ class MemleafMemoryProvider(MemoryProvider):
2175
2179
  if not _record_tool_observation(seen_call_keys, observation_key):
2176
2180
  continue
2177
2181
  read_sequence += 1
2182
+ if result_status == "ok" and retrieval_match:
2183
+ controlled_reads += 1
2178
2184
  logger.info(
2179
2185
  "memleaf retrieval-read source=hermes session=%s turn=%s read_seq=%d retrieval_present=%s retrieval_match=%s result=%s",
2180
2186
  _safe_component(session_id, "none") if session_id else "none",
@@ -2202,12 +2208,39 @@ class MemleafMemoryProvider(MemoryProvider):
2202
2208
  "detected" if bypass else "not_detected",
2203
2209
  )
2204
2210
  if not statuses:
2205
- return "unknown"
2206
- if "found" in statuses:
2207
- return "found"
2208
- if "no_match" in statuses:
2209
- return "no_match"
2210
- return "error"
2211
+ search_status = "unknown"
2212
+ audit_status = "SEARCH_UNKNOWN"
2213
+ elif "found" in statuses:
2214
+ search_status = "found"
2215
+ # Hermes exposes no public pre-final hook and no reliable signal
2216
+ # that proves whether an answer used historical memory when no
2217
+ # controlled read occurred. Do not fabricate FOUND_NOT_USED or
2218
+ # FOUND_REQUIRED_READ_MISSING; record the uncertainty explicitly.
2219
+ audit_status = "FOUND_READ" if controlled_reads else "FOUND_NO_READ_UNDETERMINED"
2220
+ elif "no_match" in statuses:
2221
+ search_status = "no_match"
2222
+ audit_status = "NO_MATCH"
2223
+ else:
2224
+ search_status = "error"
2225
+ audit_status = "ERROR"
2226
+ if isinstance(audit_state, dict):
2227
+ audit_state.clear()
2228
+ audit_state.update(
2229
+ {
2230
+ "status": audit_status,
2231
+ "search_status": search_status,
2232
+ "controlled_reads": controlled_reads,
2233
+ }
2234
+ )
2235
+ logger.info(
2236
+ "memleaf retrieval-audit source=hermes session=%s turn=%s status=%s search=%s controlled_reads=%d",
2237
+ _safe_component(session_id, "none") if session_id else "none",
2238
+ _safe_component(turn_id, "none") if turn_id else "none",
2239
+ audit_status,
2240
+ search_status,
2241
+ controlled_reads,
2242
+ )
2243
+ return search_status
2211
2244
 
2212
2245
  @staticmethod
2213
2246
  def _catalog_retrieval_id(value: Any) -> Optional[str]:
@@ -2335,6 +2368,7 @@ class MemleafMemoryProvider(MemoryProvider):
2335
2368
  if retrieval_id is None and resolved_turn_number is None:
2336
2369
  retrieval_id = self._current_gate_id(effective_session)
2337
2370
  if self._gate_enabled:
2371
+ audit_state: dict[str, Any] = {}
2338
2372
  observation = self._observe_search_messages(
2339
2373
  messages,
2340
2374
  retrieval_id,
@@ -2342,10 +2376,12 @@ class MemleafMemoryProvider(MemoryProvider):
2342
2376
  turn_id=turn_id,
2343
2377
  vault_root=_resolve_vault(self._config()),
2344
2378
  seen_call_keys=self._observed_tool_call_keys,
2379
+ audit_state=audit_state,
2345
2380
  )
2346
2381
  while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
2347
2382
  self._observed_tool_call_keys.popitem(last=False)
2348
2383
  self._last_retrieval_observation = observation
2384
+ self._last_retrieval_audit = str(audit_state.get("status") or "SEARCH_UNKNOWN")
2349
2385
  lineage_ready = self._retry_pending_lineage(effective_session)
2350
2386
  for role, content in visible_events:
2351
2387
  if not self._capture_visible(
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.16
2
+ version: 0.2.18
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -22,6 +22,7 @@ from .adapters.codex import CodexAdapter
22
22
  from .adapters.hermes import HermesAdapter, hermes_home_for_platform
23
23
  from .cli import _home_from_environment, _prepare_model_route
24
24
  from .locking import atomic_write_json
25
+ from .native_registration import ensure_hermes_native_sources
25
26
  from .vault import Vault
26
27
 
27
28
 
@@ -415,6 +416,19 @@ def install_hermes(*, vault_path: Path | None = None) -> dict[str, Any]:
415
416
  "model": model,
416
417
  }
417
418
 
419
+ try:
420
+ native_registration = ensure_hermes_native_sources(vault, hermes_home)
421
+ except Exception:
422
+ return {
423
+ "status": "failure",
424
+ "reason": "Hermes native memory sources could not be registered safely",
425
+ "core_version": core_version,
426
+ "provider_version": provider_version,
427
+ "vault": str(vault.root),
428
+ "provider": str(provider_path),
429
+ "model": model,
430
+ }
431
+
418
432
  update_agents_index(
419
433
  vault.agents_index_path,
420
434
  {
@@ -443,6 +457,7 @@ def install_hermes(*, vault_path: Path | None = None) -> dict[str, Any]:
443
457
  "provider": str(provider_path),
444
458
  "mcp_command": str(command),
445
459
  "model": model,
460
+ "native_sources": native_registration,
446
461
  }
447
462
 
448
463
 
@@ -0,0 +1,123 @@
1
+ """Host-native source registration helpers.
2
+
3
+ This module owns only configuration of read-only native sources. It never
4
+ creates, writes, renames, or removes a host's native memory files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from copy import deepcopy
10
+ from pathlib import Path
11
+ from typing import Any, Mapping
12
+
13
+ from .config import save_config
14
+ from .native_index import NativeIndexer, validate_native_sources
15
+
16
+
17
+ _HERMES_SOURCES = (
18
+ ("hermes_memory", "hermes_memory_builtin", "MEMORY.md"),
19
+ ("hermes_user", "hermes_user_builtin", "USER.md"),
20
+ )
21
+
22
+
23
+ def _same_path(left: str | Path, right: str | Path) -> bool:
24
+ try:
25
+ return Path(left).expanduser().resolve(strict=False) == Path(right).expanduser().resolve(strict=False)
26
+ except (OSError, RuntimeError, ValueError):
27
+ return False
28
+
29
+
30
+ def _desired(path: Path) -> dict[str, Any]:
31
+ return {
32
+ "agent": "hermes",
33
+ "path": str(path.expanduser().resolve(strict=False)),
34
+ "share": False,
35
+ "format": "markdown",
36
+ }
37
+
38
+
39
+ def _safe_existing_for_hermes(source: Mapping[str, Any], path: Path) -> bool:
40
+ return (
41
+ source.get("agent") == "hermes"
42
+ and source.get("share") is False
43
+ and source.get("format", "markdown") == "markdown"
44
+ and _same_path(str(source.get("path", "")), path)
45
+ )
46
+
47
+
48
+ def ensure_hermes_native_sources(vault: Any, hermes_home: Path | str) -> dict[str, Any]:
49
+ """Idempotently register Hermes MEMORY.md and USER.md as private sources.
50
+
51
+ Existing user-defined native sources are preserved. A semantic conflict
52
+ fails closed instead of silently changing the user's source or exposing a
53
+ private Hermes file to another agent.
54
+ """
55
+
56
+ home = Path(hermes_home).expanduser().resolve(strict=False)
57
+ targets = [(canonical, fallback, home / "memories" / filename) for canonical, fallback, filename in _HERMES_SOURCES]
58
+
59
+ with vault.lock():
60
+ config = vault.config()
61
+ existing_value = config.get("native_sources", {})
62
+ if not isinstance(existing_value, Mapping):
63
+ raise RuntimeError("invalid native_sources configuration")
64
+ sources: dict[str, dict[str, Any]] = {
65
+ str(source_id): deepcopy(dict(source))
66
+ for source_id, source in existing_value.items()
67
+ if isinstance(source_id, str) and isinstance(source, Mapping)
68
+ }
69
+ # Validate before touching anything. This also catches duplicate paths
70
+ # already present in a manually edited configuration.
71
+ validate_native_sources(sources, base_dir=vault.root)
72
+ changed = False
73
+ registered: dict[str, str] = {}
74
+
75
+ for canonical, fallback, path in targets:
76
+ desired = _desired(path)
77
+ path_owner = next(
78
+ (
79
+ source_id
80
+ for source_id, source in sources.items()
81
+ if _same_path(str(source.get("path", "")), path)
82
+ ),
83
+ None,
84
+ )
85
+ if path_owner is not None:
86
+ current = sources[path_owner]
87
+ if not _safe_existing_for_hermes(current, path):
88
+ raise RuntimeError(f"unsafe Hermes native source conflict: {path_owner}")
89
+ registered[canonical] = path_owner
90
+ continue
91
+
92
+ target_id = canonical
93
+ if target_id in sources:
94
+ if _safe_existing_for_hermes(sources[target_id], path):
95
+ registered[canonical] = target_id
96
+ continue
97
+ target_id = fallback
98
+ if target_id in sources and not _safe_existing_for_hermes(sources[target_id], path):
99
+ raise RuntimeError(f"Hermes native source id conflict: {canonical}")
100
+
101
+ if target_id not in sources:
102
+ sources[target_id] = desired
103
+ changed = True
104
+ registered[canonical] = target_id
105
+
106
+ if changed:
107
+ updated = deepcopy(dict(config))
108
+ updated["native_sources"] = sources
109
+ save_config(vault.config_path, updated)
110
+
111
+ # Refresh the derived index under the same vault lock. Missing native
112
+ # files are a valid state and are represented as unavailable entries;
113
+ # this function never creates them.
114
+ index_summary = NativeIndexer(vault).refresh_unlocked()
115
+
116
+ return {
117
+ "changed": changed,
118
+ "sources": registered,
119
+ "index": index_summary,
120
+ }
121
+
122
+
123
+ __all__ = ["ensure_hermes_native_sources"]