memleaf 0.2.16__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.16 → memleaf-0.2.17}/CHANGELOG.md +10 -0
  2. {memleaf-0.2.16/src/memleaf.egg-info → memleaf-0.2.17}/PKG-INFO +3 -3
  3. {memleaf-0.2.16 → memleaf-0.2.17}/README.en.md +2 -2
  4. {memleaf-0.2.16 → memleaf-0.2.17}/README.md +2 -2
  5. {memleaf-0.2.16 → memleaf-0.2.17}/pyproject.toml +1 -1
  6. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/processing.py +259 -15
  9. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/prompts.py +16 -0
  10. {memleaf-0.2.16 → memleaf-0.2.17/src/memleaf.egg-info}/PKG-INFO +3 -3
  11. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/SOURCES.txt +1 -0
  12. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_hermes_provider.py +3 -3
  13. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_pypi_install.py +5 -5
  14. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_c1_mcp.py +1 -1
  15. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_c3_packaging.py +1 -1
  16. memleaf-0.2.17/tests/test_todo_state_recovery.py +165 -0
  17. {memleaf-0.2.16 → memleaf-0.2.17}/IMPLEMENTATION_PLAN.md +0 -0
  18. {memleaf-0.2.16 → memleaf-0.2.17}/LICENSE +0 -0
  19. {memleaf-0.2.16 → memleaf-0.2.17}/MANIFEST.in +0 -0
  20. {memleaf-0.2.16 → memleaf-0.2.17}/RELEASE_CHECKLIST.md +0 -0
  21. {memleaf-0.2.16 → memleaf-0.2.17}/examples/README.md +0 -0
  22. {memleaf-0.2.16 → memleaf-0.2.17}/examples/basic_usage.py +0 -0
  23. {memleaf-0.2.16 → memleaf-0.2.17}/examples/mcp_stdio.ndjson +0 -0
  24. {memleaf-0.2.16 → memleaf-0.2.17}/install.ps1 +0 -0
  25. {memleaf-0.2.16 → memleaf-0.2.17}/install.sh +0 -0
  26. {memleaf-0.2.16 → memleaf-0.2.17}/setup.cfg +0 -0
  27. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/__main__.py +0 -0
  28. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/__init__.py +0 -0
  29. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/antigravity.py +0 -0
  30. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/base.py +0 -0
  31. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/codex.py +0 -0
  32. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/hermes.py +0 -0
  33. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/budget.py +0 -0
  34. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/capture.py +0 -0
  35. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/cli.py +0 -0
  36. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/compaction.py +0 -0
  37. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/config.py +0 -0
  38. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/credentials.py +0 -0
  39. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/frontmatter.py +0 -0
  40. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/hermes_provider/README.md +0 -0
  41. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/hermes_provider/__init__.py +0 -0
  42. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/host_events.py +0 -0
  43. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/host_runtime.py +0 -0
  44. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/inbox.py +0 -0
  45. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/index.py +0 -0
  46. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/installer.py +0 -0
  47. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/__init__.py +0 -0
  48. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/base.py +0 -0
  49. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/claude_compatible.py +0 -0
  50. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/gemini.py +0 -0
  51. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/openai_compatible.py +0 -0
  52. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/router.py +0 -0
  53. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/locking.py +0 -0
  54. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/mcp_server.py +0 -0
  55. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/memory_writer.py +0 -0
  56. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/model_discovery.py +0 -0
  57. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/models.py +0 -0
  58. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/native_index.py +0 -0
  59. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/redaction.py +0 -0
  60. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/retrieval.py +0 -0
  61. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/retrieval_gate.py +0 -0
  62. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/scope_maintenance.py +0 -0
  63. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/scope_state.py +0 -0
  64. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/service.py +0 -0
  65. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/validation.py +0 -0
  66. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/dependency_links.txt +0 -0
  68. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/entry_points.txt +0 -0
  69. {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/top_level.txt +0 -0
  70. {memleaf-0.2.16 → memleaf-0.2.17}/tests/__init__.py +0 -0
  71. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_admission_noise.py +0 -0
  72. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_codex_install.py +0 -0
  73. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_codex_native_cli.py +0 -0
  74. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_context_budget.py +0 -0
  75. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_credential_safety.py +0 -0
  76. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_cross_host_acceptance.py +0 -0
  77. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_extraction_quality_regressions.py +0 -0
  78. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_install.py +0 -0
  82. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b1.py +0 -0
  89. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b2a.py +0 -0
  90. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b2b.py +0 -0
  91. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3a_commit.py +0 -0
  92. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3a_contract.py +0 -0
  93. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3b_native_context.py +0 -0
  94. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3b_native_index.py +0 -0
  95. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3b_scope.py +0 -0
  96. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3c_retrieval.py +0 -0
  97. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  98. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_c2_init.py +0 -0
  99. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_update_target_recovery.py +0 -0
  100. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_upgrade_preserves_vault.py +0 -0
  101. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_gate_limits.py +0 -0
  102. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_host_flow.py +0 -0
  103. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_mcp_flow.py +0 -0
  104. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_nomatch_semantics.py +0 -0
  105. {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,16 @@
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
+
5
15
  ## 0.2.16 — 2026-09-02
6
16
 
7
17
  - Keep read-only operational queries, derived overdue counters, and one-time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.16
3
+ Version: 0.2.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.16。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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.16.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.16 is distributed through PyPI.
7
+ > **Version: 0.2.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.16。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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.16"
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.16"
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
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.16
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
@@ -105,6 +105,100 @@ _EXECUTION_RECEIPT_RE = re.compile(
105
105
  r"(?:核验归档|归档核验|服务器已接受(?:提交|投递)|server accepted)",
106
106
  re.IGNORECASE,
107
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
108
202
 
109
203
 
110
204
  def _automatic_read_only_query(events: Iterable[Mapping[str, Any]]) -> bool:
@@ -136,6 +230,71 @@ def _automatic_transient_memory(value: Any) -> bool:
136
230
  return bool(_DERIVED_OVERDUE_RE.search(value) or _EXECUTION_RECEIPT_RE.search(value))
137
231
 
138
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
+
139
298
  _DIAGNOSTIC_CANDIDATE_ALLOWED = _DIAGNOSTIC_CANDIDATE_REQUIRED | frozenset(
140
299
  ("reason", "duplicate_memory_id", "update_memory_id")
141
300
  )
@@ -2315,14 +2474,70 @@ class Processor:
2315
2474
  "turn_index": turn.turn_index,
2316
2475
  },
2317
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
+ )
2318
2531
  requests: list[dict[str, Any]] = []
2319
2532
  observed_scopes: list[str] = []
2320
2533
  read_only_query = _automatic_read_only_query(events)
2321
2534
  for candidate in gate["candidates"]:
2322
2535
  candidate_id_key = str(candidate.get("candidate_id", "")).casefold()
2536
+ recovery = recovery_by_candidate.get(candidate_id_key)
2323
2537
  detached_update_target_id = detached_update_target_ids.get(candidate_id_key)
2324
2538
  if candidate.get("worth") and (
2325
- read_only_query or _automatic_transient_memory(candidate.get("memory"))
2539
+ (read_only_query and recovery is None)
2540
+ or _automatic_transient_memory(candidate.get("memory"))
2326
2541
  ):
2327
2542
  continue
2328
2543
  # A combined mailbox/daily digest is not an atomic memory. If a
@@ -2413,6 +2628,15 @@ class Processor:
2413
2628
  else None
2414
2629
  ),
2415
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]
2416
2640
  if detached_update_target_id is not None:
2417
2641
  candidate_related = [
2418
2642
  item
@@ -2567,18 +2791,24 @@ class Processor:
2567
2791
  )
2568
2792
 
2569
2793
  try:
2570
- summary = self._complete_json_stage(
2571
- backend,
2572
- summarize_prompt(
2573
- candidate,
2574
- events,
2575
- related_memories=candidate_related,
2576
- scope_background=candidate_scope_background,
2577
- scope_registry=scope_registry,
2578
- ),
2579
- system=SUMMARIZE_SYSTEM,
2580
- purpose="summarize",
2581
- 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(
2582
2812
  _normalize_summary_dates(raw, turn, candidate),
2583
2813
  current_event_keys=turn.event_keys,
2584
2814
  related_native_ids=candidate_native_ids,
@@ -2586,15 +2816,29 @@ class Processor:
2586
2816
  scope_registry=validation_scope_registry,
2587
2817
  expected_scopes=candidate["scopes"],
2588
2818
  expected_scope_source=candidate["scope_source"],
2589
- allow_no_change=True,
2819
+ allow_no_change=recovery is None,
2590
2820
  # The summarize stage may not reinterpret a gate
2591
- # candidate, including CREATE candidates. Updates
2821
+ # candidate, including CREATE candidates. Updates
2592
2822
  # additionally retain the active target's immutable
2593
2823
  # type below.
2594
2824
  expected_type=candidate.get("type"),
2595
2825
  expected_update_memory_id=gate_update_target,
2596
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,
2597
2838
  ),
2839
+ system=SUMMARIZE_SYSTEM,
2840
+ purpose="summarize",
2841
+ parser=parse_summary,
2598
2842
  diagnostic_context={
2599
2843
  "source": turn.source,
2600
2844
  "session_id": turn.session_id,
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.16
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.16。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.16 通过 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
  ## 项目定位
@@ -93,6 +93,7 @@ tests/test_stage_b3d_scope_maintenance.py
93
93
  tests/test_stage_c1_mcp.py
94
94
  tests/test_stage_c2_init.py
95
95
  tests/test_stage_c3_packaging.py
96
+ tests/test_todo_state_recovery.py
96
97
  tests/test_update_target_recovery.py
97
98
  tests/test_upgrade_preserves_vault.py
98
99
  tests/test_v2_gate_limits.py
@@ -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.16"
306
+ client.server_version = "0.2.17"
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.16"), \
310
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.17"), \
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.16"), \
327
+ patch.object(provider_module, "_provider_manifest_version", return_value="0.2.17"), \
328
328
  patch.object(provider_module.logger, "warning") as warning:
329
329
  provider.initialize(
330
330
  "version-missing-session",
@@ -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.16", (target / "plugin.yaml").read_text(encoding="utf-8"))
84
+ self.assertIn("version: 0.2.17", (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.16",
216
- "provider_version": "0.2.16",
215
+ "core_version": "0.2.17",
216
+ "provider_version": "0.2.17",
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.16", output)
224
- self.assertIn("Hermes provider=0.2.16", output)
223
+ self.assertIn("core=0.2.17", output)
224
+ self.assertIn("Hermes provider=0.2.17", 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.16"},
107
+ {"name": "memleaf", "version": "0.2.17"},
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.16")
27
+ self.assertEqual(self.project["version"], "0.2.17")
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")
@@ -0,0 +1,165 @@
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ from memleaf import Memleaf
8
+ from memleaf.index import event_key
9
+
10
+
11
+ class QueueBackend:
12
+ def __init__(self, responses):
13
+ self.responses = list(responses)
14
+ self.calls = []
15
+
16
+ def complete(self, prompt, *, system="", purpose="", temperature=0.0):
17
+ self.calls.append({"prompt": prompt, "purpose": purpose})
18
+ return self.responses.pop(0)
19
+
20
+
21
+ class TodoStateRecoveryTest(unittest.TestCase):
22
+ @staticmethod
23
+ def gate(candidates):
24
+ return json.dumps({"candidates": candidates}, ensure_ascii=False)
25
+
26
+ @staticmethod
27
+ def summary(event_key_value):
28
+ # Deliberately omit update_memory_id/status/completed_at. The user
29
+ # declaration is the source of those deterministic state fields.
30
+ return json.dumps(
31
+ {
32
+ "title": "鑫元基金架构评审文档修改与反馈",
33
+ "body": "架构评审文档已完成并发出。",
34
+ "tags": ["todo"],
35
+ "type": "todo",
36
+ "scopes": ["project:鑫元基金"],
37
+ "scope_source": "model",
38
+ "sources": [{"event_key": event_key_value}],
39
+ },
40
+ ensure_ascii=False,
41
+ )
42
+
43
+ def setUp(self):
44
+ self.tmp = tempfile.TemporaryDirectory()
45
+ self.vault = Path(self.tmp.name) / "vault"
46
+
47
+ def tearDown(self):
48
+ self.tmp.cleanup()
49
+
50
+ def add_todo(self, service, memory_id="todo-1", title="鑫元基金架构评审文档修改与反馈"):
51
+ return service.create_memory(
52
+ memory_id=memory_id,
53
+ title=title,
54
+ body="需按要求修改并反馈。",
55
+ tags=["todo"],
56
+ type="todo",
57
+ scopes=["project:鑫元基金"],
58
+ scope_source="model",
59
+ )
60
+
61
+ def add_turn(self, service, user, *, assistant="已收到。"):
62
+ service.capture("hermes", "session", "turn-1", "user", user, event_id="todo-user")
63
+ service.capture("hermes", "session", "turn-1", "assistant", assistant, event_id="todo-assistant")
64
+ return event_key("todo-user")
65
+
66
+ def test_gate_empty_recovers_completion_in_place_and_anchors_timestamp(self):
67
+ backend = QueueBackend([])
68
+ service = Memleaf(self.vault, model=backend)
69
+ old = self.add_todo(service)
70
+ with patch("memleaf.capture._timestamp", return_value="2026-09-02T09:21:51Z"):
71
+ user_key = self.add_turn(
72
+ service,
73
+ "鑫元基金的架构评审文档我已经完成了,发给他们了。还有什么事情要完成",
74
+ )
75
+ backend.responses.extend([self.gate([]), self.summary(user_key)])
76
+
77
+ result = service.process(source="hermes", session_id="session", model=backend)
78
+
79
+ self.assertEqual(result["memory_ids"], [old.memory_id])
80
+ active = service.read(old.memory_id)
81
+ self.assertEqual(active.status, "completed")
82
+ self.assertEqual(active.completed_at, "2026-09-02T09:21:51Z")
83
+ self.assertEqual(len(service._read_memories_unlocked("knowledge")), 1)
84
+ history = service._read_memories_unlocked("history")
85
+ self.assertEqual(len(history), 1)
86
+ self.assertEqual(history[0].memory.extra["active_memory_id"], old.memory_id)
87
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
88
+
89
+ def assert_no_transition(self, text, *, assistant="已收到。", name="no-transition"):
90
+ backend = QueueBackend([self.gate([])])
91
+ service = Memleaf(Path(self.tmp.name) / name, model=backend)
92
+ old = self.add_todo(service)
93
+ self.add_turn(service, text, assistant=assistant)
94
+ result = service.process(source="hermes", session_id="session", model=backend)
95
+ active = service.read(old.memory_id)
96
+ self.assertEqual(result["memories_written"], 0)
97
+ self.assertIn(active.status, (None, "active"))
98
+ self.assertEqual(service._read_memories_unlocked("history"), [])
99
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate"])
100
+
101
+ def test_query_does_not_complete(self):
102
+ self.assert_no_transition("鑫元基金的架构评审文档完成了吗?", name="query")
103
+
104
+ def test_confirmation_and_uncertain_completion_do_not_complete(self):
105
+ for index, text in enumerate(
106
+ (
107
+ "鑫元基金的架构评审文档已经完成了,对吗?",
108
+ "鑫元基金的架构评审文档应该已经完成了",
109
+ "鑫元基金的架构评审文档可能已经完成了",
110
+ "鑫元基金的架构评审文档是否已经完成?",
111
+ )
112
+ ):
113
+ with self.subTest(text=text):
114
+ self.assert_no_transition(text, name=f"confirmation-{index}")
115
+
116
+ def test_negative_and_future_statements_do_not_complete(self):
117
+ for index, text in enumerate(("鑫元基金的架构评审文档还没完成", "鑫元基金的架构评审文档准备完成", "鑫元基金的架构评审文档我会完成")):
118
+ with self.subTest(text=text):
119
+ self.assert_no_transition(text, name=f"future-{index}")
120
+
121
+ def test_assistant_only_completion_does_not_complete(self):
122
+ self.assert_no_transition(
123
+ "鑫元基金的架构评审文档现在是什么状态?",
124
+ assistant="鑫元基金的架构评审文档已经完成了。",
125
+ name="assistant-only",
126
+ )
127
+
128
+ def test_ambiguous_same_topic_todos_do_not_guess(self):
129
+ backend = QueueBackend([self.gate([])])
130
+ service = Memleaf(Path(self.tmp.name) / "ambiguous", model=backend)
131
+ self.add_todo(service, memory_id="todo-a")
132
+ service.create_memory(
133
+ memory_id="todo-b",
134
+ title="鑫元基金架构评审文档修改与反馈",
135
+ body="需按另一份要求修改并反馈。",
136
+ tags=["todo"],
137
+ type="todo",
138
+ scopes=["project:鑫元基金"],
139
+ scope_source="model",
140
+ )
141
+ self.add_turn(service, "鑫元基金的架构评审文档我已经完成了。")
142
+
143
+ result = service.process(source="hermes", session_id="session", model=backend)
144
+
145
+ self.assertEqual(result["memories_written"], 0)
146
+ self.assertTrue(all(item.memory.status in (None, "active") for item in service._read_memories_unlocked("knowledge")))
147
+ self.assertEqual(service._read_memories_unlocked("history"), [])
148
+
149
+ def test_cancelled_state_is_recovered_in_place(self):
150
+ backend = QueueBackend([])
151
+ service = Memleaf(Path(self.tmp.name) / "cancelled", model=backend)
152
+ old = self.add_todo(service)
153
+ with patch("memleaf.capture._timestamp", return_value="2026-09-02T09:21:51Z"):
154
+ user_key = self.add_turn(service, "鑫元基金的架构评审文档不用做了。")
155
+ backend.responses.extend([self.gate([]), self.summary(user_key)])
156
+
157
+ result = service.process(source="hermes", session_id="session", model=backend)
158
+
159
+ self.assertEqual(result["memory_ids"], [old.memory_id])
160
+ self.assertEqual(service.read(old.memory_id).status, "cancelled")
161
+ self.assertIsNone(service.read(old.memory_id).completed_at)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ unittest.main()
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