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.
- {memleaf-0.2.16 → memleaf-0.2.17}/CHANGELOG.md +10 -0
- {memleaf-0.2.16/src/memleaf.egg-info → memleaf-0.2.17}/PKG-INFO +3 -3
- {memleaf-0.2.16 → memleaf-0.2.17}/README.en.md +2 -2
- {memleaf-0.2.16 → memleaf-0.2.17}/README.md +2 -2
- {memleaf-0.2.16 → memleaf-0.2.17}/pyproject.toml +1 -1
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/__init__.py +1 -1
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/hermes_provider/plugin.yaml +1 -1
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/processing.py +259 -15
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/prompts.py +16 -0
- {memleaf-0.2.16 → memleaf-0.2.17/src/memleaf.egg-info}/PKG-INFO +3 -3
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/SOURCES.txt +1 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_hermes_provider.py +3 -3
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_pypi_install.py +5 -5
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_c1_mcp.py +1 -1
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_c3_packaging.py +1 -1
- memleaf-0.2.17/tests/test_todo_state_recovery.py +165 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/IMPLEMENTATION_PLAN.md +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/LICENSE +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/MANIFEST.in +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/RELEASE_CHECKLIST.md +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/examples/README.md +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/examples/basic_usage.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/examples/mcp_stdio.ndjson +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/install.ps1 +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/install.sh +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/setup.cfg +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/__main__.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/__init__.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/antigravity.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/base.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/codex.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/adapters/hermes.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/budget.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/capture.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/cli.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/compaction.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/config.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/credentials.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/frontmatter.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/hermes_provider/README.md +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/hermes_provider/__init__.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/host_events.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/host_runtime.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/inbox.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/index.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/installer.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/__init__.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/base.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/claude_compatible.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/gemini.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/openai_compatible.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/llm/router.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/locking.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/mcp_server.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/memory_writer.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/model_discovery.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/models.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/native_index.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/redaction.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/retrieval.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/retrieval_gate.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/scope_maintenance.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/scope_state.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/service.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/validation.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf/vault.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/dependency_links.txt +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/entry_points.txt +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/src/memleaf.egg-info/top_level.txt +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/__init__.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_admission_noise.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_codex_install.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_codex_native_cli.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_context_budget.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_credential_safety.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_cross_host_acceptance.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_extraction_quality_regressions.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_hermes_stdio_transport.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_host_events.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_host_runtime_contract.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_install.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_maintenance_v2.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_model_discovery.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_retrieval_gate.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_retrieval_v2.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_session_lineage.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_a.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b1.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b2a.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b2b.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3a_commit.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3a_contract.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3b_native_context.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3b_native_index.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3b_scope.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3c_retrieval.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_b3d_scope_maintenance.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_stage_c2_init.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_update_target_recovery.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_upgrade_preserves_vault.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_gate_limits.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_host_flow.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_mcp_flow.py +0 -0
- {memleaf-0.2.16 → memleaf-0.2.17}/tests/test_v2_nomatch_semantics.py +0 -0
- {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.
|
|
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.
|
|
27
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
7
|
+
> **当前版本:0.2.17。**
|
|
8
|
+
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.17 通过 PyPI 分发。
|
|
9
9
|
> **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
|
|
10
10
|
|
|
11
11
|
## 项目定位
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Local-first Markdown memory core for AI agents."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.2.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
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=
|
|
2819
|
+
allow_no_change=recovery is None,
|
|
2590
2820
|
# The summarize stage may not reinterpret a gate
|
|
2591
|
-
# candidate, including CREATE candidates.
|
|
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.
|
|
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.
|
|
27
|
-
> 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
216
|
-
"provider_version": "0.2.
|
|
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.
|
|
224
|
-
self.assertIn("Hermes provider=0.2.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|