memleaf 0.2.17__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.17 → memleaf-0.2.18}/CHANGELOG.md +7 -0
  2. {memleaf-0.2.17/src/memleaf.egg-info → memleaf-0.2.18}/PKG-INFO +3 -3
  3. {memleaf-0.2.17 → memleaf-0.2.18}/README.en.md +2 -2
  4. {memleaf-0.2.17 → memleaf-0.2.18}/README.md +2 -2
  5. {memleaf-0.2.17 → memleaf-0.2.18}/install.sh +17 -0
  6. {memleaf-0.2.17 → memleaf-0.2.18}/pyproject.toml +1 -1
  7. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/__init__.py +1 -1
  8. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/hermes_provider/__init__.py +42 -6
  9. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  10. {memleaf-0.2.17 → 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.17 → memleaf-0.2.18}/src/memleaf/processing.py +155 -32
  13. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/prompts.py +6 -3
  14. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/validation.py +13 -1
  15. {memleaf-0.2.17 → memleaf-0.2.18/src/memleaf.egg-info}/PKG-INFO +3 -3
  16. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/SOURCES.txt +3 -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.17 → memleaf-0.2.18}/tests/test_hermes_provider.py +91 -3
  20. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_maintenance_v2.py +4 -2
  21. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_pypi_install.py +5 -5
  22. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_c1_mcp.py +1 -1
  23. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_c3_packaging.py +1 -1
  24. {memleaf-0.2.17 → memleaf-0.2.18}/IMPLEMENTATION_PLAN.md +0 -0
  25. {memleaf-0.2.17 → memleaf-0.2.18}/LICENSE +0 -0
  26. {memleaf-0.2.17 → memleaf-0.2.18}/MANIFEST.in +0 -0
  27. {memleaf-0.2.17 → memleaf-0.2.18}/RELEASE_CHECKLIST.md +0 -0
  28. {memleaf-0.2.17 → memleaf-0.2.18}/examples/README.md +0 -0
  29. {memleaf-0.2.17 → memleaf-0.2.18}/examples/basic_usage.py +0 -0
  30. {memleaf-0.2.17 → memleaf-0.2.18}/examples/mcp_stdio.ndjson +0 -0
  31. {memleaf-0.2.17 → memleaf-0.2.18}/install.ps1 +0 -0
  32. {memleaf-0.2.17 → memleaf-0.2.18}/setup.cfg +0 -0
  33. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/__main__.py +0 -0
  34. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/__init__.py +0 -0
  35. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/antigravity.py +0 -0
  36. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/base.py +0 -0
  37. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/codex.py +0 -0
  38. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/adapters/hermes.py +0 -0
  39. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/budget.py +0 -0
  40. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/capture.py +0 -0
  41. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/cli.py +0 -0
  42. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/compaction.py +0 -0
  43. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/config.py +0 -0
  44. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/credentials.py +0 -0
  45. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/frontmatter.py +0 -0
  46. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/hermes_provider/README.md +0 -0
  47. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/host_events.py +0 -0
  48. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/host_runtime.py +0 -0
  49. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/inbox.py +0 -0
  50. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/index.py +0 -0
  51. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/__init__.py +0 -0
  52. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/base.py +0 -0
  53. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/claude_compatible.py +0 -0
  54. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/gemini.py +0 -0
  55. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/openai_compatible.py +0 -0
  56. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/llm/router.py +0 -0
  57. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/locking.py +0 -0
  58. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/mcp_server.py +0 -0
  59. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/memory_writer.py +0 -0
  60. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/model_discovery.py +0 -0
  61. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/models.py +0 -0
  62. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/native_index.py +0 -0
  63. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/redaction.py +0 -0
  64. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/retrieval.py +0 -0
  65. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/retrieval_gate.py +0 -0
  66. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/scope_maintenance.py +0 -0
  67. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/scope_state.py +0 -0
  68. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/service.py +0 -0
  69. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf/vault.py +0 -0
  70. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/dependency_links.txt +0 -0
  71. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/entry_points.txt +0 -0
  72. {memleaf-0.2.17 → memleaf-0.2.18}/src/memleaf.egg-info/top_level.txt +0 -0
  73. {memleaf-0.2.17 → memleaf-0.2.18}/tests/__init__.py +0 -0
  74. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_admission_noise.py +0 -0
  75. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_codex_install.py +0 -0
  76. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_codex_native_cli.py +0 -0
  77. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_context_budget.py +0 -0
  78. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_credential_safety.py +0 -0
  79. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_cross_host_acceptance.py +0 -0
  80. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_extraction_quality_regressions.py +0 -0
  81. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_hermes_stdio_transport.py +0 -0
  82. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_host_events.py +0 -0
  83. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_host_runtime_contract.py +0 -0
  84. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_install.py +0 -0
  85. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_model_discovery.py +0 -0
  86. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_retrieval_gate.py +0 -0
  87. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_retrieval_v2.py +0 -0
  88. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_session_lineage.py +0 -0
  89. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_a.py +0 -0
  90. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b1.py +0 -0
  91. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b2a.py +0 -0
  92. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b2b.py +0 -0
  93. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3a_commit.py +0 -0
  94. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3a_contract.py +0 -0
  95. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3b_native_context.py +0 -0
  96. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3b_native_index.py +0 -0
  97. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3b_scope.py +0 -0
  98. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3c_retrieval.py +0 -0
  99. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  100. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_stage_c2_init.py +0 -0
  101. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_todo_state_recovery.py +0 -0
  102. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_update_target_recovery.py +0 -0
  103. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_upgrade_preserves_vault.py +0 -0
  104. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_gate_limits.py +0 -0
  105. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_host_flow.py +0 -0
  106. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_mcp_flow.py +0 -0
  107. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_nomatch_semantics.py +0 -0
  108. {memleaf-0.2.17 → memleaf-0.2.18}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,13 @@
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
+
5
12
  ## 0.2.17 — 2026-09-02
6
13
 
7
14
  - Preserve explicit user-confirmed completion or cancellation of one uniquely
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.17
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.17。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 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.17.**
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.17 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.17。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 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.17"
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.17"
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.17
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"]
@@ -230,6 +230,80 @@ def _automatic_transient_memory(value: Any) -> bool:
230
230
  return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
231
231
 
232
232
 
233
+ _CANDIDATE_LOOKUP_SPLIT = re.compile(
234
+ r"[;;。!?!?\n]+|[,,]\s*(?=(?:后续|今后|以后|每次|所有后续|going forward|from now on))"
235
+ r"|(?:同时|另外|此外|其次|并且|并要求|还需|另需|in addition|separately)",
236
+ re.IGNORECASE,
237
+ )
238
+
239
+
240
+ def _candidate_lookup_queries(value: Any) -> list[str]:
241
+ """Return bounded candidate-level lookup variants for cross-turn dedupe."""
242
+
243
+ if not isinstance(value, str) or not value.strip():
244
+ return []
245
+ text = value.strip()
246
+ values = [text]
247
+ values.extend(part.strip() for part in _CANDIDATE_LOOKUP_SPLIT.split(text) if part.strip())
248
+ result: list[str] = []
249
+ seen: set[str] = set()
250
+ for item in values:
251
+ normalized = normalize_term(item)
252
+ if len(normalized) < 4 or normalized in seen:
253
+ continue
254
+ seen.add(normalized)
255
+ result.append(item)
256
+ if len(result) >= 8:
257
+ break
258
+ return result
259
+
260
+
261
+ def _automatic_create_conflicts(
262
+ candidate: Mapping[str, Any],
263
+ summary: Mapping[str, Any],
264
+ related: Iterable[Mapping[str, Any]],
265
+ *,
266
+ ignore_memory_ids: Iterable[str] = (),
267
+ ) -> bool:
268
+ """Return true only when a finalized CREATE is already covered."""
269
+
270
+ candidate_text = str(candidate.get("memory", "")).strip()
271
+ queries = _candidate_lookup_queries(candidate_text)
272
+ summary_body = normalize_term(str(summary.get("body", "")))
273
+ candidate_type = candidate.get("type")
274
+ ignored = {
275
+ value.casefold() for value in ignore_memory_ids
276
+ if isinstance(value, str) and value
277
+ }
278
+ for raw in related:
279
+ if not isinstance(raw, Mapping):
280
+ continue
281
+ memory_id = raw.get("memory_id")
282
+ if isinstance(memory_id, str) and memory_id.casefold() in ignored:
283
+ # Requests already generated for this visible turn may have been
284
+ # partially persisted by a failed transaction. Ignore them here
285
+ # so replay can finish the remaining deterministic candidates.
286
+ continue
287
+ related_body = normalize_term(str(raw.get("body", "")))
288
+ if raw.get("native") is True:
289
+ related_text = normalize_term(
290
+ f"{raw.get('title', '')}\n{raw.get('body', '')}"
291
+ )
292
+ for query in queries:
293
+ normalized = normalize_term(query)
294
+ if len(normalized) >= 8 and related_text and (
295
+ normalized in related_text
296
+ or (len(related_text) >= 8 and related_text in normalized)
297
+ ):
298
+ return True
299
+ continue
300
+ if raw.get("type") != candidate_type:
301
+ continue
302
+ if summary_body and related_body == summary_body:
303
+ return True
304
+ return False
305
+
306
+
233
307
  def _todo_state_recovery_candidate(
234
308
  events: Iterable[Mapping[str, Any]],
235
309
  related: Iterable[Mapping[str, Any]],
@@ -1450,7 +1524,7 @@ class Processor:
1450
1524
  self,
1451
1525
  turn: InboxTurn,
1452
1526
  state: Mapping[str, Any],
1453
- query: str,
1527
+ query: str | Iterable[str],
1454
1528
  explicit_scope: Any = None,
1455
1529
  *,
1456
1530
  overlay: Iterable[Mapping[str, Any]] = (),
@@ -1464,7 +1538,15 @@ class Processor:
1464
1538
  list[dict[str, str]],
1465
1539
  Optional[tuple[list[Any], bool]],
1466
1540
  ]:
1467
- visible = query.strip() if isinstance(query, str) else ""
1541
+ if isinstance(query, str):
1542
+ query_value: str | list[str] = query.strip()
1543
+ else:
1544
+ query_value = [
1545
+ str(item).strip()
1546
+ for item in query
1547
+ if isinstance(item, str) and item.strip()
1548
+ ]
1549
+ visible = query_value if isinstance(query_value, str) else " ".join(query_value)
1468
1550
  scope = _safe_scope_background(state, explicit_scope)
1469
1551
  local: list[dict[str, Any]] = []
1470
1552
  indexed_native: list[dict[str, Any]] = []
@@ -1495,7 +1577,7 @@ class Processor:
1495
1577
  records = priority_records
1496
1578
  else:
1497
1579
  records = self.service._search_unlocked(
1498
- visible,
1580
+ query_value,
1499
1581
  scope=scope if scope else None,
1500
1582
  include_history=False,
1501
1583
  todo_status="all",
@@ -1512,7 +1594,7 @@ class Processor:
1512
1594
  records = [
1513
1595
  record
1514
1596
  for record in records
1515
- if candidate_matches_query(record.memory, visible)
1597
+ if candidate_matches_query(record.memory, query_value)
1516
1598
  ]
1517
1599
  if visible and not records and not priority_records and self._has_specific_scope(scope):
1518
1600
  scoped_records, ambiguous = self._scope_records_unlocked(scope)
@@ -1532,7 +1614,7 @@ class Processor:
1532
1614
  if visible:
1533
1615
  if not priority_only:
1534
1616
  indexed_native = NativeIndexer(self.service.vault).search_unlocked(
1535
- visible,
1617
+ query_value,
1536
1618
  target_agent=turn.source,
1537
1619
  for_context=False,
1538
1620
  limit=None,
@@ -1996,14 +2078,11 @@ class Processor:
1996
2078
  candidate: Mapping[str, Any],
1997
2079
  related: Iterable[Mapping[str, Any]],
1998
2080
  ) -> dict[str, Any]:
1999
- """Reuse one unambiguously matching project plan/constraint memory.
2000
-
2001
- The gate remains authoritative when it names a target. This narrow
2002
- fallback handles a common model omission: a project-plan candidate is
2003
- emitted as a new ``fact`` without ``update_memory_id`` even though the
2004
- same scoped plan is already active. The active target's type is the
2005
- canonical type; it is copied before summary validation so the target
2006
- type invariant remains enforced.
2081
+ """Reuse one unambiguous same-scope active memory before CREATE.
2082
+
2083
+ General same-type matching prevents a wrong gate target from escaping
2084
+ into a sibling CREATE. Project-plan candidates retain the specialized
2085
+ project-over-fact preference used by earlier maintenance behavior.
2007
2086
  """
2008
2087
 
2009
2088
  result = dict(candidate)
@@ -2014,14 +2093,52 @@ class Processor:
2014
2093
  or not isinstance(result.get("memory"), str)
2015
2094
  ):
2016
2095
  return result
2017
- if result.get("type") not in {"fact", "project"}:
2018
- return result
2019
- if not self._is_project_plan_title(result["memory"]):
2020
- return result
2021
2096
  project_keys = self._project_scope_keys(result.get("scopes"))
2022
2097
  if len(project_keys) != 1:
2023
2098
  return result
2024
2099
 
2100
+ candidate_type = result.get("type")
2101
+ candidate_text = result["memory"]
2102
+ same_type_matches: list[Memory] = []
2103
+ seen_same_type: set[str] = set()
2104
+ for item in related:
2105
+ if not isinstance(item, Mapping) or item.get("native") is True:
2106
+ continue
2107
+ memory_id = item.get("memory_id")
2108
+ if (
2109
+ not isinstance(memory_id, str)
2110
+ or item.get("type") != candidate_type
2111
+ or self._project_scope_keys(item.get("scopes")) != project_keys
2112
+ or memory_id.casefold() in seen_same_type
2113
+ ):
2114
+ continue
2115
+ try:
2116
+ target = Memory.from_mapping(item)
2117
+ except (TypeError, ValueError):
2118
+ continue
2119
+ target_title = normalize_term(target.title)
2120
+ candidate_normalized = normalize_term(candidate_text)
2121
+ if (
2122
+ len(target_title) < 4
2123
+ or target_title not in candidate_normalized
2124
+ or not candidate_matches_query(target, candidate_text)
2125
+ ):
2126
+ continue
2127
+ seen_same_type.add(memory_id.casefold())
2128
+ same_type_matches.append(target)
2129
+
2130
+ specialized_plan = (
2131
+ candidate_type in {"fact", "project"}
2132
+ and self._is_project_plan_title(candidate_text)
2133
+ )
2134
+ if not specialized_plan:
2135
+ if len(same_type_matches) > 1:
2136
+ result["_defer_reason"] = "ambiguous_update_target"
2137
+ return result
2138
+ if len(same_type_matches) == 1:
2139
+ result["update_memory_id"] = same_type_matches[0].memory_id
2140
+ return result
2141
+
2025
2142
  project_matches: list[Memory] = []
2026
2143
  fact_matches: list[Memory] = []
2027
2144
  seen: set[str] = set()
@@ -2051,10 +2168,6 @@ class Processor:
2051
2168
  else:
2052
2169
  fact_matches.append(target)
2053
2170
 
2054
- # Prefer a project target over a same-topic fact. If there are
2055
- # multiple project targets, defer rather than creating a sibling by
2056
- # guessing; the same conservative rule applies when only multiple
2057
- # durable fact targets remain.
2058
2171
  if len(project_matches) > 1 or (not project_matches and len(fact_matches) > 1):
2059
2172
  result["_defer_reason"] = "ambiguous_update_target"
2060
2173
  return result
@@ -2065,8 +2178,6 @@ class Processor:
2065
2178
  else:
2066
2179
  return result
2067
2180
  result["update_memory_id"] = target.memory_id
2068
- # The update target's type is immutable. Correct a model's fact-vs-
2069
- # project label only after the same-use target was identified.
2070
2181
  result["type"] = target.type
2071
2182
  return result
2072
2183
 
@@ -2530,6 +2641,7 @@ class Processor:
2530
2641
  )
2531
2642
  requests: list[dict[str, Any]] = []
2532
2643
  observed_scopes: list[str] = []
2644
+ current_turn_request_ids: set[str] = set()
2533
2645
  read_only_query = _automatic_read_only_query(events)
2534
2646
  for candidate in gate["candidates"]:
2535
2647
  candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
@@ -2614,7 +2726,7 @@ class Processor:
2614
2726
  candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
2615
2727
  turn,
2616
2728
  state,
2617
- str(candidate.get("memory", "")).strip(),
2729
+ _candidate_lookup_queries(candidate.get("memory")),
2618
2730
  candidate_scopes,
2619
2731
  overlay=self._planned_related,
2620
2732
  priority_memory_ids=[
@@ -2895,15 +3007,26 @@ class Processor:
2895
3007
  scope_source=summary.get("scope_source"),
2896
3008
  )
2897
3009
  continue
2898
- requests.append(
2899
- self._request(
2900
- summary,
2901
- turn,
2902
- candidate_id=str(candidate["candidate_id"]),
2903
- conversation_title=title,
2904
- native_refs=candidate_native_refs,
2905
- )
3010
+ pending_request = self._request(
3011
+ summary,
3012
+ turn,
3013
+ candidate_id=str(candidate["candidate_id"]),
3014
+ conversation_title=title,
3015
+ native_refs=candidate_native_refs,
2906
3016
  )
3017
+ current_turn_request_ids.add(pending_request["memory_id"].casefold())
3018
+ summary_update_target = summary.get("update_memory_id")
3019
+ final_is_create = not (
3020
+ isinstance(summary_update_target, str) and summary_update_target
3021
+ )
3022
+ if final_is_create and _automatic_create_conflicts(
3023
+ candidate,
3024
+ summary,
3025
+ candidate_related,
3026
+ ignore_memory_ids=current_turn_request_ids,
3027
+ ):
3028
+ continue
3029
+ requests.append(pending_request)
2907
3030
  for observed_scope in summary["scopes"]:
2908
3031
  if (
2909
3032
  isinstance(observed_scope, str)