memleaf 0.2.5__tar.gz → 0.2.6__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 (102) hide show
  1. {memleaf-0.2.5 → memleaf-0.2.6}/CHANGELOG.md +13 -0
  2. {memleaf-0.2.5/src/memleaf.egg-info → memleaf-0.2.6}/PKG-INFO +3 -3
  3. {memleaf-0.2.5 → memleaf-0.2.6}/README.md +2 -2
  4. {memleaf-0.2.5 → memleaf-0.2.6}/pyproject.toml +1 -1
  5. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/hermes_provider/__init__.py +1 -0
  7. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/processing.py +3 -0
  9. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/prompts.py +38 -4
  10. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/validation.py +25 -2
  11. {memleaf-0.2.5 → memleaf-0.2.6/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_hermes_provider.py +17 -0
  13. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_pypi_install.py +1 -1
  14. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b1.py +64 -0
  15. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b2a.py +347 -0
  16. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_c1_mcp.py +1 -1
  17. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_c3_packaging.py +1 -1
  18. {memleaf-0.2.5 → memleaf-0.2.6}/IMPLEMENTATION_PLAN.md +0 -0
  19. {memleaf-0.2.5 → memleaf-0.2.6}/LICENSE +0 -0
  20. {memleaf-0.2.5 → memleaf-0.2.6}/MANIFEST.in +0 -0
  21. {memleaf-0.2.5 → memleaf-0.2.6}/README.en.md +0 -0
  22. {memleaf-0.2.5 → memleaf-0.2.6}/RELEASE_CHECKLIST.md +0 -0
  23. {memleaf-0.2.5 → memleaf-0.2.6}/examples/README.md +0 -0
  24. {memleaf-0.2.5 → memleaf-0.2.6}/examples/basic_usage.py +0 -0
  25. {memleaf-0.2.5 → memleaf-0.2.6}/examples/mcp_stdio.ndjson +0 -0
  26. {memleaf-0.2.5 → memleaf-0.2.6}/install.ps1 +0 -0
  27. {memleaf-0.2.5 → memleaf-0.2.6}/install.sh +0 -0
  28. {memleaf-0.2.5 → memleaf-0.2.6}/setup.cfg +0 -0
  29. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/__main__.py +0 -0
  30. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/__init__.py +0 -0
  31. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/antigravity.py +0 -0
  32. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/base.py +0 -0
  33. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/codex.py +0 -0
  34. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/adapters/hermes.py +0 -0
  35. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/budget.py +0 -0
  36. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/capture.py +0 -0
  37. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/cli.py +0 -0
  38. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/compaction.py +0 -0
  39. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/config.py +0 -0
  40. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/credentials.py +0 -0
  41. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/frontmatter.py +0 -0
  42. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/hermes_provider/README.md +0 -0
  43. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/host_events.py +0 -0
  44. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/host_runtime.py +0 -0
  45. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/inbox.py +0 -0
  46. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/index.py +0 -0
  47. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/installer.py +0 -0
  48. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/__init__.py +0 -0
  49. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/base.py +0 -0
  50. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/claude_compatible.py +0 -0
  51. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/gemini.py +0 -0
  52. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/openai_compatible.py +0 -0
  53. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/llm/router.py +0 -0
  54. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/locking.py +0 -0
  55. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/mcp_server.py +0 -0
  56. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/memory_writer.py +0 -0
  57. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/model_discovery.py +0 -0
  58. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/models.py +0 -0
  59. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/native_index.py +0 -0
  60. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/SOURCES.txt +0 -0
  68. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.5 → memleaf-0.2.6}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.5 → memleaf-0.2.6}/tests/__init__.py +0 -0
  72. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_install.py +0 -0
  82. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.5 → memleaf-0.2.6}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.6 — 2026-09-01
6
+
7
+ - Reject one worthy memory that spans multiple independent project scopes and
8
+ retry with an explicit instruction to split actionable project topics while
9
+ discarding temporary mailbox-sweep status items.
10
+ - Tighten automatic admission so aggregate mailbox/daily-report snapshots,
11
+ one-off meetings without durable outcomes, pending PM acceptance, and
12
+ awaiting-feedback states do not become a cross-project permanent memory;
13
+ keep independent risks and actionable todos eligible.
14
+ - Prefer `UPDATE` or `NO_CHANGE` for an existing future-use topic, preserve
15
+ unimplemented customer requests as proposed or pending, and recognize the
16
+ Chinese relative-date forms `今日`, `明日`, and `昨日` in strict retries.
17
+
5
18
  ## 0.2.5 — 2026-09-01
6
19
 
7
20
  - Detect multiple gate candidates that target the same active memory before
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.5
3
+ Version: 0.2.6
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.5。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 PyPI 分发。
26
+ > **当前版本:0.2.6。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -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.5。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 PyPI 分发。
7
+ > **当前版本:0.2.6。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 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.5"
7
+ version = "0.2.6"
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.5"
3
+ __version__ = "0.2.6"
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
@@ -76,6 +76,7 @@ _MODEL_VALIDATION_DETAILS = frozenset(
76
76
  "candidate_shape",
77
77
  "duplicate_candidate_id",
78
78
  "duplicate_update_target",
79
+ "mixed_project_scopes",
79
80
  "invalid_evidence",
80
81
  "invalid_flags",
81
82
  "invalid_type",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.5
2
+ version: 0.2.6
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -33,6 +33,7 @@ from .native_index import NativeIndexer
33
33
  from .prompts import (
34
34
  DUPLICATE_TARGET_CORRECTION,
35
35
  JSON_CORRECTION,
36
+ MIXED_PROJECT_SCOPES_CORRECTION,
36
37
  RELATIVE_TIME_CORRECTION,
37
38
  GATE_SYSTEM,
38
39
  SUMMARY_TARGET_CORRECTION,
@@ -479,6 +480,8 @@ class Processor:
479
480
  stage = getattr(error, "stage", None)
480
481
  if hint == "duplicate_update_target":
481
482
  return DUPLICATE_TARGET_CORRECTION
483
+ if hint == "mixed_project_scopes":
484
+ return MIXED_PROJECT_SCOPES_CORRECTION
482
485
  if hint == "relative_time":
483
486
  return RELATIVE_TIME_CORRECTION
484
487
  if stage == "summarize" and hint == "invalid_update_target":
@@ -47,7 +47,7 @@ Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
47
47
  For a one-off relative date in a candidate or its later summary, use the
48
48
  timestamp of the evidence event that supports it as the calendar anchor and
49
49
  emit an absolute YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
50
- 今天/明天/昨天, 本周X/这周X/下周X/上周X, and this/next/last weekday;
50
+ 今天/明天/昨天/今日/明日/昨日, 本周X/这周X/下周X/上周X, and this/next/last weekday;
51
51
  use Monday-Sunday calendar weeks for week expressions. Do not leave these
52
52
  strong relative forms in a worthy memory, and never guess without a timestamp:
53
53
  defer or omit a date-dependent candidate if its supporting event has no
@@ -92,6 +92,13 @@ be independently retrieved and updated. Prefer one coherent current-state memory
92
92
  over adjacent overlapping snapshots; do not use a score or fixed category rule.
93
93
  Most ordinary turns should produce zero or one candidate; multiple require
94
94
  genuinely independent future questions or actions.
95
+ Never persist an aggregate mailbox sweep, daily report, or "items to watch"
96
+ digest as one cross-project memory. Inspect each item and split genuinely
97
+ independent project risks or actionable todo items; if an item has no
98
+ independent future use, set worth=false. A one-off meeting with no durable
99
+ decision or commitment, a request awaiting PM acceptance, and pending feedback
100
+ or another intermediate status normally have worth=false. Keep an independent
101
+ project risk or concrete future action even when it came from a digest.
95
102
  An intermediate state such as a draft awaiting confirmation, preparation/
96
103
  processing status, temporary error, repeated confirmation, or unconfirmed
97
104
  suggestion normally has no independent future use and is worth=false. Keep one
@@ -113,6 +120,11 @@ operational incident spans stats/search/remember calls; this default remains.
113
120
  Retain only a separate, user-confirmed future-use fact, preference, identity, constraint, commitment,
114
121
  project risk, or durable lesson that prevents repeating the investigation, not
115
122
  the execution transcript.
123
+ If a related active memory already covers the same future question/action and
124
+ the current turn adds no confirmed change, use worth=false and do not create a
125
+ sibling. If a customer or user proposes a new change without confirmation of
126
+ implementation, preserve it as requested/proposed/pending and never state that
127
+ it is already implemented.
116
128
  Return no prose, markdown fences, comments, or trailing text."""
117
129
 
118
130
 
@@ -142,11 +154,15 @@ to the gate candidate type and to the active update target type.
142
154
  For the same use, preserve still-valid facts, add confirmed progress, replace
143
155
  contradictions, and never reduce the memory to the latest operation or create a
144
156
  sibling with a new label.
157
+ Never turn an email sweep, daily report, or watchlist into one cross-project
158
+ summary. Keep only one future-use topic in the summary; split unrelated
159
+ projects at the gate, and omit temporary meeting/awaiting-acceptance/pending-
160
+ feedback states unless they have an independent future action.
145
161
  Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
146
162
  For a one-off relative date in the summary title or body, use the timestamp of
147
163
  the supporting evidence event as the calendar anchor and emit an absolute
148
164
  YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
149
- 今天/明天/昨天, 本周X/这周X/下周X/上周X, and this/next/last weekday;
165
+ 今天/明天/昨天/今日/明日/昨日, 本周X/这周X/下周X/上周X, and this/next/last weekday;
150
166
  use Monday-Sunday calendar weeks for week expressions. Never leave these
151
167
  strong relative forms in the summary, and never guess without a timestamp.
152
168
  Recurring schedules such as 每天、每周三, or every Wednesday are not one-off
@@ -155,7 +171,12 @@ In automatic capture/process mode, a pure read-only query whose answer only
155
171
  restates a related active memory is not a new candidate. It must have been
156
172
  rejected by the gate; do not use duplicate_memory_id or update_memory_id to
157
173
  append the query as a source. Only a newly confirmed fact or state change in
158
- the current evidence may update an existing memory.
174
+ the current evidence may update an existing memory. If a related active memory
175
+ already covers the same future question/action and the current evidence adds no
176
+ confirmed change, retain the existing memory unchanged (the gate should emit
177
+ worth=false) rather than creating a sibling. When the current evidence is a
178
+ customer/user request that is not confirmed implemented, state it as
179
+ requested/proposed/pending and never as completed or deployed.
159
180
  fields and JSON types are title (string), body (string), tags (string list),
160
181
  type (one of preference, fact, project, todo, event, identity, other), scopes
161
182
  (non-empty string list), and sources (non-empty object list). scope_source, if
@@ -210,7 +231,7 @@ RELATIVE_TIME_CORRECTION = (
210
231
  "each one-off relative date from that timestamp and the stated weekday "
211
232
  "semantics. In the summary title and body, every one-off calendar date must "
212
233
  "be written only as YYYY-MM-DD: remove forms such as today/tomorrow/yesterday, "
213
- "今天/明天/昨天, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
234
+ "今天/明天/昨天/今日/明日/昨日, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
214
235
  "If a relative weekday is followed by a parenthesized numeric date and they "
215
236
  "conflict, trust the event timestamp plus the weekday meaning, replace the "
216
237
  "numeric date with the computed YYYY-MM-DD, and remove the relative wording "
@@ -220,6 +241,19 @@ RELATIVE_TIME_CORRECTION = (
220
241
  )
221
242
 
222
243
 
244
+ MIXED_PROJECT_SCOPES_CORRECTION = (
245
+ "Previous output violated: mixed_project_scopes. Keep each worthy "
246
+ "candidate and summary atomic: it may contain at most one distinct "
247
+ "project:<name> scope. At the gate, split genuinely independent projects "
248
+ "into separate candidates, preserving their own evidence, or mark purely "
249
+ "temporary meeting/awaiting-acceptance/pending-feedback items worth=false. "
250
+ "In a summary, keep only one future-use project topic and its matching "
251
+ "details. global plus one project and valid domain/portfolio parent scopes "
252
+ "are allowed; do not remove those parent scopes merely to pass validation. "
253
+ "Return only the strict JSON object."
254
+ )
255
+
256
+
223
257
  DUPLICATE_TARGET_CORRECTION = (
224
258
  "Previous output violated: duplicate_update_target. Merge every candidate "
225
259
  "that refers to the same active memory target into one candidate for one "
@@ -52,6 +52,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
52
52
  "candidate_shape",
53
53
  "duplicate_candidate_id",
54
54
  "duplicate_update_target",
55
+ "mixed_project_scopes",
55
56
  "invalid_evidence",
56
57
  "invalid_flags",
57
58
  "invalid_type",
@@ -72,7 +73,7 @@ _RELATIVE_CALENDAR_EXPRESSION = re.compile(
72
73
  r"(?<![A-Za-z])(?:today|tomorrow|yesterday)(?![A-Za-z])"
73
74
  r"|(?<![A-Za-z])(?:this|next|last)\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?![A-Za-z])"
74
75
  r"|(?:本|这|下|上)(?:个)?(?:周|星期|礼拜)\s*(?:一|二|三|四|五|六|日|天|末|[1-7])"
75
- r"|(?:今天|明天|昨天)"
76
+ r"|(?:今天|明天|昨天|今日|明日|昨日)"
76
77
  r")",
77
78
  re.IGNORECASE,
78
79
  )
@@ -228,6 +229,25 @@ def _scopes(value: Any, scope_source: Any) -> list[str]:
228
229
  return result
229
230
 
230
231
 
232
+ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
233
+ """Keep one worthy memory atomic across independent project scopes.
234
+
235
+ Parent scopes (global/domain/portfolio) may accompany one project scope;
236
+ only two or more distinct project scopes represent an ambiguous aggregate.
237
+ """
238
+
239
+ project_scopes = {
240
+ scope.casefold()
241
+ for scope in scopes
242
+ if isinstance(scope, str) and scope.partition(":")[0] == "project"
243
+ }
244
+ if len(project_scopes) > 1:
245
+ raise ModelOutputError(
246
+ "one memory cannot cover multiple project scopes",
247
+ validation_detail="mixed_project_scopes",
248
+ )
249
+
250
+
231
251
  def _memory_id(value: Any, field: str) -> str:
232
252
  result = _string(value, field)
233
253
  if "/" in result or "\\" in result or result in (".", ".."):
@@ -535,7 +555,9 @@ def validate_gate_output(
535
555
  target_ids.add(target_key)
536
556
  if not isinstance(item["scope_source"], str) or item["scope_source"] not in SCOPE_SOURCES:
537
557
  raise ModelOutputError("invalid scope_source", validation_detail="invalid_scope_source")
538
- _scopes(item["scopes"], item["scope_source"])
558
+ item["scopes"] = _scopes(item["scopes"], item["scope_source"])
559
+ if item["worth"]:
560
+ _reject_mixed_project_scopes(item["scopes"])
539
561
  if "reason" in item:
540
562
  _string(item["reason"], "reason", nonempty=False)
541
563
  if len(item["reason"]) > 30:
@@ -659,6 +681,7 @@ def validate_summarize_output(
659
681
  validation_detail="invalid_type",
660
682
  )
661
683
  item["scopes"] = _scopes(item["scopes"], item.get("scope_source"))
684
+ _reject_mixed_project_scopes(item["scopes"])
662
685
  item["scope_operations"] = _scope_operations(
663
686
  item.get("scope_operations"),
664
687
  summary_scopes=item["scopes"],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.5
3
+ Version: 0.2.6
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.5。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 PyPI 分发。
26
+ > **当前版本:0.2.6。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -1734,6 +1734,23 @@ class HermesProviderTests(unittest.TestCase):
1734
1734
  self.assertIsNotNone(fields)
1735
1735
  self.assertEqual(fields[4], "duplicate_update_target")
1736
1736
 
1737
+ def test_mixed_project_scope_validation_detail_is_preserved(self) -> None:
1738
+ value = {
1739
+ "isError": True,
1740
+ "structuredContent": {
1741
+ "error": {
1742
+ "code": "model_invalid_response",
1743
+ "stage": "gate",
1744
+ "validation_reason": "schema_violation",
1745
+ "validation_detail": "mixed_project_scopes",
1746
+ "attempt_count": 3,
1747
+ }
1748
+ },
1749
+ }
1750
+ fields = provider_module._mcp_error_fields(value)
1751
+ self.assertIsNotNone(fields)
1752
+ self.assertEqual(fields[4], "mixed_project_scopes")
1753
+
1737
1754
  def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
1738
1755
  value = {
1739
1756
  "isError": True,
@@ -39,7 +39,7 @@ class PyPIInstallTests(unittest.TestCase):
39
39
 
40
40
  self.assertTrue(target.is_dir())
41
41
  self.assertFalse(target.is_symlink())
42
- self.assertIn("version: 0.2.5", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.6", (target / "plugin.yaml").read_text(encoding="utf-8"))
43
43
 
44
44
  def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
45
45
  with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
@@ -12,6 +12,7 @@ from memleaf.inbox import complete_turns, parse_inbox
12
12
  from memleaf.prompts import (
13
13
  DUPLICATE_TARGET_CORRECTION,
14
14
  GATE_SYSTEM,
15
+ MIXED_PROJECT_SCOPES_CORRECTION,
15
16
  RELATIVE_TIME_CORRECTION,
16
17
  SUMMARY_TARGET_CORRECTION,
17
18
  SUMMARY_TYPE_CORRECTION,
@@ -184,6 +185,7 @@ class StageB1Test(unittest.TestCase):
184
185
  self.assertIn("ISO-8601 UTC timestamp", prompt_system)
185
186
  self.assertIn("YYYY-MM-DD", prompt_system)
186
187
  self.assertIn("本周X/这周X/下周X/上周X", prompt_system)
188
+ self.assertIn("今日/明日/昨日", prompt_system)
187
189
  self.assertIn("every Wednesday", prompt_system)
188
190
 
189
191
  def test_relative_summary_is_rejected_then_absolute_retry_is_committed(self):
@@ -1179,6 +1181,11 @@ class ValidationTest(unittest.TestCase):
1179
1181
  self.assertIn("immutable", SUMMARY_TARGET_CORRECTION.lower())
1180
1182
  self.assertIn("exactly equal", SUMMARY_TYPE_CORRECTION.lower())
1181
1183
 
1184
+ def test_correction_prompt_explains_project_scope_atomicity(self):
1185
+ self.assertIn("mixed_project_scopes", MIXED_PROJECT_SCOPES_CORRECTION)
1186
+ self.assertIn("split", MIXED_PROJECT_SCOPES_CORRECTION.lower())
1187
+ self.assertIn("global plus one project", MIXED_PROJECT_SCOPES_CORRECTION.lower())
1188
+
1182
1189
  def test_mcp_model_diagnostics_preserve_duplicate_target_detail(self):
1183
1190
  error = ModelOutputError(
1184
1191
  "safe",
@@ -1331,6 +1338,63 @@ class ValidationTest(unittest.TestCase):
1331
1338
  ["event-a"],
1332
1339
  )
1333
1340
 
1341
+ def test_worthy_memory_rejects_multiple_project_scopes_but_allows_parent_context(self):
1342
+ base = {
1343
+ "candidate_id": "c",
1344
+ "memory": "one future-use topic",
1345
+ "evidence_event_ids": ["event-a"],
1346
+ "duplicate": False,
1347
+ "worth": True,
1348
+ "type": "project",
1349
+ "scope_source": "model",
1350
+ }
1351
+ invalid = dict(base, scopes=["project:cn", "project:fund"])
1352
+ with self.assertRaises(ModelOutputError) as raised:
1353
+ parse_gate_output(json.dumps({"candidates": [invalid]}), ["event-a"])
1354
+ self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
1355
+
1356
+ for scopes in (
1357
+ ["global", "project:cn"],
1358
+ ["portfolio:finance", "project:cn"],
1359
+ ["domain:work", "portfolio:finance", "project:cn"],
1360
+ ):
1361
+ with self.subTest(scopes=scopes):
1362
+ parsed = parse_gate_output(
1363
+ json.dumps({"candidates": [dict(base, scopes=scopes)]}),
1364
+ ["event-a"],
1365
+ )
1366
+ self.assertEqual(parsed["candidates"][0]["scopes"], scopes)
1367
+
1368
+ summary = {
1369
+ "title": "One topic",
1370
+ "body": "One future-use topic.",
1371
+ "tags": [],
1372
+ "type": "project",
1373
+ "scopes": ["project:cn", "project:fund"],
1374
+ "scope_source": "model",
1375
+ "sources": [{"event_key": "event-a"}],
1376
+ }
1377
+ with self.assertRaises(ModelOutputError) as raised:
1378
+ parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
1379
+ self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
1380
+
1381
+ def test_chinese_relative_date_aliases_are_rejected(self):
1382
+ for relative in ("今日", "明日", "昨日"):
1383
+ with self.subTest(relative=relative):
1384
+ summary = {
1385
+ "title": f"截止{relative}",
1386
+ "body": "完成项目交付。",
1387
+ "tags": [],
1388
+ "type": "todo",
1389
+ "scopes": ["global"],
1390
+ "scope_source": "model",
1391
+ "sources": [{"event_key": "event-a"}],
1392
+ "status": "active",
1393
+ }
1394
+ with self.assertRaises(ModelOutputError) as raised:
1395
+ parse_summarize_output(json.dumps(summary), current_event_keys=["event-a"])
1396
+ self.assertEqual(raised.exception.validation_detail, "relative_time")
1397
+
1334
1398
  def test_summary_evidence_and_sources_are_limited_to_current_turn_fields(self):
1335
1399
  summary = {
1336
1400
  "title": "Traceable",
@@ -182,6 +182,205 @@ class StageB2ATest(unittest.TestCase):
182
182
  self.assertEqual(len(backend.calls), calls_after_first)
183
183
  self.assertEqual(len(self.knowledge(service)), 2)
184
184
 
185
+ def test_mixed_project_digest_retries_to_atomic_project_outputs(self):
186
+ service = self.service(name="mixed-project-digest")
187
+ history_memory = service.create_memory(
188
+ memory_id="mem-zhongyin-history",
189
+ title="中银国际历史需求",
190
+ body="中银国际历史需求已完整覆盖:需求清单逐项确认、信创测试环境部署、历史数据库和附件全量迁移。",
191
+ type="project",
192
+ scopes=["project:zhongyin"],
193
+ )
194
+ existing = service.create_memory(
195
+ memory_id="mem-zhongyin-plan",
196
+ title="中银国际实施计划",
197
+ body="中银国际实施计划当前按原始安排执行。",
198
+ type="project",
199
+ scopes=["project:zhongyin"],
200
+ )
201
+ config = service.vault.config()
202
+ config["llm"]["diagnostic_logging"] = True
203
+ save_config(service.vault.config_path, config)
204
+ user_key, assistant_key = self.capture_turn(
205
+ service,
206
+ session="mixed-project-digest",
207
+ user_event="mixed-digest-user",
208
+ assistant_event="mixed-digest-assistant",
209
+ user=(
210
+ "邮箱巡检:中银国际历史需求已覆盖需求清单逐项确认、信创测试环境部署、"
211
+ "历史数据库和附件全量迁移;客户提出单点登录提前并行、数据文件规范提前确认、"
212
+ "重新压实实施计划;摩根基金本周三排查证券申报类型;会议待确认;"
213
+ "Orion待PM受理;金元顺安待反馈。"
214
+ ),
215
+ assistant="会议和待办已整理,后续按项目分别跟进。",
216
+ )
217
+
218
+ def scoped(candidate_id, *, memory, type, scopes, worth=True, update_memory_id=None):
219
+ value = self.candidate(
220
+ candidate_id,
221
+ [user_key, assistant_key],
222
+ memory=memory,
223
+ type=type,
224
+ worth=worth,
225
+ update_memory_id=update_memory_id,
226
+ )
227
+ value["scopes"] = list(scopes)
228
+ return value
229
+
230
+ mixed = scoped(
231
+ "digest",
232
+ memory="2026-09-01邮箱巡检需关注事项",
233
+ type="fact",
234
+ scopes=[
235
+ "project:zhongyin",
236
+ "project:morgan",
237
+ "project:orion",
238
+ "project:jinyuan",
239
+ ],
240
+ )
241
+ corrected = [
242
+ scoped(
243
+ "zhongyin-plan",
244
+ memory="中银国际实施计划新增客户建议:单点登录提前并行、数据文件规范提前确认、重新压实实施计划",
245
+ type="project",
246
+ scopes=["project:zhongyin"],
247
+ update_memory_id=existing.memory_id,
248
+ ),
249
+ scoped(
250
+ "morgan-todo",
251
+ memory="摩根基金排查证券申报类型",
252
+ type="todo",
253
+ scopes=["project:morgan"],
254
+ ),
255
+ scoped(
256
+ "orion-pending",
257
+ memory="Orion待PM受理",
258
+ type=None,
259
+ scopes=["project:orion"],
260
+ worth=False,
261
+ ),
262
+ scoped(
263
+ "jinyuan-pending",
264
+ memory="金元顺安待反馈",
265
+ type=None,
266
+ scopes=["project:jinyuan"],
267
+ worth=False,
268
+ ),
269
+ ]
270
+ backend = QueueBackend(
271
+ [
272
+ self.gate([mixed]),
273
+ self.gate(corrected),
274
+ self.summary(
275
+ user_key,
276
+ title="中银国际实施计划",
277
+ body=(
278
+ "客户提出单点登录提前并行、数据文件规范提前确认、重新压实实施计划;"
279
+ "当前待纳入,尚未表示已落地。"
280
+ ),
281
+ type="project",
282
+ scopes=["project:zhongyin"],
283
+ update_memory_id=existing.memory_id,
284
+ ),
285
+ self.summary(
286
+ user_key,
287
+ title="摩根基金证券申报类型排查",
288
+ body="待排查摩根基金证券申报类型,截止日期为2026-09-02。",
289
+ type="todo",
290
+ scopes=["project:morgan"],
291
+ status="active",
292
+ ),
293
+ ]
294
+ )
295
+
296
+ result = service.process(
297
+ source="codex",
298
+ session_id="mixed-project-digest",
299
+ model=backend,
300
+ )
301
+
302
+ self.assertEqual(result["processed_turns"], 1)
303
+ self.assertEqual(result["memories_written"], 2)
304
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "summarize", "summarize"])
305
+ self.assertIn("Previous output violated: mixed_project_scopes.", backend.calls[1]["prompt"])
306
+ active = self.knowledge(service)
307
+ self.assertEqual(len(active), 3)
308
+ active_by_id = {record.memory.memory_id: record.memory for record in active}
309
+ self.assertIn(history_memory.memory_id, active_by_id)
310
+ self.assertIn(existing.memory_id, active_by_id)
311
+ self.assertTrue(all(sum(scope.startswith("project:") for scope in record.memory.scopes) <= 1 for record in active))
312
+ self.assertEqual(active_by_id[existing.memory_id].type, "project")
313
+ self.assertIn("客户提出", active_by_id[existing.memory_id].body)
314
+ self.assertIn("待纳入", active_by_id[existing.memory_id].body)
315
+ todo = next(
316
+ memory
317
+ for memory_id, memory in active_by_id.items()
318
+ if memory_id not in {history_memory.memory_id, existing.memory_id}
319
+ )
320
+ self.assertEqual(
321
+ set(active_by_id),
322
+ {history_memory.memory_id, existing.memory_id, todo.memory_id},
323
+ )
324
+ self.assertEqual(todo.type, "todo")
325
+ self.assertEqual(todo.scopes, ["project:morgan"])
326
+ self.assertEqual(active_by_id[history_memory.memory_id].body, history_memory.body)
327
+ self.assertIn("单点登录提前并行", active_by_id[existing.memory_id].body)
328
+ self.assertNotIn("需求清单逐项确认", active_by_id[existing.memory_id].body)
329
+ self.assertNotIn("历史数据库和附件全量迁移", active_by_id[existing.memory_id].body)
330
+ history = service._read_memories_unlocked("history")
331
+ self.assertEqual(len(history), 1)
332
+ self.assertEqual(history[0].memory.extra["active_memory_id"], existing.memory_id)
333
+ self.assertEqual(history[0].memory.body, existing.body)
334
+ bodies = "\n".join(record.memory.body for record in active)
335
+ self.assertNotIn("邮箱巡检", bodies)
336
+ self.assertNotIn("需关注事项", bodies)
337
+ self.assertNotIn("待PM受理", bodies)
338
+ self.assertNotIn("待反馈", bodies)
339
+ diagnostics = [
340
+ json.loads(line)
341
+ for line in (service.vault.logs_path / "model-diagnostics.jsonl").read_text(encoding="utf-8").splitlines()
342
+ ]
343
+ self.assertEqual(diagnostics[0]["validation_detail"], "mixed_project_scopes")
344
+
345
+ def test_three_mixed_project_gate_failures_keep_inbox_and_watermark(self):
346
+ backend = QueueBackend()
347
+ service = self.service(backend, name="mixed-gate-failure")
348
+ user_key, assistant_key = self.capture_turn(
349
+ service,
350
+ session="mixed-gate-failure",
351
+ user_event="mixed-gate-failure-user",
352
+ assistant_event="mixed-gate-failure-assistant",
353
+ )
354
+ mixed = self.candidate(
355
+ "mixed-gate-candidate",
356
+ [user_key, assistant_key],
357
+ memory="cross-project digest",
358
+ type="fact",
359
+ )
360
+ mixed["scopes"] = ["project:zhongyin", "project:morgan"]
361
+ backend.responses.extend([self.gate([mixed])] * 3)
362
+
363
+ with self.assertRaises(ModelOutputError) as raised:
364
+ service.process(
365
+ source="codex",
366
+ session_id="mixed-gate-failure",
367
+ model=backend,
368
+ )
369
+
370
+ self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
371
+ self.assertEqual(raised.exception.stage, "gate")
372
+ self.assertEqual(raised.exception.attempt_count, 3)
373
+ marker = self.processed(service)["sessions"]["codex/mixed-gate-failure"]["processing"]
374
+ self.assertEqual(marker["failure_stage"], "gate")
375
+ self.assertEqual(marker["validation_detail"], "mixed_project_scopes")
376
+ self.assertEqual(marker["attempt_count"], 3)
377
+ self.assertEqual(
378
+ self.processed(service)["sessions"]["codex/mixed-gate-failure"].get("watermark", 0),
379
+ 0,
380
+ )
381
+ self.assertTrue((service.vault.inbox_path / "codex" / "mixed-gate-failure.md").is_file())
382
+ self.assertEqual(self.knowledge(service), [])
383
+
185
384
  def test_draft_turn_is_discarded_and_final_email_state_is_the_only_memory(self):
186
385
  backend = QueueBackend()
187
386
  service = self.service(backend, name="email-final-only")
@@ -531,6 +730,154 @@ class StageB2ATest(unittest.TestCase):
531
730
  self.assertEqual(service.read(existing.memory_id).body, "The fact is updated.")
532
731
  self.assertEqual(len(service.vault.list_markdown("history")), 1)
533
732
 
733
+ def test_mixed_project_summary_retries_before_writing(self):
734
+ backend = QueueBackend()
735
+ service = self.service(backend, name="mixed-summary-retry")
736
+ user_key, _ = self.capture_turn(
737
+ service,
738
+ turn="mixed-summary",
739
+ user_event="mixed-summary-user",
740
+ assistant_event="mixed-summary-assistant",
741
+ )
742
+ candidate = self.candidate(
743
+ "mixed-summary-candidate",
744
+ [user_key],
745
+ memory="one project topic",
746
+ type="project",
747
+ )
748
+ candidate["scopes"] = ["project:zhongyin"]
749
+ mixed_summary = self.summary(
750
+ user_key,
751
+ title="One topic",
752
+ body="One project topic.",
753
+ type="project",
754
+ scopes=["project:zhongyin", "project:morgan"],
755
+ )
756
+ corrected_summary = self.summary(
757
+ user_key,
758
+ title="One topic",
759
+ body="One project topic.",
760
+ type="project",
761
+ scopes=["project:zhongyin"],
762
+ )
763
+ backend.responses.extend([self.gate([candidate]), mixed_summary, corrected_summary])
764
+
765
+ result = service.process()
766
+
767
+ self.assertEqual(result["processed_turns"], 1)
768
+ self.assertEqual(result["memories_written"], 1)
769
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize", "summarize"])
770
+ self.assertIn("Previous output violated: mixed_project_scopes.", backend.calls[2]["prompt"])
771
+ self.assertEqual(service._read_memories_unlocked("knowledge")[0].memory.scopes, ["project:zhongyin"])
772
+
773
+ def test_three_mixed_project_summary_failures_keep_inbox_and_watermark(self):
774
+ backend = QueueBackend()
775
+ service = self.service(backend, name="mixed-summary-failure")
776
+ user_key, _ = self.capture_turn(
777
+ service,
778
+ turn="mixed-summary-failure",
779
+ user_event="mixed-summary-failure-user",
780
+ assistant_event="mixed-summary-failure-assistant",
781
+ )
782
+ candidate = self.candidate(
783
+ "mixed-summary-failure-candidate",
784
+ [user_key],
785
+ memory="one project topic",
786
+ type="project",
787
+ )
788
+ candidate["scopes"] = ["project:zhongyin"]
789
+ mixed_summary = self.summary(
790
+ user_key,
791
+ title="One topic",
792
+ body="One project topic.",
793
+ type="project",
794
+ scopes=["project:zhongyin", "project:morgan"],
795
+ )
796
+ backend.responses.extend([self.gate([candidate]), mixed_summary, mixed_summary, mixed_summary])
797
+
798
+ with self.assertRaises(ModelOutputError) as raised:
799
+ service.process()
800
+
801
+ self.assertEqual(raised.exception.validation_detail, "mixed_project_scopes")
802
+ self.assertEqual(raised.exception.stage, "summarize")
803
+ self.assertEqual(raised.exception.attempt_count, 3)
804
+ marker = self.processed(service)["sessions"]["codex/s"]["processing"]
805
+ self.assertEqual(marker["failure_stage"], "summarize")
806
+ self.assertEqual(marker["validation_detail"], "mixed_project_scopes")
807
+ self.assertEqual(marker["attempt_count"], 3)
808
+ self.assertEqual(self.processed(service)["sessions"]["codex/s"].get("watermark", 0), 0)
809
+ self.assertTrue((service.vault.inbox_path / "codex" / "s.md").is_file())
810
+ self.assertEqual(service._read_memories_unlocked("knowledge"), [])
811
+
812
+ def test_chinese_relative_date_alias_retries_to_absolute_date(self):
813
+ anchor = "2026-09-01T02:01:41Z"
814
+ backend = QueueBackend()
815
+ with patch("memleaf.capture._timestamp", return_value=anchor):
816
+ service = self.service(backend, name="chinese-relative-retry-anchored")
817
+ user_key, _ = self.capture_turn(
818
+ service,
819
+ turn="chinese-relative",
820
+ user_event="chinese-relative-user",
821
+ assistant_event="chinese-relative-assistant",
822
+ )
823
+ candidate = self.candidate(
824
+ "chinese-relative-candidate",
825
+ [user_key],
826
+ memory="完成截止日期",
827
+ type="todo",
828
+ )
829
+ backend.responses.extend(
830
+ [
831
+ self.gate([candidate]),
832
+ self.summary(user_key, title="截止昨日", body="昨日完成。", type="todo", status="active"),
833
+ self.summary(user_key, title="截止日期", body="截止日期为2026-08-31。", type="todo", status="active"),
834
+ ]
835
+ )
836
+ result = service.process()
837
+
838
+ self.assertEqual(result["processed_turns"], 1)
839
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize", "summarize"])
840
+ self.assertIn("今日/明日/昨日", backend.calls[2]["prompt"])
841
+ self.assertEqual(service._read_memories_unlocked("knowledge")[0].memory.body, "截止日期为2026-08-31。")
842
+
843
+ def test_three_chinese_relative_date_failures_keep_inbox_and_watermark(self):
844
+ anchor = "2026-09-01T02:01:41Z"
845
+ backend = QueueBackend()
846
+ service = self.service(backend, name="chinese-relative-failure")
847
+ with patch("memleaf.capture._timestamp", return_value=anchor):
848
+ user_key, _ = self.capture_turn(
849
+ service,
850
+ turn="chinese-relative-failure",
851
+ user_event="chinese-relative-failure-user",
852
+ assistant_event="chinese-relative-failure-assistant",
853
+ )
854
+ candidate = self.candidate(
855
+ "chinese-relative-failure-candidate",
856
+ [user_key],
857
+ memory="完成截止日期",
858
+ type="todo",
859
+ )
860
+ bad_summary = self.summary(
861
+ user_key,
862
+ title="截止今日",
863
+ body="今日完成。",
864
+ type="todo",
865
+ status="active",
866
+ )
867
+ backend.responses.extend([self.gate([candidate]), bad_summary, bad_summary, bad_summary])
868
+
869
+ with self.assertRaises(ModelOutputError) as raised:
870
+ service.process()
871
+
872
+ self.assertEqual(raised.exception.validation_detail, "relative_time")
873
+ self.assertEqual(raised.exception.stage, "summarize")
874
+ self.assertEqual(raised.exception.attempt_count, 3)
875
+ marker = self.processed(service)["sessions"]["codex/s"]["processing"]
876
+ self.assertEqual(marker["validation_detail"], "relative_time")
877
+ self.assertEqual(marker["attempt_count"], 3)
878
+ self.assertEqual(self.processed(service)["sessions"]["codex/s"].get("watermark", 0), 0)
879
+ self.assertTrue((service.vault.inbox_path / "codex" / "s.md").is_file())
880
+
534
881
  def test_duplicate_update_target_is_rejected_at_gate_and_retried(self):
535
882
  """Duplicate targets are corrected before the writer commit phase."""
536
883
 
@@ -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.5"},
107
+ {"name": "memleaf", "version": "0.2.6"},
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.5")
27
+ self.assertEqual(self.project["version"], "0.2.6")
28
28
  self.assertEqual(self.project["requires-python"], ">=3.11")
29
29
  self.assertEqual(self.project["dependencies"], [])
30
30
  self.assertEqual(self.project["license"], "MIT")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
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