memleaf 0.2.15__tar.gz → 0.2.16__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 (104) hide show
  1. {memleaf-0.2.15 → memleaf-0.2.16}/CHANGELOG.md +9 -0
  2. {memleaf-0.2.15/src/memleaf.egg-info → memleaf-0.2.16}/PKG-INFO +3 -3
  3. {memleaf-0.2.15 → memleaf-0.2.16}/README.en.md +2 -2
  4. {memleaf-0.2.15 → memleaf-0.2.16}/README.md +2 -2
  5. {memleaf-0.2.15 → memleaf-0.2.16}/pyproject.toml +1 -1
  6. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/hermes_provider/__init__.py +60 -2
  8. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/processing.py +58 -0
  10. {memleaf-0.2.15 → memleaf-0.2.16/src/memleaf.egg-info}/PKG-INFO +3 -3
  11. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_admission_noise.py +161 -28
  12. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_hermes_provider.py +76 -5
  13. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_pypi_install.py +5 -5
  14. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_c1_mcp.py +1 -1
  15. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_c3_packaging.py +1 -1
  16. {memleaf-0.2.15 → memleaf-0.2.16}/IMPLEMENTATION_PLAN.md +0 -0
  17. {memleaf-0.2.15 → memleaf-0.2.16}/LICENSE +0 -0
  18. {memleaf-0.2.15 → memleaf-0.2.16}/MANIFEST.in +0 -0
  19. {memleaf-0.2.15 → memleaf-0.2.16}/RELEASE_CHECKLIST.md +0 -0
  20. {memleaf-0.2.15 → memleaf-0.2.16}/examples/README.md +0 -0
  21. {memleaf-0.2.15 → memleaf-0.2.16}/examples/basic_usage.py +0 -0
  22. {memleaf-0.2.15 → memleaf-0.2.16}/examples/mcp_stdio.ndjson +0 -0
  23. {memleaf-0.2.15 → memleaf-0.2.16}/install.ps1 +0 -0
  24. {memleaf-0.2.15 → memleaf-0.2.16}/install.sh +0 -0
  25. {memleaf-0.2.15 → memleaf-0.2.16}/setup.cfg +0 -0
  26. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/__main__.py +0 -0
  27. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/__init__.py +0 -0
  28. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/antigravity.py +0 -0
  29. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/base.py +0 -0
  30. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/codex.py +0 -0
  31. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/adapters/hermes.py +0 -0
  32. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/budget.py +0 -0
  33. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/capture.py +0 -0
  34. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/cli.py +0 -0
  35. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/compaction.py +0 -0
  36. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/config.py +0 -0
  37. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/credentials.py +0 -0
  38. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/frontmatter.py +0 -0
  39. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/hermes_provider/README.md +0 -0
  40. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/host_events.py +0 -0
  41. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/host_runtime.py +0 -0
  42. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/inbox.py +0 -0
  43. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/index.py +0 -0
  44. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/installer.py +0 -0
  45. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/__init__.py +0 -0
  46. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/base.py +0 -0
  47. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/claude_compatible.py +0 -0
  48. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/gemini.py +0 -0
  49. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/openai_compatible.py +0 -0
  50. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/llm/router.py +0 -0
  51. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/locking.py +0 -0
  52. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/mcp_server.py +0 -0
  53. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/memory_writer.py +0 -0
  54. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/model_discovery.py +0 -0
  55. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/models.py +0 -0
  56. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/native_index.py +0 -0
  57. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/prompts.py +0 -0
  58. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/redaction.py +0 -0
  59. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/retrieval.py +0 -0
  60. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/retrieval_gate.py +0 -0
  61. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/scope_maintenance.py +0 -0
  62. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/scope_state.py +0 -0
  63. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/service.py +0 -0
  64. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/validation.py +0 -0
  65. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf/vault.py +0 -0
  66. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/SOURCES.txt +0 -0
  67. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/dependency_links.txt +0 -0
  68. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/entry_points.txt +0 -0
  69. {memleaf-0.2.15 → memleaf-0.2.16}/src/memleaf.egg-info/top_level.txt +0 -0
  70. {memleaf-0.2.15 → memleaf-0.2.16}/tests/__init__.py +0 -0
  71. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_codex_install.py +0 -0
  72. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_codex_native_cli.py +0 -0
  73. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_context_budget.py +0 -0
  74. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_credential_safety.py +0 -0
  75. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_cross_host_acceptance.py +0 -0
  76. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_extraction_quality_regressions.py +0 -0
  77. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_hermes_stdio_transport.py +0 -0
  78. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_host_events.py +0 -0
  79. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_host_runtime_contract.py +0 -0
  80. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_install.py +0 -0
  81. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_maintenance_v2.py +0 -0
  82. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_model_discovery.py +0 -0
  83. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_retrieval_gate.py +0 -0
  84. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_retrieval_v2.py +0 -0
  85. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_session_lineage.py +0 -0
  86. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_a.py +0 -0
  87. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b1.py +0 -0
  88. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b2a.py +0 -0
  89. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b2b.py +0 -0
  90. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3a_commit.py +0 -0
  91. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3a_contract.py +0 -0
  92. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3b_native_context.py +0 -0
  93. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3b_native_index.py +0 -0
  94. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3b_scope.py +0 -0
  95. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3c_retrieval.py +0 -0
  96. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  97. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_stage_c2_init.py +0 -0
  98. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_update_target_recovery.py +0 -0
  99. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_upgrade_preserves_vault.py +0 -0
  100. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_gate_limits.py +0 -0
  101. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_host_flow.py +0 -0
  102. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_mcp_flow.py +0 -0
  103. {memleaf-0.2.15 → memleaf-0.2.16}/tests/test_v2_nomatch_semantics.py +0 -0
  104. {memleaf-0.2.15 → memleaf-0.2.16}/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.16 — 2026-09-02
6
+
7
+ - Keep read-only operational queries, derived overdue counters, and one-time
8
+ execution receipts out of permanent-memory extraction; preserve explicitly
9
+ confirmed durable facts and preferences.
10
+ - Deduplicate Hermes tool observations across cumulative conversation payloads,
11
+ preventing historical reads from being re-audited as uncontrolled while
12
+ retaining diagnostics for genuinely mismatched current-turn reads.
13
+
5
14
  ## 0.2.15 — 2026-09-02
6
15
 
7
16
  - Recover a candidate whose `update_memory_id` has the wrong type at the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.15
3
+ Version: 0.2.16
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.15。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.15 通过 PyPI 分发。
26
+ > **当前版本:0.2.16。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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.15.**
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.15 is distributed through PyPI.
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.
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.15。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.15 通过 PyPI 分发。
7
+ > **当前版本:0.2.16。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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.15"
7
+ version = "0.2.16"
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.15"
3
+ __version__ = "0.2.16"
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
@@ -49,6 +49,7 @@ _MAX_PENDING_TURN_NUMBERS = 128
49
49
  _MAX_SESSION_ALIASES = 128
50
50
  _MAX_LINEAGE_RETRIES = 2
51
51
  _MAX_DEFERRED_PROCESS_SESSIONS = 128
52
+ _MAX_OBSERVED_TOOL_CALL_KEYS = 2048
52
53
  _SAFE_COMPONENT_RE = re.compile(r"[^A-Za-z0-9_.-]+")
53
54
  _PROVIDER_VERSION_RE = re.compile(r"^version:\s*([^\s#]+)\s*(?:#.*)?$", re.MULTILINE)
54
55
  _ASCII_QUERY_TERM_RE = re.compile(r"[a-z0-9]+(?:[ ._-][a-z0-9]+)*")
@@ -628,6 +629,45 @@ def _tool_result_for_call(
628
629
  return _CALL_FAILED
629
630
 
630
631
 
632
+ def _tool_observation_key(call: Mapping[str, Any], ordinal: int) -> str:
633
+ """Build a stable in-memory identity for one visible tool call.
634
+
635
+ Hermes can pass the complete conversation back on every turn. The
636
+ retrieval token and result status are turn-relative, so they must not be
637
+ part of the identity: otherwise an old successful read is reclassified as
638
+ ``uncontrolled_success`` when a newer token is observed. Call ids are
639
+ stable when Hermes supplies them; the ordinal/arguments fallback keeps
640
+ anonymous calls distinct within the cumulative message list.
641
+ """
642
+
643
+ arguments = call.get("arguments")
644
+ try:
645
+ arguments_text = json.dumps(arguments, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
646
+ except (TypeError, ValueError):
647
+ arguments_text = ""
648
+ call_id = call.get("call_id") if isinstance(call.get("call_id"), str) else ""
649
+ name = call.get("name") if isinstance(call.get("name"), str) else ""
650
+ if call_id:
651
+ identity = f"{name}\x00call-id\x00{call_id}"
652
+ else:
653
+ identity = f"{name}\x00anonymous\x00{ordinal}\x00{arguments_text}"
654
+ return sha256(identity.encode("utf-8")).hexdigest()
655
+
656
+
657
+ def _record_tool_observation(seen: Any, key: str) -> bool:
658
+ """Record one observation key and report whether it was new."""
659
+
660
+ if seen is None:
661
+ return True
662
+ if key in seen:
663
+ return False
664
+ if isinstance(seen, set):
665
+ seen.add(key)
666
+ else:
667
+ seen[key] = None
668
+ return True
669
+
670
+
631
671
  def _hermes_read_status(value: Any) -> str:
632
672
  """Classify a visible read result without inspecting it in diagnostics."""
633
673
 
@@ -1125,6 +1165,7 @@ class MemleafMemoryProvider(MemoryProvider):
1125
1165
  self._gate_turn_ids: "OrderedDict[Tuple[str, int], str]" = OrderedDict()
1126
1166
  self._active_turn_numbers: "OrderedDict[str, int]" = OrderedDict()
1127
1167
  self._active_retrieval_ids: "OrderedDict[str, Optional[str]]" = OrderedDict()
1168
+ self._observed_tool_call_keys: "OrderedDict[str, None]" = OrderedDict()
1128
1169
  self._version_warning_emitted = False
1129
1170
  # Hermes keeps one provider instance alive while compression rotates
1130
1171
  # the physical session id. Keep only a bounded alias chain so a
@@ -1752,6 +1793,7 @@ class MemleafMemoryProvider(MemoryProvider):
1752
1793
  if not continuous:
1753
1794
  self._pending_lineage.clear()
1754
1795
  self._deferred_process_sessions.clear()
1796
+ self._observed_tool_call_keys.clear()
1755
1797
  if _as_bool(reset, False) or _as_bool(rewound, False):
1756
1798
  self._clear_session_turn_state(old_session_id or next_session_id)
1757
1799
  for key in list(self._retrieval_ids_by_turn):
@@ -1826,6 +1868,7 @@ class MemleafMemoryProvider(MemoryProvider):
1826
1868
  self._gate_turn_ids.clear()
1827
1869
  self._active_turn_numbers.clear()
1828
1870
  self._active_retrieval_ids.clear()
1871
+ self._observed_tool_call_keys.clear()
1829
1872
  self._session_aliases.clear()
1830
1873
  self._pending_lineage.clear()
1831
1874
  self._deferred_process_sessions.clear()
@@ -2073,6 +2116,7 @@ class MemleafMemoryProvider(MemoryProvider):
2073
2116
  session_id: str = "",
2074
2117
  turn_id: str = "",
2075
2118
  vault_root: Optional[Path] = None,
2119
+ seen_call_keys: Any = None,
2076
2120
  ) -> str:
2077
2121
  """Observe explicit host MCP calls in public messages.
2078
2122
 
@@ -2087,25 +2131,32 @@ class MemleafMemoryProvider(MemoryProvider):
2087
2131
  results = _visible_tool_results(messages)
2088
2132
  statuses: list[str] = []
2089
2133
  search_results_used: set[int] = set()
2134
+ search_ordinal = 0
2090
2135
  for call in calls:
2091
2136
  if call.get("name") != "mcp__memleaf__search":
2092
2137
  continue
2138
+ search_ordinal += 1
2093
2139
  arguments = call.get("arguments")
2094
2140
  if not isinstance(arguments, Mapping) or arguments.get("retrieval_id") != retrieval_id:
2095
2141
  continue
2096
2142
  payload = _tool_result_for_call(call, calls, results, search_results_used)
2097
2143
  if payload is not _CALL_FAILED:
2144
+ status = _hermes_search_status(payload)
2145
+ observation_key = _tool_observation_key(call, search_ordinal)
2146
+ if not _record_tool_observation(seen_call_keys, observation_key):
2147
+ continue
2098
2148
  # Hermes has no public write-back hook for this soft observer.
2099
2149
  # A valid current-turn result is enough to mark the local
2100
2150
  # provider diagnostic; no Core ledger or body is touched here.
2101
- statuses.append(_hermes_search_status(payload))
2151
+ statuses.append(status)
2102
2152
 
2103
2153
  read_results_used: set[int] = set()
2104
2154
  read_sequence = 0
2155
+ read_ordinal = 0
2105
2156
  for call in calls:
2106
2157
  if call.get("name") != "mcp__memleaf__read":
2107
2158
  continue
2108
- read_sequence += 1
2159
+ read_ordinal += 1
2109
2160
  arguments = call.get("arguments")
2110
2161
  retrieval_present = isinstance(arguments, Mapping) and "retrieval_id" in arguments
2111
2162
  retrieval_match = bool(
@@ -2120,6 +2171,10 @@ class MemleafMemoryProvider(MemoryProvider):
2120
2171
  # mismatched gate binding. Do not make that look like a
2121
2172
  # controlled success in the diagnostic stream.
2122
2173
  result_status = "uncontrolled_success"
2174
+ observation_key = _tool_observation_key(call, read_ordinal)
2175
+ if not _record_tool_observation(seen_call_keys, observation_key):
2176
+ continue
2177
+ read_sequence += 1
2123
2178
  logger.info(
2124
2179
  "memleaf retrieval-read source=hermes session=%s turn=%s read_seq=%d retrieval_present=%s retrieval_match=%s result=%s",
2125
2180
  _safe_component(session_id, "none") if session_id else "none",
@@ -2286,7 +2341,10 @@ class MemleafMemoryProvider(MemoryProvider):
2286
2341
  session_id=effective_session,
2287
2342
  turn_id=turn_id,
2288
2343
  vault_root=_resolve_vault(self._config()),
2344
+ seen_call_keys=self._observed_tool_call_keys,
2289
2345
  )
2346
+ while len(self._observed_tool_call_keys) > _MAX_OBSERVED_TOOL_CALL_KEYS:
2347
+ self._observed_tool_call_keys.popitem(last=False)
2290
2348
  self._last_retrieval_observation = observation
2291
2349
  lineage_ready = self._retry_pending_lineage(effective_session)
2292
2350
  for role, content in visible_events:
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.15
2
+ version: 0.2.16
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -87,6 +87,55 @@ _DIAGNOSTIC_GATE_ALLOWED = frozenset(("candidates",))
87
87
  _DIAGNOSTIC_CANDIDATE_REQUIRED = frozenset(
88
88
  ("candidate_id", "memory", "evidence_event_ids", "duplicate", "worth", "type", "scopes", "scope_source")
89
89
  )
90
+
91
+ _READ_ONLY_QUERY_MARKERS = (
92
+ "有没有", "有哪些", "是什么", "是谁", "多少", "查询", "查一下", "查下",
93
+ "看看", "查看", "汇总", "列出", "告诉我", "what", "which", "who", "show me",
94
+ )
95
+ _USER_ASSERTION_MARKERS = (
96
+ "已确认", "确认了", "已决定", "我决定", "改为", "更新为", "变更为", "以后",
97
+ "今后", "约定", "我的偏好", "我喜欢", "必须", "要求是", "截止日期为",
98
+ "负责人是", "负责人为",
99
+ )
100
+ _DERIVED_OVERDUE_RE = re.compile(
101
+ r"(?:逾期|超期|overdue)\s*\d+(?:\.\d+)?\s*(?:天|日|days?)",
102
+ re.IGNORECASE,
103
+ )
104
+ _EXECUTION_RECEIPT_RE = re.compile(
105
+ r"(?:核验归档|归档核验|服务器已接受(?:提交|投递)|server accepted)",
106
+ re.IGNORECASE,
107
+ )
108
+
109
+
110
+ def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
111
+ """Return true when the user only asks for information in this turn."""
112
+
113
+ user_text = "\n".join(
114
+ str(event.get("content", ""))
115
+ for event in events
116
+ if str(event.get("role", "")).casefold() == "user"
117
+ ).strip()
118
+ if not user_text:
119
+ return False
120
+ folded = user_text.casefold()
121
+ query_prefix = re.sub(r"^(?:请|帮我|麻烦|请帮我)\s*", "", folded)
122
+ asks = "?" in user_text or "?" in user_text or any(
123
+ query_prefix.startswith(marker) or query_prefix.startswith(f"我{marker}")
124
+ for marker in _READ_ONLY_QUERY_MARKERS
125
+ )
126
+ if not asks:
127
+ return False
128
+ return not any(marker in folded for marker in _USER_ASSERTION_MARKERS)
129
+
130
+
131
+ def _automatic_transient_memory(value: Any) -> bool:
132
+ """Reject volatile counters and one-off execution receipts at write time."""
133
+
134
+ if not isinstance(value, str):
135
+ return False
136
+ return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
137
+
138
+
90
139
  _DIAGNOSTIC_CANDIDATE_ALLOWED = _DIAGNOSTIC_CANDIDATE_REQUIRED | frozenset(
91
140
  ("reason", "duplicate_memory_id", "update_memory_id")
92
141
  )
@@ -2268,9 +2317,14 @@ class Processor:
2268
2317
  )
2269
2318
  requests: list[dict[str, Any]] = []
2270
2319
  observed_scopes: list[str] = []
2320
+ read_only_query = _automatic_read_only_query(events)
2271
2321
  for candidate in gate["candidates"]:
2272
2322
  candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
2273
2323
  detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
2324
+ if candidate.get("worth") and (
2325
+ read_only_query or _automatic_transient_memory(candidate.get("memory"))
2326
+ ):
2327
+ continue
2274
2328
  # A combined mailbox/daily digest is not an atomic memory. If a
2275
2329
  # concrete action was worth retaining, the gate must emit it as
2276
2330
  # its own candidate; the aggregate shell itself is NO_CHANGE.
@@ -2566,6 +2620,10 @@ class Processor:
2566
2620
  f"{summary.get('title', '')}\n{summary.get('body', '')}"
2567
2621
  ):
2568
2622
  continue
2623
+ if _automatic_transient_memory(
2624
+ f"{summary.get('title', '')}\n{summary.get('body', '')}"
2625
+ ):
2626
+ continue
2569
2627
  if gate_update_target is not None:
2570
2628
  summary_update_target = summary.get("update_memory_id")
2571
2629
  if summary_update_target is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.15
3
+ Version: 0.2.16
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.15。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.15 通过 PyPI 分发。
26
+ > **当前版本:0.2.16。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -305,7 +305,160 @@ class AdmissionFlowTests(unittest.TestCase):
305
305
  self.assertEqual(len(memories), 1)
306
306
  self.assertIn("交付延期风险", memories[0].memory.body)
307
307
 
308
- def test_combined_orion_digest_is_dropped_but_atomic_action_is_kept(self):
308
+ def test_pure_operational_query_cannot_reingest_assistant_snapshot(self):
309
+ service = self.make_service("pure-operational-query")
310
+ existing = service.create_memory(
311
+ memory_id="mem-puyin-overdue",
312
+ title="浦银安盛测试数据",
313
+ body="浦银安盛需要提供一版可靠测试数据。",
314
+ tags=["project"],
315
+ type="todo",
316
+ )
317
+ backend = QueueBackend()
318
+ user_key, assistant_key = self.capture_turn(
319
+ service,
320
+ session="pure-operational-query",
321
+ turn="turn-1",
322
+ user="我有没有近期比较紧急的事情要处理?",
323
+ assistant=(
324
+ "巡检汇总:浦银安盛任务逾期5天;泰信基金任务逾期33天,"
325
+ "状态已驳回、无负责人;中银国际历史数据和附件需要全部迁移。"
326
+ ),
327
+ )
328
+ backend.responses.append(
329
+ gate(
330
+ [
331
+ candidate(
332
+ "repeated-task",
333
+ [user_key, assistant_key],
334
+ memory="浦银安盛需要提供一版可靠测试数据,逾期5天。",
335
+ type="todo",
336
+ ),
337
+ candidate(
338
+ "daily-overdue",
339
+ [user_key, assistant_key],
340
+ memory="泰信基金任务逾期33天,状态已驳回、无负责人。",
341
+ type="todo",
342
+ ),
343
+ candidate(
344
+ "assistant-only-plan",
345
+ [user_key, assistant_key],
346
+ memory="中银国际历史数据和附件需要全部迁移。",
347
+ type="project",
348
+ ),
349
+ ]
350
+ )
351
+ )
352
+
353
+ result = service.process(source="hermes", session_id="pure-operational-query", model=backend)
354
+
355
+ self.assertEqual(result["memory_ids"], [])
356
+ self.assertEqual(result["memories_written"], 0)
357
+ self.assertEqual(service.read(existing.memory_id).body, existing.body)
358
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
359
+
360
+ def test_query_with_user_confirmed_durable_fact_remains_admissible(self):
361
+ service = self.make_service("query-with-confirmation")
362
+ backend = QueueBackend()
363
+ user_key, assistant_key = self.capture_turn(
364
+ service,
365
+ session="query-with-confirmation",
366
+ turn="turn-1",
367
+ user="以后处理项目邮件请保持简洁,可以吗?",
368
+ assistant="可以,之后项目邮件保持简洁。",
369
+ )
370
+ backend.responses.extend(
371
+ [
372
+ gate(
373
+ [
374
+ candidate(
375
+ "confirmed-preference",
376
+ [user_key, assistant_key],
377
+ memory="用户偏好项目邮件保持简洁。",
378
+ type="preference",
379
+ )
380
+ ]
381
+ ),
382
+ summary(
383
+ user_key,
384
+ title="项目邮件保持简洁",
385
+ body="用户偏好项目邮件保持简洁。",
386
+ type="preference",
387
+ ),
388
+ ]
389
+ )
390
+
391
+ result = service.process(source="hermes", session_id="query-with-confirmation", model=backend)
392
+
393
+ self.assertEqual(result["memories_written"], 1)
394
+ self.assertEqual(service._read_memories_unlocked("knowledge")[0].memory.type, "preference")
395
+
396
+ def test_dynamic_overdue_only_update_is_ignored(self):
397
+ service = self.make_service("dynamic-overdue-update")
398
+ old = service.create_memory(
399
+ memory_id="mem-taixin-overdue",
400
+ title="泰信基金申请日期展示问题",
401
+ body="泰信基金申请日期展示问题逾期26天。",
402
+ tags=["operational"],
403
+ type="todo",
404
+ )
405
+ backend = QueueBackend()
406
+ user_key, assistant_key = self.capture_turn(
407
+ service,
408
+ session="dynamic-overdue-update",
409
+ turn="turn-1",
410
+ user="查询泰信基金当前任务状态。",
411
+ assistant="泰信基金申请日期展示问题现在逾期33天。",
412
+ )
413
+ backend.responses.append(
414
+ gate(
415
+ [
416
+ candidate(
417
+ "taixin-status",
418
+ [user_key, assistant_key],
419
+ memory="泰信基金申请日期展示问题逾期33天。",
420
+ type="todo",
421
+ update_memory_id=old.memory_id,
422
+ )
423
+ ]
424
+ )
425
+ )
426
+
427
+ result = service.process(source="hermes", session_id="dynamic-overdue-update", model=backend)
428
+
429
+ self.assertEqual(result["memory_ids"], [])
430
+ self.assertEqual(result["memories_written"], 0)
431
+ self.assertEqual(service.read(old.memory_id).body, old.body)
432
+
433
+ def test_one_time_execution_receipt_is_not_persisted(self):
434
+ service = self.make_service("execution-receipt")
435
+ backend = QueueBackend()
436
+ user_key, assistant_key = self.capture_turn(
437
+ service,
438
+ session="execution-receipt",
439
+ turn="turn-1",
440
+ user="把供数清单发给刘洋。",
441
+ assistant="邮件已发送,已归档核验,服务器已接受提交。",
442
+ )
443
+ backend.responses.append(
444
+ gate(
445
+ [
446
+ candidate(
447
+ "sent-receipt",
448
+ [user_key, assistant_key],
449
+ memory="供数清单邮件已发送并完成归档核验。",
450
+ type="fact",
451
+ )
452
+ ]
453
+ )
454
+ )
455
+
456
+ result = service.process(source="hermes", session_id="execution-receipt", model=backend)
457
+
458
+ self.assertEqual(result["memory_ids"], [])
459
+ self.assertEqual(result["memories_written"], 0)
460
+
461
+ def test_read_only_orion_digest_does_not_reingest_atomic_assistant_action(self):
309
462
  service = self.make_service("orion-digest")
310
463
  backend = QueueBackend()
311
464
  user_key, _ = self.capture_turn(
@@ -329,34 +482,14 @@ class AdmissionFlowTests(unittest.TestCase):
329
482
  type="todo",
330
483
  )
331
484
  atomic["scopes"] = ["project:orion"]
332
- atomic_summary = json.loads(
333
- summary(
334
- user_key,
335
- title="Orion提供旧版本生产取数脚本",
336
- body="Orion需要提供旧版本生产取数脚本。",
337
- type="todo",
338
- )
339
- )
340
- atomic_summary.update(
341
- {
342
- "scopes": ["project:orion"],
343
- "status": "active",
344
- }
345
- )
346
- backend.responses.extend(
347
- [gate([aggregate, atomic]), json.dumps(atomic_summary, ensure_ascii=False)]
348
- )
485
+ backend.responses.append(gate([aggregate, atomic]))
349
486
 
350
487
  result = service.process(source="hermes", session_id="orion-digest", model=backend)
351
488
 
352
- self.assertEqual(result["memories_written"], 1)
489
+ self.assertEqual(result["memories_written"], 0)
353
490
  self.assertEqual(result["deferred_candidates"], 0)
354
- memories = service._read_memories_unlocked("knowledge")
355
- self.assertEqual(len(memories), 1)
356
- self.assertEqual(memories[0].memory.title, "Orion提供旧版本生产取数脚本")
357
- self.assertNotIn("汇总", memories[0].memory.title)
358
- self.assertNotIn("完成4条", memories[0].memory.body)
359
- self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
491
+ self.assertEqual(service._read_memories_unlocked("knowledge"), [])
492
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
360
493
 
361
494
  def test_attachment_only_followup_is_dropped_before_summary(self):
362
495
  service = self.make_service("attachment-followup")
@@ -412,7 +545,7 @@ class AdmissionFlowTests(unittest.TestCase):
412
545
 
413
546
  self.assertEqual(result["memories_written"], 0)
414
547
  self.assertEqual(service._read_memories_unlocked("knowledge"), [])
415
- self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
548
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
416
549
 
417
550
  def test_attachment_with_owner_deadline_and_remediation_remains_admissible(self):
418
551
  service = self.make_service("attachment-action")
@@ -421,8 +554,8 @@ class AdmissionFlowTests(unittest.TestCase):
421
554
  service,
422
555
  session="attachment-action",
423
556
  turn="turn-1",
424
- user="鑫元基金SIT问题清单怎么处理?",
425
- assistant="张三需在2026-09-03前逐项整改SIT问题清单。",
557
+ user="已确认张三需在2026-09-03前逐项整改鑫元基金SIT问题清单,还要补充什么吗?",
558
+ assistant="该负责人、期限和整改要求已经明确。",
426
559
  )
427
560
  item = candidate(
428
561
  "xinyuan-sit-remediation",
@@ -303,11 +303,11 @@ class HermesProviderTests(unittest.TestCase):
303
303
 
304
304
  def test_provider_does_not_warn_when_core_versions_match(self) -> None:
305
305
  client = FakeClient(responses=[{"stats": True}])
306
- client.server_version = "0.2.15"
306
+ client.server_version = "0.2.16"
307
307
  provider = provider_module.MemleafMemoryProvider()
308
308
  with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
309
309
  patch.object(provider_module, "_MCPClient", return_value=client), \
310
- patch.object(provider_module, "_provider_manifest_version", return_value="0.2.15"), \
310
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.16"), \
311
311
  patch.object(provider_module.logger, "warning") as warning:
312
312
  provider.initialize(
313
313
  "version-match-session",
@@ -324,7 +324,7 @@ class HermesProviderTests(unittest.TestCase):
324
324
  provider = provider_module.MemleafMemoryProvider()
325
325
  with patch.object(provider_module, "_resolve_command", return_value="memleaf-mcp"), \
326
326
  patch.object(provider_module, "_MCPClient", return_value=client), \
327
- patch.object(provider_module, "_provider_manifest_version", return_value="0.2.15"), \
327
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.16"), \
328
328
  patch.object(provider_module.logger, "warning") as warning:
329
329
  provider.initialize(
330
330
  "version-missing-session",
@@ -1481,7 +1481,7 @@ class HermesProviderTests(unittest.TestCase):
1481
1481
  )
1482
1482
  self.assertEqual(provider_module._hermes_read_status(empty), "missing_result")
1483
1483
 
1484
- def test_read_diagnostics_keep_token_after_client_reconnect_and_log_repeated_reads(self) -> None:
1484
+ def test_read_diagnostics_keep_token_after_client_reconnect_without_relogging_history(self) -> None:
1485
1485
  provider = self.provider(
1486
1486
  responses=[{"stored": True}, {"stored": True}, {"processed_turns": 1}] * 2
1487
1487
  )
@@ -1538,11 +1538,82 @@ class HermesProviderTests(unittest.TestCase):
1538
1538
 
1539
1539
  output = "\n".join(call.args[0] % call.args[1:] for call in info.call_args_list)
1540
1540
  read_logs = [line for line in output.splitlines() if "retrieval-read" in line]
1541
- self.assertEqual(len(read_logs), 4)
1541
+ self.assertEqual(len(read_logs), 2)
1542
1542
  self.assertTrue(all("retrieval_present=True" in line for line in read_logs))
1543
1543
  self.assertTrue(all("retrieval_match=True" in line for line in read_logs))
1544
1544
  self.assertNotIn("rtv-reconnect", output)
1545
1545
 
1546
+ def test_read_diagnostics_skip_cumulative_history_but_keep_current_wrong_token(self) -> None:
1547
+ old_call = {
1548
+ "id": "read-old",
1549
+ "function": {
1550
+ "name": "mcp__memleaf__read",
1551
+ "arguments": json.dumps(
1552
+ {"memory_id": "MEMORY_OLD", "retrieval_id": "rtv-old"}
1553
+ ),
1554
+ },
1555
+ }
1556
+ current_call = {
1557
+ "id": "read-current",
1558
+ "function": {
1559
+ "name": "mcp__memleaf__read",
1560
+ "arguments": json.dumps(
1561
+ {"memory_id": "MEMORY_CURRENT", "retrieval_id": "rtv-current"}
1562
+ ),
1563
+ },
1564
+ }
1565
+ wrong_token_call = {
1566
+ "id": "read-current-wrong-token",
1567
+ "function": {
1568
+ "name": "mcp__memleaf__read",
1569
+ "arguments": json.dumps(
1570
+ {"memory_id": "MEMORY_WRONG", "retrieval_id": "rtv-other"}
1571
+ ),
1572
+ },
1573
+ }
1574
+ first_messages = [
1575
+ {"role": "assistant", "tool_calls": [old_call]},
1576
+ {
1577
+ "role": "tool",
1578
+ "tool_call_id": "read-old",
1579
+ "content": json.dumps({"memory_id": "MEMORY_OLD", "body": "old"}),
1580
+ },
1581
+ ]
1582
+ cumulative_messages = [
1583
+ *first_messages,
1584
+ {"role": "assistant", "tool_calls": [current_call, wrong_token_call]},
1585
+ {
1586
+ "role": "tool",
1587
+ "tool_call_id": "read-current",
1588
+ "content": json.dumps({"memory_id": "MEMORY_CURRENT", "body": "current"}),
1589
+ },
1590
+ {
1591
+ "role": "tool",
1592
+ "tool_call_id": "read-current-wrong-token",
1593
+ "content": json.dumps({"memory_id": "MEMORY_WRONG", "body": "wrong"}),
1594
+ },
1595
+ ]
1596
+ seen_call_keys: set[str] = set()
1597
+
1598
+ with patch.object(provider_module.logger, "info") as info:
1599
+ provider_module.MemleafMemoryProvider._observe_search_messages(
1600
+ first_messages,
1601
+ "rtv-old",
1602
+ seen_call_keys=seen_call_keys,
1603
+ )
1604
+ provider_module.MemleafMemoryProvider._observe_search_messages(
1605
+ cumulative_messages,
1606
+ "rtv-current",
1607
+ seen_call_keys=seen_call_keys,
1608
+ )
1609
+
1610
+ output = "\n".join(call.args[0] % call.args[1:] for call in info.call_args_list)
1611
+ read_logs = [line for line in output.splitlines() if "retrieval-read" in line]
1612
+ self.assertEqual(len(read_logs), 3)
1613
+ self.assertIn("retrieval_match=True result=ok", read_logs[0])
1614
+ self.assertIn("retrieval_match=True result=ok", read_logs[1])
1615
+ self.assertIn("retrieval_match=False result=uncontrolled_success", read_logs[2])
1616
+
1546
1617
  def test_file_tools_only_mark_paths_inside_configured_vault_as_bypass(self) -> None:
1547
1618
  vault = self.root / "configured-vault"
1548
1619
  vault.mkdir()
@@ -81,7 +81,7 @@ class PyPIInstallTests(unittest.TestCase):
81
81
 
82
82
  self.assertTrue(target.is_dir())
83
83
  self.assertFalse(target.is_symlink())
84
- self.assertIn("version: 0.2.15", (target / "plugin.yaml").read_text(encoding="utf-8"))
84
+ self.assertIn("version: 0.2.16", (target / "plugin.yaml").read_text(encoding="utf-8"))
85
85
 
86
86
  def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
87
87
  with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
@@ -212,16 +212,16 @@ class PyPIInstallTests(unittest.TestCase):
212
212
  "status": "configured",
213
213
  "reason": "ok",
214
214
  "vault": "/tmp/memleaf-vault",
215
- "core_version": "0.2.15",
216
- "provider_version": "0.2.15",
215
+ "core_version": "0.2.16",
216
+ "provider_version": "0.2.16",
217
217
  "model": {"status": "configured"},
218
218
  }
219
219
  with mock.patch("memleaf.installer.install_hermes", return_value=result), \
220
220
  mock.patch("sys.stdout") as stdout:
221
221
  self.assertEqual(cli.main(["install"]), 0)
222
222
  output = "\n".join(str(call.args[0]) for call in stdout.write.call_args_list)
223
- self.assertIn("core=0.2.15", output)
224
- self.assertIn("Hermes provider=0.2.15", output)
223
+ self.assertIn("core=0.2.16", output)
224
+ self.assertIn("Hermes provider=0.2.16", output)
225
225
 
226
226
  def test_readmes_document_one_line_core_and_provider_upgrade(self) -> None:
227
227
  command = "python -m pip install -U memleaf && python -m memleaf install"
@@ -104,7 +104,7 @@ class StageC1MCPTest(unittest.TestCase):
104
104
  testcase.assertEqual(result["resultType"], "complete")
105
105
  testcase.assertEqual(
106
106
  result["_meta"]["io.modelcontextprotocol/serverInfo"],
107
- {"name": "memleaf", "version": "0.2.15"},
107
+ {"name": "memleaf", "version": "0.2.16"},
108
108
  )
109
109
  return result
110
110
 
@@ -24,7 +24,7 @@ class StageC3PackagingTests(unittest.TestCase):
24
24
 
25
25
  def test_project_metadata_and_version_export(self) -> None:
26
26
  self.assertEqual(self.project["name"], "memleaf")
27
- self.assertEqual(self.project["version"], "0.2.15")
27
+ self.assertEqual(self.project["version"], "0.2.16")
28
28
  self.assertEqual(self.project["requires-python"], ">=3.11")
29
29
  self.assertEqual(self.project["dependencies"], [])
30
30
  self.assertEqual(self.project["license"], "MIT")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes