memleaf 0.2.15__tar.gz → 0.2.17__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 (105) hide show
  1. {memleaf-0.2.15 → memleaf-0.2.17}/CHANGELOG.md +19 -0
  2. {memleaf-0.2.15/src/memleaf.egg-info → memleaf-0.2.17}/PKG-INFO +3 -3
  3. {memleaf-0.2.15 → memleaf-0.2.17}/README.en.md +2 -2
  4. {memleaf-0.2.15 → memleaf-0.2.17}/README.md +2 -2
  5. {memleaf-0.2.15 → memleaf-0.2.17}/pyproject.toml +1 -1
  6. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/hermes_provider/__init__.py +60 -2
  8. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/processing.py +316 -14
  10. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/prompts.py +16 -0
  11. {memleaf-0.2.15 → memleaf-0.2.17/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/SOURCES.txt +1 -0
  13. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_admission_noise.py +161 -28
  14. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_hermes_provider.py +76 -5
  15. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_pypi_install.py +5 -5
  16. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_c1_mcp.py +1 -1
  17. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_c3_packaging.py +1 -1
  18. memleaf-0.2.17/tests/test_todo_state_recovery.py +165 -0
  19. {memleaf-0.2.15 → memleaf-0.2.17}/IMPLEMENTATION_PLAN.md +0 -0
  20. {memleaf-0.2.15 → memleaf-0.2.17}/LICENSE +0 -0
  21. {memleaf-0.2.15 → memleaf-0.2.17}/MANIFEST.in +0 -0
  22. {memleaf-0.2.15 → memleaf-0.2.17}/RELEASE_CHECKLIST.md +0 -0
  23. {memleaf-0.2.15 → memleaf-0.2.17}/examples/README.md +0 -0
  24. {memleaf-0.2.15 → memleaf-0.2.17}/examples/basic_usage.py +0 -0
  25. {memleaf-0.2.15 → memleaf-0.2.17}/examples/mcp_stdio.ndjson +0 -0
  26. {memleaf-0.2.15 → memleaf-0.2.17}/install.ps1 +0 -0
  27. {memleaf-0.2.15 → memleaf-0.2.17}/install.sh +0 -0
  28. {memleaf-0.2.15 → memleaf-0.2.17}/setup.cfg +0 -0
  29. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/__main__.py +0 -0
  30. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/__init__.py +0 -0
  31. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/antigravity.py +0 -0
  32. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/base.py +0 -0
  33. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/codex.py +0 -0
  34. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/adapters/hermes.py +0 -0
  35. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/budget.py +0 -0
  36. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/capture.py +0 -0
  37. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/cli.py +0 -0
  38. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/compaction.py +0 -0
  39. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/config.py +0 -0
  40. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/credentials.py +0 -0
  41. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/frontmatter.py +0 -0
  42. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/hermes_provider/README.md +0 -0
  43. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/host_events.py +0 -0
  44. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/host_runtime.py +0 -0
  45. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/inbox.py +0 -0
  46. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/index.py +0 -0
  47. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/installer.py +0 -0
  48. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/__init__.py +0 -0
  49. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/base.py +0 -0
  50. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/claude_compatible.py +0 -0
  51. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/gemini.py +0 -0
  52. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/openai_compatible.py +0 -0
  53. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/llm/router.py +0 -0
  54. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/locking.py +0 -0
  55. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/mcp_server.py +0 -0
  56. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/memory_writer.py +0 -0
  57. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/model_discovery.py +0 -0
  58. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/models.py +0 -0
  59. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/native_index.py +0 -0
  60. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/validation.py +0 -0
  67. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf/vault.py +0 -0
  68. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.15 → memleaf-0.2.17}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.15 → memleaf-0.2.17}/tests/__init__.py +0 -0
  72. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_codex_install.py +0 -0
  73. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_codex_native_cli.py +0 -0
  74. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_context_budget.py +0 -0
  75. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_credential_safety.py +0 -0
  76. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_cross_host_acceptance.py +0 -0
  77. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_extraction_quality_regressions.py +0 -0
  78. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_install.py +0 -0
  82. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b1.py +0 -0
  89. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b2a.py +0 -0
  90. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b2b.py +0 -0
  91. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3a_commit.py +0 -0
  92. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3a_contract.py +0 -0
  93. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3b_native_context.py +0 -0
  94. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3b_native_index.py +0 -0
  95. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3b_scope.py +0 -0
  96. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3c_retrieval.py +0 -0
  97. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  98. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_stage_c2_init.py +0 -0
  99. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_update_target_recovery.py +0 -0
  100. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_upgrade_preserves_vault.py +0 -0
  101. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_gate_limits.py +0 -0
  102. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_host_flow.py +0 -0
  103. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_mcp_flow.py +0 -0
  104. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_nomatch_semantics.py +0 -0
  105. {memleaf-0.2.15 → memleaf-0.2.17}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.17 — 2026-09-02
6
+
7
+ - Preserve explicit user-confirmed completion or cancellation of one uniquely
8
+ related active todo as an in-place state update, even when the gate omits the
9
+ candidate or the same message asks what remains.
10
+ - Keep todo-state recovery deterministic and bounded: reuse the existing
11
+ memory ID, anchor `completed_at` to the user event, archive the old version,
12
+ and reject queries, negation, future intent, uncertainty, assistant-only
13
+ claims, and ambiguous targets.
14
+
15
+ ## 0.2.16 — 2026-09-02
16
+
17
+ - Keep read-only operational queries, derived overdue counters, and one-time
18
+ execution receipts out of permanent-memory extraction; preserve explicitly
19
+ confirmed durable facts and preferences.
20
+ - Deduplicate Hermes tool observations across cumulative conversation payloads,
21
+ preventing historical reads from being re-audited as uncontrolled while
22
+ retaining diagnostics for genuinely mismatched current-turn reads.
23
+
5
24
  ## 0.2.15 — 2026-09-02
6
25
 
7
26
  - 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.17
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.17。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 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.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.
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.17。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 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.17"
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.17"
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.17
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,214 @@ _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
+ _TODO_COMPLETION_RE = re.compile(
109
+ r"(?:完成|做完|办完|搞定|处理完|closed|completed|finished|done)",
110
+ re.IGNORECASE,
111
+ )
112
+ _TODO_CANCEL_RE = re.compile(
113
+ r"(?:取消|作废|撤销|不做|不用做|不需要做|cancel(?:led|ed)?|abort(?:ed)?)",
114
+ re.IGNORECASE,
115
+ )
116
+ _TODO_NEGATION_MARKERS = (
117
+ "还没", "没有", "未", "尚未", "没", "not", "never", "will", "would", "准备", "打算", "计划", "将要", "会", "要",
118
+ )
119
+ _TODO_UNCERTAINTY_MARKERS = (
120
+ "应该", "可能", "大概", "似乎", "不确定", "也许", "好像", "probably", "perhaps", "maybe", "seems", "not sure",
121
+ )
122
+ _TODO_CONFIRMATION_RE = re.compile(
123
+ r"(?:对吗|对不对|是吗|正确吗|是不是|是否(?=(?:已|已经|完成|正确|这样|发|做|弄)))"
124
+ )
125
+ _TODO_QUERY_SUFFIXES = ("吗", "么", "?", "?", "呢")
126
+
127
+
128
+ def _explicit_todo_state_change(
129
+ events: Iterable[Mapping[str, Any]],
130
+ ) -> tuple[str, list[str], str] | None:
131
+ """Detect one explicit user todo completion/cancellation declaration.
132
+
133
+ This intentionally reads user events only. A completion marker is valid
134
+ only when it is declarative (not negated, future, or a question), so an
135
+ assistant recap or a trailing "what remains?" cannot manufacture a state
136
+ transition.
137
+ """
138
+
139
+ states: list[tuple[str, str, str]] = []
140
+ for event in events:
141
+ if str(event.get("role", "")).casefold() != "user":
142
+ continue
143
+ content = event.get("content")
144
+ event_key_value = event.get("event_key")
145
+ timestamp = event.get("timestamp")
146
+ if not isinstance(content, str) or not content.strip():
147
+ continue
148
+ if not isinstance(event_key_value, str) or not event_key_value:
149
+ continue
150
+ if not isinstance(timestamp, str) or _parse_time(timestamp) is None:
151
+ continue
152
+ folded = content.casefold()
153
+ event_states: set[str] = set()
154
+ for marker, state in (
155
+ (_TODO_COMPLETION_RE, "completed"),
156
+ (_TODO_CANCEL_RE, "cancelled"),
157
+ ):
158
+ for match in marker.finditer(folded):
159
+ start, end = match.span()
160
+ prefix = folded[max(0, start - 8) : start]
161
+ suffix = folded[end : end + 4]
162
+ if any(value in prefix for value in _TODO_NEGATION_MARKERS + _TODO_UNCERTAINTY_MARKERS):
163
+ continue
164
+ if any(suffix.startswith(value) for value in _TODO_QUERY_SUFFIXES) or re.match(
165
+ r"^(?:了|啦)?(?:吗|么|呢|[??])", suffix
166
+ ):
167
+ continue
168
+ # Confirmation questions may place the question marker after
169
+ # a comma ("已经完成了,对吗?"). Keep a follow-up about
170
+ # other work eligible; only direct confirmation wording is
171
+ # rejected here.
172
+ if _TODO_CONFIRMATION_RE.search(prefix) or _TODO_CONFIRMATION_RE.search(folded[end:]):
173
+ continue
174
+ # A bare English "done"/"completed" is declarative. For
175
+ # Chinese verbs require either a completion particle or an
176
+ # explicit completed-state prefix to avoid matching a future
177
+ # action such as "完成计划".
178
+ if state in {"completed", "cancelled"} and not (
179
+ any(value in prefix for value in ("已", "已经", "刚刚", "刚才", "i ", "i've", "has ", "was "))
180
+ or suffix.startswith(("了", "啦", ".", "!", "!", ",", ",", ";", ";"))
181
+ or match.group(0).casefold() in {"completed", "finished", "done", "closed", "cancelled", "canceled", "aborted"}
182
+ ):
183
+ continue
184
+ event_states.add(state)
185
+ break
186
+ states.extend((state, event_key_value, timestamp) for state in sorted(event_states))
187
+
188
+ if not states:
189
+ return None
190
+ distinct_states = {state for state, _event_key_value, _timestamp in states}
191
+ if len(distinct_states) != 1:
192
+ return None
193
+ state = states[0][0]
194
+ evidence_event_ids = list(dict.fromkeys(item[1] for item in states))
195
+ # Preserve the event timestamp's absolute instant as completed_at. The
196
+ # parser accepts ISO-8601 strings; canonical UTC avoids offset ambiguity.
197
+ anchor = _parse_time(states[-1][2])
198
+ if anchor is None:
199
+ return None
200
+ completed_at = anchor.strftime("%Y-%m-%dT%H:%M:%SZ")
201
+ return state, evidence_event_ids, completed_at
202
+
203
+
204
+ def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
205
+ """Return true when the user only asks for information in this turn."""
206
+
207
+ user_text = "\n".join(
208
+ str(event.get("content", ""))
209
+ for event in events
210
+ if str(event.get("role", "")).casefold() == "user"
211
+ ).strip()
212
+ if not user_text:
213
+ return False
214
+ folded = user_text.casefold()
215
+ query_prefix = re.sub(r"^(?:请|帮我|麻烦|请帮我)\s*", "", folded)
216
+ asks = "?" in user_text or "?" in user_text or any(
217
+ query_prefix.startswith(marker) or query_prefix.startswith(f"我{marker}")
218
+ for marker in _READ_ONLY_QUERY_MARKERS
219
+ )
220
+ if not asks:
221
+ return False
222
+ return not any(marker in folded for marker in _USER_ASSERTION_MARKERS)
223
+
224
+
225
+ def _automatic_transient_memory(value: Any) -> bool:
226
+ """Reject volatile counters and one-off execution receipts at write time."""
227
+
228
+ if not isinstance(value, str):
229
+ return False
230
+ return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
231
+
232
+
233
+ def _todo_state_recovery_candidate(
234
+ events: Iterable[Mapping[str, Any]],
235
+ related: Iterable[Mapping[str, Any]],
236
+ ) -> tuple[dict[str, Any], str, str, str] | None:
237
+ """Recover a missed todo transition from one explicit user declaration.
238
+
239
+ This is deliberately narrower than general admission: it only considers
240
+ active todo records already returned by the bounded related-memory query,
241
+ and proceeds only when exactly one record is lexically relevant to the
242
+ user's own completion/cancellation statement.
243
+ """
244
+
245
+ state_change = _explicit_todo_state_change(events)
246
+ if state_change is None:
247
+ return None
248
+ state, evidence_event_ids, completed_at = state_change
249
+ user_text = "\n".join(
250
+ str(event.get("content", ""))
251
+ for event in events
252
+ if str(event.get("role", "")).casefold() == "user"
253
+ ).strip()
254
+ matches: list[Memory] = []
255
+ seen_ids: set[str] = set()
256
+ for item in related:
257
+ if not isinstance(item, Mapping) or item.get("native") is True:
258
+ continue
259
+ if item.get("type") != "todo" or item.get("status") in {"completed", "cancelled"}:
260
+ continue
261
+ memory_id = item.get("memory_id")
262
+ if not isinstance(memory_id, str) or not memory_id or memory_id.casefold() in seen_ids:
263
+ continue
264
+ try:
265
+ memory = Memory.from_mapping(item)
266
+ except (TypeError, ValueError):
267
+ continue
268
+ if not candidate_matches_query(memory, user_text):
269
+ continue
270
+ seen_ids.add(memory_id.casefold())
271
+ matches.append(memory)
272
+ if len(matches) != 1:
273
+ return None
274
+ target = matches[0]
275
+ candidate_id_material = "|".join(
276
+ [target.memory_id, state, *sorted(item.casefold() for item in evidence_event_ids)]
277
+ )
278
+ candidate_id = "todo-state-" + hashlib.sha256(candidate_id_material.encode("utf-8")).hexdigest()[:16]
279
+ scopes = list(target.scopes) or ["global"]
280
+ scope_source = target.scope_source
281
+ if scope_source not in {"model", "user", "session_context", "insufficient_context"}:
282
+ scope_source = "model"
283
+ status_word = "completed" if state == "completed" else "cancelled"
284
+ candidate = {
285
+ "candidate_id": candidate_id,
286
+ "memory": f"{target.title} 用户已明确{status_word}。",
287
+ "evidence_event_ids": evidence_event_ids,
288
+ "duplicate": False,
289
+ "worth": True,
290
+ "type": "todo",
291
+ "scopes": scopes,
292
+ "scope_source": scope_source,
293
+ "update_memory_id": target.memory_id,
294
+ }
295
+ return candidate, state, target.memory_id, completed_at
296
+
297
+
90
298
  _DIAGNOSTIC_CANDIDATE_ALLOWED = _DIAGNOSTIC_CANDIDATE_REQUIRED | frozenset(
91
299
  ("reason", "duplicate_memory_id", "update_memory_id")
92
300
  )
@@ -2266,11 +2474,72 @@ class Processor:
2266
2474
  "turn_index": turn.turn_index,
2267
2475
  },
2268
2476
  )
2477
+ # A model may correctly treat the rest of a mixed turn as a query and
2478
+ # still miss the user's explicit completion update. Recover only one
2479
+ # uniquely related active todo; all other gate decisions stay intact.
2480
+ todo_state_recovery = _todo_state_recovery_candidate(events, related)
2481
+ recovery_by_candidate: dict[str, tuple[str, str, str]] = {}
2482
+ if todo_state_recovery is not None:
2483
+ recovery_candidate, recovery_state, recovery_target_id, recovery_completed_at = todo_state_recovery
2484
+ matching_index = next(
2485
+ (
2486
+ index
2487
+ for index, item in enumerate(gate["candidates"])
2488
+ if isinstance(item, Mapping)
2489
+ and (
2490
+ item.get("update_memory_id") == recovery_target_id
2491
+ or item.get("duplicate_memory_id") == recovery_target_id
2492
+ )
2493
+ ),
2494
+ None,
2495
+ )
2496
+ if matching_index is not None:
2497
+ existing_candidate = gate["candidates"][matching_index]
2498
+ if (
2499
+ isinstance(existing_candidate, Mapping)
2500
+ and existing_candidate.get("update_memory_id") == recovery_target_id
2501
+ and existing_candidate.get("type") == "todo"
2502
+ and existing_candidate.get("worth") is True
2503
+ and existing_candidate.get("duplicate") is False
2504
+ ):
2505
+ recovery_by_candidate[str(existing_candidate["candidate_id"]).casefold()] = (
2506
+ recovery_state,
2507
+ recovery_target_id,
2508
+ recovery_completed_at,
2509
+ )
2510
+ else:
2511
+ # A duplicate or mismatched candidate cannot represent a
2512
+ # state transition. Replace only that target's candidate.
2513
+ gate["candidates"] = [
2514
+ item
2515
+ for index, item in enumerate(gate["candidates"])
2516
+ if index != matching_index
2517
+ ]
2518
+ gate["candidates"].append(recovery_candidate)
2519
+ recovery_by_candidate[recovery_candidate["candidate_id"].casefold()] = (
2520
+ recovery_state,
2521
+ recovery_target_id,
2522
+ recovery_completed_at,
2523
+ )
2524
+ else:
2525
+ gate["candidates"].append(recovery_candidate)
2526
+ recovery_by_candidate[recovery_candidate["candidate_id"].casefold()] = (
2527
+ recovery_state,
2528
+ recovery_target_id,
2529
+ recovery_completed_at,
2530
+ )
2269
2531
  requests: list[dict[str, Any]] = []
2270
2532
  observed_scopes: list[str] = []
2533
+ read_only_query = _automatic_read_only_query(events)
2271
2534
  for candidate in gate["candidates"]:
2272
2535
  candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
2536
+ recovery = recovery_by_candidate.get(candidate_id_key)
2273
2537
  detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
2538
+ if candidate.get("worth") and (
2539
+ (read_only_query and recovery is None)
2540
+ or _automatic_transient_memory(candidate.get("memory"))
2541
+ ):
2542
+ continue
2274
2543
  # A combined mailbox/daily digest is not an atomic memory. If a
2275
2544
  # concrete action was worth retaining, the gate must emit it as
2276
2545
  # its own candidate; the aggregate shell itself is NO_CHANGE.
@@ -2359,6 +2628,15 @@ class Processor:
2359
2628
  else None
2360
2629
  ),
2361
2630
  )
2631
+ if recovery is not None:
2632
+ recovery_target = self._active_memory_by_id(recovery[1])
2633
+ if recovery_target is not None and not any(
2634
+ isinstance(item, Mapping)
2635
+ and isinstance(item.get("memory_id"), str)
2636
+ and item["memory_id"].casefold() == recovery_target.memory_id.casefold()
2637
+ for item in candidate_related
2638
+ ):
2639
+ candidate_related = [recovery_target.to_dict(), *candidate_related]
2362
2640
  if detached_update_target_id is not None:
2363
2641
  candidate_related = [
2364
2642
  item
@@ -2513,18 +2791,24 @@ class Processor:
2513
2791
  )
2514
2792
 
2515
2793
  try:
2516
- summary = self._complete_json_stage(
2517
- backend,
2518
- summarize_prompt(
2519
- candidate,
2520
- events,
2521
- related_memories=candidate_related,
2522
- scope_background=candidate_scope_background,
2523
- scope_registry=scope_registry,
2524
- ),
2525
- system=SUMMARIZE_SYSTEM,
2526
- purpose="summarize",
2527
- parser=lambda raw: parse_summarize_output(
2794
+ def parse_summary(raw: str) -> dict[str, Any]:
2795
+ if recovery is not None and isinstance(raw, str):
2796
+ # Complete/cancel state is deterministic evidence from
2797
+ # the user event. Inject it before strict validation so
2798
+ # a model that omits status/completed_at cannot leave
2799
+ # the update active or fail the required-field check.
2800
+ parsed_raw = parse_strict_json(raw)
2801
+ if isinstance(parsed_raw, Mapping) and "decision" not in parsed_raw:
2802
+ state_change, target_id, completed_at = recovery
2803
+ parsed_raw = dict(parsed_raw)
2804
+ parsed_raw["update_memory_id"] = target_id
2805
+ parsed_raw["status"] = state_change
2806
+ if state_change == "completed":
2807
+ parsed_raw["completed_at"] = completed_at
2808
+ else:
2809
+ parsed_raw.pop("completed_at", None)
2810
+ raw = json.dumps(parsed_raw, ensure_ascii=False, separators=(",", ":"))
2811
+ parsed = parse_summarize_output(
2528
2812
  _normalize_summary_dates(raw, turn, candidate),
2529
2813
  current_event_keys=turn.event_keys,
2530
2814
  related_native_ids=candidate_native_ids,
@@ -2532,15 +2816,29 @@ class Processor:
2532
2816
  scope_registry=validation_scope_registry,
2533
2817
  expected_scopes=candidate["scopes"],
2534
2818
  expected_scope_source=candidate["scope_source"],
2535
- allow_no_change=True,
2819
+ allow_no_change=recovery is None,
2536
2820
  # The summarize stage may not reinterpret a gate
2537
- # candidate, including CREATE candidates. Updates
2821
+ # candidate, including CREATE candidates. Updates
2538
2822
  # additionally retain the active target's immutable
2539
2823
  # type below.
2540
2824
  expected_type=candidate.get("type"),
2541
2825
  expected_update_memory_id=gate_update_target,
2542
2826
  expected_target_type=gate_target_type,
2827
+ )
2828
+ return parsed
2829
+
2830
+ summary = self._complete_json_stage(
2831
+ backend,
2832
+ summarize_prompt(
2833
+ candidate,
2834
+ events,
2835
+ related_memories=candidate_related,
2836
+ scope_background=candidate_scope_background,
2837
+ scope_registry=scope_registry,
2543
2838
  ),
2839
+ system=SUMMARIZE_SYSTEM,
2840
+ purpose="summarize",
2841
+ parser=parse_summary,
2544
2842
  diagnostic_context={
2545
2843
  "source": turn.source,
2546
2844
  "session_id": turn.session_id,
@@ -2566,6 +2864,10 @@ class Processor:
2566
2864
  f"{summary.get('title', '')}\n{summary.get('body', '')}"
2567
2865
  ):
2568
2866
  continue
2867
+ if _automatic_transient_memory(
2868
+ f"{summary.get('title', '')}\n{summary.get('body', '')}"
2869
+ ):
2870
+ continue
2569
2871
  if gate_update_target is not None:
2570
2872
  summary_update_target = summary.get("update_memory_id")
2571
2873
  if summary_update_target is None:
@@ -84,6 +84,15 @@ active memory, return candidates=[]; never set duplicate_memory_id or
84
84
  update_memory_id merely to record the query or append its source. A query turn
85
85
  that also contains a newly confirmed fact or state change remains eligible for
86
86
  that new information only.
87
+ An explicit user statement that a related active todo is now completed or
88
+ cancelled is a durable state change for the same future use, even when the
89
+ statement also asks what remains. Emit one worthy todo UPDATE for the exact
90
+ related active memory_id, keep type=todo, and make the candidate memory text
91
+ state that the todo is completed or cancelled. The summary must set the
92
+ corresponding status field; for status=completed it must also set completed_at
93
+ from the supporting event timestamp. Do not treat "完成了吗?"/"是否完成" as completion, and do not
94
+ infer completion from assistant-only text, a future promise ("我会完成"), or a
95
+ negative statement ("还没完成").
87
96
  An explicit user request to remember, keep, or use a concrete object bypasses
88
97
  the worth test only in the actual explicit remember API path, and only for its
89
98
  requested content. In the automatic capture/process path, a request to invoke or test the remember tool
@@ -237,6 +246,13 @@ confirmed change, retain the existing memory unchanged (the gate should emit
237
246
  worth=false) rather than creating a sibling. When the current evidence is a
238
247
  customer/user request that is not confirmed implemented, state it as
239
248
  requested/proposed/pending and never as completed or deployed.
249
+ When the current user explicitly confirms that a related active todo was
250
+ completed or cancelled, this is a state update rather than a read-only query.
251
+ Preserve the todo's same memory_id and type, set status=completed or cancelled,
252
+ and for completed set completed_at to the timestamp of the supporting user
253
+ event. A later question in the same user message does not erase that update.
254
+ Never infer this transition from assistant-only text, "完成了吗?", "还没完成",
255
+ or a future promise such as "准备完成"/"我会完成".
240
256
  fields and JSON types are title (string), body (string), tags (string list),
241
257
  type (one of preference, fact, project, todo, event, identity, other), scopes
242
258
  (non-empty string list), and sources (non-empty object list). scope_source, if