memleaf 0.2.3__tar.gz → 0.2.5__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.3 → memleaf-0.2.5}/CHANGELOG.md +20 -0
  2. {memleaf-0.2.3 → memleaf-0.2.5}/PKG-INFO +3 -3
  3. {memleaf-0.2.3 → memleaf-0.2.5}/README.en.md +2 -2
  4. {memleaf-0.2.3 → memleaf-0.2.5}/README.md +2 -2
  5. {memleaf-0.2.3 → memleaf-0.2.5}/pyproject.toml +1 -1
  6. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/hermes_provider/__init__.py +2 -0
  8. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/memory_writer.py +41 -13
  10. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/processing.py +76 -5
  11. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/prompts.py +51 -0
  12. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/validation.py +60 -0
  13. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf.egg-info/PKG-INFO +3 -3
  14. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_hermes_provider.py +55 -0
  15. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_pypi_install.py +1 -1
  16. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b1.py +111 -2
  17. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b2a.py +260 -1
  18. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_c1_mcp.py +1 -1
  19. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_c3_packaging.py +1 -1
  20. {memleaf-0.2.3 → memleaf-0.2.5}/IMPLEMENTATION_PLAN.md +0 -0
  21. {memleaf-0.2.3 → memleaf-0.2.5}/LICENSE +0 -0
  22. {memleaf-0.2.3 → memleaf-0.2.5}/MANIFEST.in +0 -0
  23. {memleaf-0.2.3 → memleaf-0.2.5}/RELEASE_CHECKLIST.md +0 -0
  24. {memleaf-0.2.3 → memleaf-0.2.5}/examples/README.md +0 -0
  25. {memleaf-0.2.3 → memleaf-0.2.5}/examples/basic_usage.py +0 -0
  26. {memleaf-0.2.3 → memleaf-0.2.5}/examples/mcp_stdio.ndjson +0 -0
  27. {memleaf-0.2.3 → memleaf-0.2.5}/install.ps1 +0 -0
  28. {memleaf-0.2.3 → memleaf-0.2.5}/install.sh +0 -0
  29. {memleaf-0.2.3 → memleaf-0.2.5}/setup.cfg +0 -0
  30. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/__main__.py +0 -0
  31. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/adapters/__init__.py +0 -0
  32. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/adapters/antigravity.py +0 -0
  33. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/adapters/base.py +0 -0
  34. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/adapters/codex.py +0 -0
  35. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/adapters/hermes.py +0 -0
  36. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/budget.py +0 -0
  37. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/capture.py +0 -0
  38. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/cli.py +0 -0
  39. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/compaction.py +0 -0
  40. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/config.py +0 -0
  41. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/credentials.py +0 -0
  42. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/frontmatter.py +0 -0
  43. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/hermes_provider/README.md +0 -0
  44. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/model_discovery.py +0 -0
  58. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/models.py +0 -0
  59. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/native_index.py +0 -0
  60. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf.egg-info/SOURCES.txt +0 -0
  68. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.3 → memleaf-0.2.5}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.3 → memleaf-0.2.5}/tests/__init__.py +0 -0
  72. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_install.py +0 -0
  82. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.3 → memleaf-0.2.5}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.5 — 2026-09-01
6
+
7
+ - Detect multiple gate candidates that target the same active memory before
8
+ batch writing, then retry with an explicit instruction to merge the
9
+ candidates while preserving all supporting evidence.
10
+ - Keep gate-selected update targets and active-memory types stable through
11
+ summarization, with bounded corrective retries for target or type drift.
12
+ - Preserve `duplicate_update_target` and aggregate preflight diagnostics across
13
+ Core, MCP, and Hermes while retaining the inbox and zero-write transaction
14
+ boundary after repeated failure.
15
+
16
+ ## 0.2.4 — 2026-09-01
17
+
18
+ - Give `relative_time` retries explicit, actionable correction instructions so
19
+ the summarizer recalculates one-off dates from evidence timestamps, removes
20
+ relative wording, and resolves conflicting parenthesized dates before the
21
+ strict validator accepts the memory.
22
+ - Preserve the `relative_time` validation detail across the Hermes provider
23
+ boundary instead of degrading it to `other_schema_violation` in diagnostics.
24
+
5
25
  ## 0.2.3 — 2026-09-01
6
26
 
7
27
  - Preserve capture event timestamps in extraction prompts and resolve one-off
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.3
3
+ Version: 0.2.5
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.3。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 PyPI 分发。
26
+ > **当前版本:0.2.5。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 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.3.**
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.3 is distributed through PyPI.
7
+ > **Version: 0.2.4.**
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.4 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.3。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 PyPI 分发。
7
+ > **当前版本:0.2.5。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 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.3"
7
+ version = "0.2.5"
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.3"
3
+ __version__ = "0.2.5"
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
@@ -75,6 +75,7 @@ _MODEL_VALIDATION_DETAILS = frozenset(
75
75
  "unknown_fields",
76
76
  "candidate_shape",
77
77
  "duplicate_candidate_id",
78
+ "duplicate_update_target",
78
79
  "invalid_evidence",
79
80
  "invalid_flags",
80
81
  "invalid_type",
@@ -85,6 +86,7 @@ _MODEL_VALIDATION_DETAILS = frozenset(
85
86
  "reason_too_long",
86
87
  "source_shape",
87
88
  "todo_fields",
89
+ "relative_time",
88
90
  "other_schema_violation",
89
91
  }
90
92
  )
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.3
2
+ version: 0.2.5
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -133,6 +133,25 @@ class MemoryWriter:
133
133
  }
134
134
  return request_keys.issubset(applied_keys)
135
135
 
136
+ @staticmethod
137
+ def _preflight_error(
138
+ message: str,
139
+ *,
140
+ validation_detail: str = "other_schema_violation",
141
+ ) -> ModelOutputError:
142
+ """Return a safe, stage-labelled batch-consistency failure."""
143
+
144
+ error = ModelOutputError(
145
+ message,
146
+ validation_reason="schema_violation",
147
+ validation_detail=validation_detail,
148
+ )
149
+ # The model outputs that form this batch have already passed their
150
+ # individual summaries. Keep the public stage vocabulary bounded
151
+ # while making a defensive aggregate failure diagnosable.
152
+ error.stage = "summarize"
153
+ return error
154
+
136
155
  def _preflight(self, requests: list[Mapping[str, Any]]) -> None:
137
156
  active = self._active_records()
138
157
  target_ids: set[str] = set()
@@ -144,11 +163,14 @@ class MemoryWriter:
144
163
  duplicate_id = request.get("duplicate_memory_id")
145
164
  if duplicate_id is not None:
146
165
  if not isinstance(duplicate_id, str) or duplicate_id not in active:
147
- raise ModelOutputError("duplicate target is not an active memory")
166
+ raise self._preflight_error("duplicate target is not an active memory")
148
167
  if duplicate_id in duplicate_ids:
149
- raise ModelOutputError("batch contains duplicate metadata merge target")
168
+ raise self._preflight_error(
169
+ "batch contains duplicate metadata merge target",
170
+ validation_detail="duplicate_update_target",
171
+ )
150
172
  if summary.get("update_memory_id") is not None:
151
- raise ModelOutputError("duplicate target conflicts with update target")
173
+ raise self._preflight_error("duplicate target conflicts with update target")
152
174
  duplicate_ids.add(duplicate_id)
153
175
  continue
154
176
  target_id = summary.get("update_memory_id")
@@ -161,12 +183,15 @@ class MemoryWriter:
161
183
  )
162
184
  seen_turns = target_turns.setdefault(target_id, set())
163
185
  if turn_identity in seen_turns:
164
- raise ModelOutputError("batch contains duplicate update target")
186
+ raise self._preflight_error(
187
+ "batch contains duplicate update target",
188
+ validation_detail="duplicate_update_target",
189
+ )
165
190
  seen_turns.add(turn_identity)
166
191
  target_ids.add(target_id)
167
192
  target = active.get(target_id)
168
193
  if target is None:
169
- raise ModelOutputError("update target is not an active memory")
194
+ raise self._preflight_error("update target is not an active memory")
170
195
  target_memory = getattr(target, "memory", target)
171
196
  target_type = (
172
197
  target_memory.type
@@ -176,19 +201,22 @@ class MemoryWriter:
176
201
  else None
177
202
  )
178
203
  if target_type != summary.get("type"):
179
- raise ModelOutputError("update target type does not match summary")
204
+ raise self._preflight_error(
205
+ "update target type does not match summary",
206
+ validation_detail="invalid_type",
207
+ )
180
208
  deterministic_id = request["memory_id"]
181
209
  if deterministic_id in memory_ids:
182
- raise ModelOutputError("batch contains duplicate deterministic memory id")
210
+ raise self._preflight_error("batch contains duplicate deterministic memory id")
183
211
  if deterministic_id in duplicate_ids:
184
- raise ModelOutputError("batch memory id collides with metadata merge target")
212
+ raise self._preflight_error("batch memory id collides with metadata merge target")
185
213
  if deterministic_id in target_ids:
186
- raise ModelOutputError("batch memory id collides with update target")
214
+ raise self._preflight_error("batch memory id collides with update target")
187
215
  if target_id is not None and target_id.casefold() == deterministic_id.casefold():
188
- raise ModelOutputError("batch memory id collides with update target")
216
+ raise self._preflight_error("batch memory id collides with update target")
189
217
  memory_ids.add(deterministic_id)
190
218
  if "/" in deterministic_id or "\\" in deterministic_id or deterministic_id in (".", ".."):
191
- raise ModelOutputError("invalid deterministic memory id")
219
+ raise self._preflight_error("invalid deterministic memory id")
192
220
  # Make the request visible to the rest of this same commit batch.
193
221
  # This is needed when a later pending turn updates a memory that
194
222
  # an earlier pending turn has just created; the real filesystem
@@ -198,9 +226,9 @@ class MemoryWriter:
198
226
  else:
199
227
  active[deterministic_id] = summary
200
228
  if target_ids.intersection(duplicate_ids):
201
- raise ModelOutputError("batch update target collides with metadata merge target")
229
+ raise self._preflight_error("batch update target collides with metadata merge target")
202
230
  if memory_ids.intersection(duplicate_ids):
203
- raise ModelOutputError("batch memory id collides with metadata merge target")
231
+ raise self._preflight_error("batch memory id collides with metadata merge target")
204
232
 
205
233
  def _core_sources(self, request: Mapping[str, Any]) -> list[dict[str, Any]]:
206
234
  turn = request["turn"]
@@ -30,7 +30,17 @@ from .locking import atomic_write_json, atomic_write_text, read_json
30
30
  from .memory_writer import MemoryWriter
31
31
  from .models import Memory, utc_now
32
32
  from .native_index import NativeIndexer
33
- from .prompts import JSON_CORRECTION, GATE_SYSTEM, SUMMARIZE_SYSTEM, gate_prompt, summarize_prompt
33
+ from .prompts import (
34
+ DUPLICATE_TARGET_CORRECTION,
35
+ JSON_CORRECTION,
36
+ RELATIVE_TIME_CORRECTION,
37
+ GATE_SYSTEM,
38
+ SUMMARY_TARGET_CORRECTION,
39
+ SUMMARY_TYPE_CORRECTION,
40
+ SUMMARIZE_SYSTEM,
41
+ gate_prompt,
42
+ summarize_prompt,
43
+ )
34
44
  from .redaction import redact_text
35
45
  from .retrieval import candidate_matches_query, filter_by_scope, normalize_term
36
46
  from .scope_state import ScopeError, normalize_scopes, register_scope_nodes
@@ -463,6 +473,22 @@ class Processor:
463
473
  return reason
464
474
  return None
465
475
 
476
+ @staticmethod
477
+ def _correction_instruction(error: BaseException) -> Optional[str]:
478
+ hint = Processor._safe_correction_hint(error)
479
+ stage = getattr(error, "stage", None)
480
+ if hint == "duplicate_update_target":
481
+ return DUPLICATE_TARGET_CORRECTION
482
+ if hint == "relative_time":
483
+ return RELATIVE_TIME_CORRECTION
484
+ if stage == "summarize" and hint == "invalid_update_target":
485
+ return SUMMARY_TARGET_CORRECTION
486
+ if stage == "summarize" and hint == "invalid_type":
487
+ return SUMMARY_TYPE_CORRECTION
488
+ if hint is not None:
489
+ return f"Previous output violated: {hint}."
490
+ return None
491
+
466
492
  def _diagnostic_enabled(self) -> bool:
467
493
  try:
468
494
  config = self.service.vault.config()
@@ -620,10 +646,10 @@ class Processor:
620
646
  except Exception:
621
647
  pass
622
648
  if self._allows_next_json_attempt(error, attempt_count):
623
- hint = self._safe_correction_hint(error)
624
649
  correction_prompt = prompt + "\n\n" + JSON_CORRECTION
625
- if hint is not None:
626
- correction_prompt += f"\nPrevious output violated: {hint}."
650
+ instruction = self._correction_instruction(error)
651
+ if instruction is not None:
652
+ correction_prompt += f"\n{instruction}"
627
653
  continue
628
654
  raise
629
655
  try:
@@ -1532,6 +1558,13 @@ class Processor:
1532
1558
  if item.get("native") is not True and isinstance(item.get("memory_id"), str)
1533
1559
  ]
1534
1560
  gate_related_memory_ids = list(related_memory_ids)
1561
+ gate_related_memory_types = {
1562
+ item["memory_id"].casefold(): item["type"]
1563
+ for item in gate_related
1564
+ if isinstance(item, Mapping)
1565
+ and isinstance(item.get("memory_id"), str)
1566
+ and isinstance(item.get("type"), str)
1567
+ }
1535
1568
  if scope_fallback is not None:
1536
1569
  scoped_records, scope_ambiguous = scope_fallback
1537
1570
  if scope_ambiguous and self._single_specific_scope(scope_background):
@@ -1543,6 +1576,11 @@ class Processor:
1543
1576
  value.casefold() for value in gate_related_memory_ids
1544
1577
  }:
1545
1578
  gate_related_memory_ids.append(memory_id)
1579
+ if (
1580
+ isinstance(memory_id, str)
1581
+ and isinstance(entry.get("type"), str)
1582
+ ):
1583
+ gate_related_memory_types[memory_id.casefold()] = entry["type"]
1546
1584
  scope_registry = self._scope_registry_projection()
1547
1585
  with self.service.vault.lock():
1548
1586
  validation_scope_registry = self.service.vault.config().get("scopes", {})
@@ -1604,6 +1642,7 @@ class Processor:
1604
1642
  raw,
1605
1643
  current_event_keys=turn.event_keys,
1606
1644
  related_memory_ids=gate_related_memory_ids,
1645
+ related_memory_types=gate_related_memory_types,
1607
1646
  ),
1608
1647
  diagnostic_context={
1609
1648
  "source": turn.source,
@@ -1712,6 +1751,22 @@ class Processor:
1712
1751
  )
1713
1752
  continue
1714
1753
 
1754
+ gate_update_target = candidate.get("update_memory_id")
1755
+ gate_target_type = None
1756
+ if isinstance(gate_update_target, str):
1757
+ gate_target_key = gate_update_target.casefold()
1758
+ gate_target_type = next(
1759
+ (
1760
+ item.get("type")
1761
+ for item in candidate_related
1762
+ if isinstance(item, Mapping)
1763
+ and isinstance(item.get("memory_id"), str)
1764
+ and item["memory_id"].casefold() == gate_target_key
1765
+ and isinstance(item.get("type"), str)
1766
+ ),
1767
+ None,
1768
+ )
1769
+
1715
1770
  summary = self._complete_json_stage(
1716
1771
  backend,
1717
1772
  summarize_prompt(
@@ -1729,6 +1784,20 @@ class Processor:
1729
1784
  related_native_ids=candidate_native_ids,
1730
1785
  related_memory_ids=candidate_memory_ids,
1731
1786
  scope_registry=validation_scope_registry,
1787
+ expected_type=(
1788
+ candidate.get("type")
1789
+ if (
1790
+ gate_update_target is not None
1791
+ and gate_target_type == candidate.get("type")
1792
+ )
1793
+ else None
1794
+ ),
1795
+ expected_update_memory_id=gate_update_target,
1796
+ expected_target_type=(
1797
+ gate_target_type
1798
+ if gate_target_type == candidate.get("type")
1799
+ else None
1800
+ ),
1732
1801
  ),
1733
1802
  diagnostic_context={
1734
1803
  "source": turn.source,
@@ -1736,7 +1805,6 @@ class Processor:
1736
1805
  "turn_index": turn.turn_index,
1737
1806
  },
1738
1807
  )
1739
- gate_update_target = candidate.get("update_memory_id")
1740
1808
  if gate_update_target is not None:
1741
1809
  summary_update_target = summary.get("update_memory_id")
1742
1810
  if summary_update_target is None:
@@ -1750,6 +1818,9 @@ class Processor:
1750
1818
  "summary update target differs from gate target",
1751
1819
  validation_detail="invalid_update_target",
1752
1820
  )
1821
+ else:
1822
+ summary = dict(summary)
1823
+ summary["update_memory_id"] = gate_update_target
1753
1824
  if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
1754
1825
  self._defer_candidate(
1755
1826
  turn_ref,
@@ -34,6 +34,11 @@ useful. Related active memories only identify a complete duplicate or valid
34
34
  state-update target; they are not current evidence and cannot make a non-worthy
35
35
  operational/test result worthy. Their item, body, and serialized-character
36
36
  context is bounded; ellipses and omissions do not prove a fact is absent.
37
+ Within one gate response, each active memory target may appear at most once
38
+ across all candidates, whether it is named by duplicate_memory_id or
39
+ update_memory_id. If multiple facts share one future-use/update target, merge
40
+ them into one candidate and include all supporting evidence IDs; never emit
41
+ separate candidates for the same target.
37
42
  Resolve an indirect reference only to one supplied active memory; otherwise
38
43
  return no candidate or leave it unscoped for retry. Never copy a related
39
44
  diagnostic/tool failure without a separate user-confirmed business fact, project
@@ -130,6 +135,10 @@ evidence; they cannot make a rejected operational or test result worthy. Their
130
135
  item/body/serialized context is bounded; ellipses and omissions do not prove
131
136
  absence. Resolve indirect references only from supplied scope and one related
132
137
  active memory; otherwise return no candidate or leave it unscoped for retry.
138
+ When the gate candidate supplies update_memory_id, that target is immutable:
139
+ omit update_memory_id or copy that exact active ID, never replace it with a
140
+ different, guessed, native, or historical ID. Keep the summary type identical
141
+ to the gate candidate type and to the active update target type.
133
142
  For the same use, preserve still-valid facts, add confirmed progress, replace
134
143
  contradictions, and never reduce the memory to the latest operation or create a
135
144
  sibling with a new label.
@@ -195,6 +204,48 @@ JSON_CORRECTION = (
195
204
  )
196
205
 
197
206
 
207
+ RELATIVE_TIME_CORRECTION = (
208
+ "Previous output violated: relative_time. Re-read the evidence events and use "
209
+ "the timestamp of the event supporting each date as the anchor; recompute "
210
+ "each one-off relative date from that timestamp and the stated weekday "
211
+ "semantics. In the summary title and body, every one-off calendar date must "
212
+ "be written only as YYYY-MM-DD: remove forms such as today/tomorrow/yesterday, "
213
+ "今天/明天/昨天, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
214
+ "If a relative weekday is followed by a parenthesized numeric date and they "
215
+ "conflict, trust the event timestamp plus the weekday meaning, replace the "
216
+ "numeric date with the computed YYYY-MM-DD, and remove the relative wording "
217
+ "and conflicting parenthetical date. Do not guess when no supporting timestamp "
218
+ "exists; omit or defer the date-dependent detail. Recurring schedules such as "
219
+ "每天、每周三, or every Wednesday are allowed. Return only the strict JSON object."
220
+ )
221
+
222
+
223
+ DUPLICATE_TARGET_CORRECTION = (
224
+ "Previous output violated: duplicate_update_target. Merge every candidate "
225
+ "that refers to the same active memory target into one candidate for one "
226
+ "future-use topic. A target may appear only once across duplicate_memory_id "
227
+ "and update_memory_id; preserve all supporting current evidence_event_ids "
228
+ "in that single candidate. Keep update_memory_id only when it is the exact "
229
+ "related active ID, and return no separate sibling candidate for that target. "
230
+ "Return only the strict gate JSON object."
231
+ )
232
+
233
+
234
+ SUMMARY_TARGET_CORRECTION = (
235
+ "Previous output violated: invalid_update_target. The gate selected the "
236
+ "update target and it is immutable. In the summary, omit update_memory_id "
237
+ "or copy the gate-selected active memory_id exactly; never substitute a "
238
+ "different, guessed, native, or historical ID. Return only the strict JSON object."
239
+ )
240
+
241
+
242
+ SUMMARY_TYPE_CORRECTION = (
243
+ "Previous output violated: invalid_type. Keep the summary type exactly equal "
244
+ "to the gate candidate type and to the active update target type. Do not "
245
+ "change the target or invent a new type. Return only the strict JSON object."
246
+ )
247
+
248
+
198
249
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
199
250
  Merge only the supplied low-priority memories when they express compatible
200
251
  information. Return an object with a memories array; [] is a safe no-op.
@@ -51,6 +51,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
51
51
  "unknown_fields",
52
52
  "candidate_shape",
53
53
  "duplicate_candidate_id",
54
+ "duplicate_update_target",
54
55
  "invalid_evidence",
55
56
  "invalid_flags",
56
57
  "invalid_type",
@@ -397,6 +398,7 @@ def validate_gate_output(
397
398
  *,
398
399
  current_event_keys: Iterable[Any] | None = None,
399
400
  related_memory_ids: Iterable[Any] | None = None,
401
+ related_memory_types: Mapping[str, Any] | None = None,
400
402
  ) -> dict[str, Any]:
401
403
  """Validate and return a normalized gate object without writing anything."""
402
404
 
@@ -426,6 +428,7 @@ def validate_gate_output(
426
428
  }
427
429
  normalized: list[dict[str, Any]] = []
428
430
  candidate_ids: set[str] = set()
431
+ target_ids: set[str] = set()
429
432
  for candidate in candidates:
430
433
  if not isinstance(candidate, Mapping):
431
434
  raise ModelOutputError("each gate candidate must be an object", validation_detail="candidate_shape")
@@ -504,7 +507,32 @@ def validate_gate_output(
504
507
  "update_memory_id is not a related active memory",
505
508
  validation_detail="invalid_update_target",
506
509
  )
510
+ target_type = None
511
+ if isinstance(related_memory_types, Mapping):
512
+ target_type = next(
513
+ (
514
+ value
515
+ for key, value in related_memory_types.items()
516
+ if isinstance(key, str) and key.casefold() == update_id.casefold()
517
+ ),
518
+ None,
519
+ )
520
+ if target_type is not None and candidate_type != target_type:
521
+ raise ModelOutputError(
522
+ "candidate type does not match update target",
523
+ validation_detail="invalid_type",
524
+ )
507
525
  item["update_memory_id"] = update_id
526
+ for target_field in ("duplicate_memory_id", "update_memory_id"):
527
+ target = item.get(target_field)
528
+ if isinstance(target, str):
529
+ target_key = target.casefold()
530
+ if target_key in target_ids:
531
+ raise ModelOutputError(
532
+ "multiple gate candidates reference the same memory target; merge them into one candidate",
533
+ validation_detail="duplicate_update_target",
534
+ )
535
+ target_ids.add(target_key)
508
536
  if not isinstance(item["scope_source"], str) or item["scope_source"] not in SCOPE_SOURCES:
509
537
  raise ModelOutputError("invalid scope_source", validation_detail="invalid_scope_source")
510
538
  _scopes(item["scopes"], item["scope_source"])
@@ -523,6 +551,7 @@ def parse_gate_output(
523
551
  *,
524
552
  current_event_keys: Iterable[Any] | None = None,
525
553
  related_memory_ids: Iterable[Any] | None = None,
554
+ related_memory_types: Mapping[str, Any] | None = None,
526
555
  ) -> dict[str, Any]:
527
556
  try:
528
557
  parsed = parse_strict_json(raw)
@@ -536,6 +565,7 @@ def parse_gate_output(
536
565
  event_keys,
537
566
  current_event_keys=current_event_keys,
538
567
  related_memory_ids=related_memory_ids,
568
+ related_memory_types=related_memory_types,
539
569
  )
540
570
  except ModelOutputError as error:
541
571
  if error.validation_reason is None:
@@ -581,6 +611,9 @@ def validate_summarize_output(
581
611
  related_native_ids: Iterable[Any] | None = None,
582
612
  related_memory_ids: Iterable[Any] | None = None,
583
613
  scope_registry: Mapping[str, Any] | None = None,
614
+ expected_type: str | None = None,
615
+ expected_update_memory_id: str | None = None,
616
+ expected_target_type: str | None = None,
584
617
  ) -> dict[str, Any]:
585
618
  """Validate one atomic memory summary; this function has no filesystem effects."""
586
619
 
@@ -615,6 +648,16 @@ def validate_summarize_output(
615
648
  candidate_type = item["type"]
616
649
  if not isinstance(candidate_type, str) or candidate_type not in MEMORY_TYPES:
617
650
  raise ModelOutputError("invalid memory type", validation_detail="invalid_type")
651
+ if expected_type is not None and candidate_type != expected_type:
652
+ raise ModelOutputError(
653
+ "summary type differs from gate candidate",
654
+ validation_detail="invalid_type",
655
+ )
656
+ if expected_target_type is not None and candidate_type != expected_target_type:
657
+ raise ModelOutputError(
658
+ "summary type does not match update target",
659
+ validation_detail="invalid_type",
660
+ )
618
661
  item["scopes"] = _scopes(item["scopes"], item.get("scope_source"))
619
662
  item["scope_operations"] = _scope_operations(
620
663
  item.get("scope_operations"),
@@ -635,6 +678,17 @@ def validate_summarize_output(
635
678
  item["memory_id"] = _memory_id(item["memory_id"], "memory_id")
636
679
  if "update_memory_id" in item:
637
680
  item["update_memory_id"] = _memory_id(item["update_memory_id"], "update_memory_id")
681
+ if (
682
+ expected_update_memory_id is not None
683
+ and (
684
+ not isinstance(expected_update_memory_id, str)
685
+ or item["update_memory_id"].casefold() != expected_update_memory_id.casefold()
686
+ )
687
+ ):
688
+ raise ModelOutputError(
689
+ "summary update target differs from gate target",
690
+ validation_detail="invalid_update_target",
691
+ )
638
692
  if related_memory_ids is not None:
639
693
  related_ids = {
640
694
  memory_id.casefold()
@@ -689,6 +743,9 @@ def parse_summarize_output(
689
743
  related_native_ids: Iterable[Any] | None = None,
690
744
  related_memory_ids: Iterable[Any] | None = None,
691
745
  scope_registry: Mapping[str, Any] | None = None,
746
+ expected_type: str | None = None,
747
+ expected_update_memory_id: str | None = None,
748
+ expected_target_type: str | None = None,
692
749
  ) -> dict[str, Any]:
693
750
  try:
694
751
  parsed = parse_strict_json(raw)
@@ -703,6 +760,9 @@ def parse_summarize_output(
703
760
  related_native_ids=related_native_ids,
704
761
  related_memory_ids=related_memory_ids,
705
762
  scope_registry=scope_registry,
763
+ expected_type=expected_type,
764
+ expected_update_memory_id=expected_update_memory_id,
765
+ expected_target_type=expected_target_type,
706
766
  )
707
767
  except ModelOutputError as error:
708
768
  if error.validation_reason is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.3
3
+ Version: 0.2.5
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.3。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.3 通过 PyPI 分发。
26
+ > **当前版本:0.2.5。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.5 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -1717,6 +1717,61 @@ class HermesProviderTests(unittest.TestCase):
1717
1717
  self.assertIsNotNone(fields)
1718
1718
  self.assertEqual(fields[4], "invalid_update_target")
1719
1719
 
1720
+ def test_duplicate_update_target_validation_detail_is_preserved(self) -> None:
1721
+ value = {
1722
+ "isError": True,
1723
+ "structuredContent": {
1724
+ "error": {
1725
+ "code": "model_invalid_response",
1726
+ "stage": "gate",
1727
+ "validation_reason": "schema_violation",
1728
+ "validation_detail": "duplicate_update_target",
1729
+ "attempt_count": 1,
1730
+ }
1731
+ },
1732
+ }
1733
+ fields = provider_module._mcp_error_fields(value)
1734
+ self.assertIsNotNone(fields)
1735
+ self.assertEqual(fields[4], "duplicate_update_target")
1736
+
1737
+ def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
1738
+ value = {
1739
+ "isError": True,
1740
+ "structuredContent": {
1741
+ "error": {
1742
+ "code": "model_invalid_response",
1743
+ "stage": "summarize",
1744
+ "validation_reason": "schema_violation",
1745
+ "validation_detail": "relative_time",
1746
+ "attempt_count": 3,
1747
+ }
1748
+ },
1749
+ }
1750
+ fields = provider_module._mcp_error_fields(value)
1751
+ self.assertIsNotNone(fields)
1752
+ self.assertEqual(fields[4], "relative_time")
1753
+ error = provider_module._MCPToolError(
1754
+ code="model_invalid_response",
1755
+ stage="summarize",
1756
+ validation_reason="schema_violation",
1757
+ validation_detail="relative_time",
1758
+ attempt_count=3,
1759
+ )
1760
+ self.assertEqual(error.validation_detail, "relative_time")
1761
+
1762
+ provider = self.provider(
1763
+ responses=[
1764
+ {"stored": True},
1765
+ {"stored": True},
1766
+ value,
1767
+ ]
1768
+ )
1769
+ with patch.object(provider_module.logger, "info") as info:
1770
+ provider.sync_turn("visible user", "visible assistant", session_id="relative-time-session")
1771
+ output = "\n".join(call.args[0] % call.args[1:] for call in info.call_args_list)
1772
+ self.assertIn("validation_detail=relative_time", output)
1773
+ self.assertIn("attempt_count=3", output)
1774
+
1720
1775
  def test_capture_failure_skips_process_and_does_not_escape_provider(self) -> None:
1721
1776
  provider = self.provider(responses=[{"stored": True}, RuntimeError("capture unavailable")])
1722
1777
  with patch.object(provider_module.logger, "warning") as warning: