memleaf 0.2.4__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.4 → memleaf-0.2.5}/CHANGELOG.md +11 -0
  2. {memleaf-0.2.4/src/memleaf.egg-info → memleaf-0.2.5}/PKG-INFO +3 -3
  3. {memleaf-0.2.4 → memleaf-0.2.5}/README.md +2 -2
  4. {memleaf-0.2.4 → memleaf-0.2.5}/pyproject.toml +1 -1
  5. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/hermes_provider/__init__.py +1 -0
  7. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/memory_writer.py +41 -13
  9. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/processing.py +56 -1
  10. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/prompts.py +35 -0
  11. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/validation.py +60 -0
  12. {memleaf-0.2.4 → memleaf-0.2.5/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_hermes_provider.py +17 -0
  14. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_pypi_install.py +1 -1
  15. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b1.py +94 -0
  16. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b2a.py +260 -1
  17. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_c1_mcp.py +1 -1
  18. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_c3_packaging.py +1 -1
  19. {memleaf-0.2.4 → memleaf-0.2.5}/IMPLEMENTATION_PLAN.md +0 -0
  20. {memleaf-0.2.4 → memleaf-0.2.5}/LICENSE +0 -0
  21. {memleaf-0.2.4 → memleaf-0.2.5}/MANIFEST.in +0 -0
  22. {memleaf-0.2.4 → memleaf-0.2.5}/README.en.md +0 -0
  23. {memleaf-0.2.4 → memleaf-0.2.5}/RELEASE_CHECKLIST.md +0 -0
  24. {memleaf-0.2.4 → memleaf-0.2.5}/examples/README.md +0 -0
  25. {memleaf-0.2.4 → memleaf-0.2.5}/examples/basic_usage.py +0 -0
  26. {memleaf-0.2.4 → memleaf-0.2.5}/examples/mcp_stdio.ndjson +0 -0
  27. {memleaf-0.2.4 → memleaf-0.2.5}/install.ps1 +0 -0
  28. {memleaf-0.2.4 → memleaf-0.2.5}/install.sh +0 -0
  29. {memleaf-0.2.4 → memleaf-0.2.5}/setup.cfg +0 -0
  30. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/__main__.py +0 -0
  31. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/__init__.py +0 -0
  32. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/antigravity.py +0 -0
  33. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/base.py +0 -0
  34. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/codex.py +0 -0
  35. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/adapters/hermes.py +0 -0
  36. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/budget.py +0 -0
  37. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/capture.py +0 -0
  38. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/cli.py +0 -0
  39. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/compaction.py +0 -0
  40. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/config.py +0 -0
  41. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/credentials.py +0 -0
  42. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/frontmatter.py +0 -0
  43. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/hermes_provider/README.md +0 -0
  44. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/model_discovery.py +0 -0
  58. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/models.py +0 -0
  59. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/native_index.py +0 -0
  60. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/SOURCES.txt +0 -0
  68. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.4 → memleaf-0.2.5}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.4 → memleaf-0.2.5}/tests/__init__.py +0 -0
  72. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_install.py +0 -0
  82. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.4 → memleaf-0.2.5}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,17 @@
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
+
5
16
  ## 0.2.4 — 2026-09-01
6
17
 
7
18
  - Give `relative_time` retries explicit, actionable correction instructions so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.4
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.4。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 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
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **当前版本:0.2.4。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 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.4"
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.4"
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",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.4
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"]
@@ -31,9 +31,12 @@ from .memory_writer import MemoryWriter
31
31
  from .models import Memory, utc_now
32
32
  from .native_index import NativeIndexer
33
33
  from .prompts import (
34
+ DUPLICATE_TARGET_CORRECTION,
34
35
  JSON_CORRECTION,
35
36
  RELATIVE_TIME_CORRECTION,
36
37
  GATE_SYSTEM,
38
+ SUMMARY_TARGET_CORRECTION,
39
+ SUMMARY_TYPE_CORRECTION,
37
40
  SUMMARIZE_SYSTEM,
38
41
  gate_prompt,
39
42
  summarize_prompt,
@@ -473,8 +476,15 @@ class Processor:
473
476
  @staticmethod
474
477
  def _correction_instruction(error: BaseException) -> Optional[str]:
475
478
  hint = Processor._safe_correction_hint(error)
479
+ stage = getattr(error, "stage", None)
480
+ if hint == "duplicate_update_target":
481
+ return DUPLICATE_TARGET_CORRECTION
476
482
  if hint == "relative_time":
477
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
478
488
  if hint is not None:
479
489
  return f"Previous output violated: {hint}."
480
490
  return None
@@ -1548,6 +1558,13 @@ class Processor:
1548
1558
  if item.get("native") is not True and isinstance(item.get("memory_id"), str)
1549
1559
  ]
1550
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
+ }
1551
1568
  if scope_fallback is not None:
1552
1569
  scoped_records, scope_ambiguous = scope_fallback
1553
1570
  if scope_ambiguous and self._single_specific_scope(scope_background):
@@ -1559,6 +1576,11 @@ class Processor:
1559
1576
  value.casefold() for value in gate_related_memory_ids
1560
1577
  }:
1561
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"]
1562
1584
  scope_registry = self._scope_registry_projection()
1563
1585
  with self.service.vault.lock():
1564
1586
  validation_scope_registry = self.service.vault.config().get("scopes", {})
@@ -1620,6 +1642,7 @@ class Processor:
1620
1642
  raw,
1621
1643
  current_event_keys=turn.event_keys,
1622
1644
  related_memory_ids=gate_related_memory_ids,
1645
+ related_memory_types=gate_related_memory_types,
1623
1646
  ),
1624
1647
  diagnostic_context={
1625
1648
  "source": turn.source,
@@ -1728,6 +1751,22 @@ class Processor:
1728
1751
  )
1729
1752
  continue
1730
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
+
1731
1770
  summary = self._complete_json_stage(
1732
1771
  backend,
1733
1772
  summarize_prompt(
@@ -1745,6 +1784,20 @@ class Processor:
1745
1784
  related_native_ids=candidate_native_ids,
1746
1785
  related_memory_ids=candidate_memory_ids,
1747
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
+ ),
1748
1801
  ),
1749
1802
  diagnostic_context={
1750
1803
  "source": turn.source,
@@ -1752,7 +1805,6 @@ class Processor:
1752
1805
  "turn_index": turn.turn_index,
1753
1806
  },
1754
1807
  )
1755
- gate_update_target = candidate.get("update_memory_id")
1756
1808
  if gate_update_target is not None:
1757
1809
  summary_update_target = summary.get("update_memory_id")
1758
1810
  if summary_update_target is None:
@@ -1766,6 +1818,9 @@ class Processor:
1766
1818
  "summary update target differs from gate target",
1767
1819
  validation_detail="invalid_update_target",
1768
1820
  )
1821
+ else:
1822
+ summary = dict(summary)
1823
+ summary["update_memory_id"] = gate_update_target
1769
1824
  if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
1770
1825
  self._defer_candidate(
1771
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.
@@ -211,6 +220,32 @@ RELATIVE_TIME_CORRECTION = (
211
220
  )
212
221
 
213
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
+
214
249
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
215
250
  Merge only the supplied low-priority memories when they express compatible
216
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.4
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.4。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 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,23 @@ 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
+
1720
1737
  def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
1721
1738
  value = {
1722
1739
  "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.4", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.5", (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: