memleaf 0.2.4__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.4 → memleaf-0.2.6}/CHANGELOG.md +24 -0
  2. {memleaf-0.2.4/src/memleaf.egg-info → memleaf-0.2.6}/PKG-INFO +3 -3
  3. {memleaf-0.2.4 → memleaf-0.2.6}/README.md +2 -2
  4. {memleaf-0.2.4 → memleaf-0.2.6}/pyproject.toml +1 -1
  5. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/hermes_provider/__init__.py +2 -0
  7. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/memory_writer.py +41 -13
  9. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/processing.py +59 -1
  10. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/prompts.py +73 -4
  11. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/validation.py +85 -2
  12. {memleaf-0.2.4 → memleaf-0.2.6/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_hermes_provider.py +34 -0
  14. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_pypi_install.py +1 -1
  15. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b1.py +158 -0
  16. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b2a.py +607 -1
  17. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_c1_mcp.py +1 -1
  18. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_c3_packaging.py +1 -1
  19. {memleaf-0.2.4 → memleaf-0.2.6}/IMPLEMENTATION_PLAN.md +0 -0
  20. {memleaf-0.2.4 → memleaf-0.2.6}/LICENSE +0 -0
  21. {memleaf-0.2.4 → memleaf-0.2.6}/MANIFEST.in +0 -0
  22. {memleaf-0.2.4 → memleaf-0.2.6}/README.en.md +0 -0
  23. {memleaf-0.2.4 → memleaf-0.2.6}/RELEASE_CHECKLIST.md +0 -0
  24. {memleaf-0.2.4 → memleaf-0.2.6}/examples/README.md +0 -0
  25. {memleaf-0.2.4 → memleaf-0.2.6}/examples/basic_usage.py +0 -0
  26. {memleaf-0.2.4 → memleaf-0.2.6}/examples/mcp_stdio.ndjson +0 -0
  27. {memleaf-0.2.4 → memleaf-0.2.6}/install.ps1 +0 -0
  28. {memleaf-0.2.4 → memleaf-0.2.6}/install.sh +0 -0
  29. {memleaf-0.2.4 → memleaf-0.2.6}/setup.cfg +0 -0
  30. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/__main__.py +0 -0
  31. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/adapters/__init__.py +0 -0
  32. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/adapters/antigravity.py +0 -0
  33. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/adapters/base.py +0 -0
  34. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/adapters/codex.py +0 -0
  35. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/adapters/hermes.py +0 -0
  36. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/budget.py +0 -0
  37. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/capture.py +0 -0
  38. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/cli.py +0 -0
  39. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/compaction.py +0 -0
  40. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/config.py +0 -0
  41. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/credentials.py +0 -0
  42. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/frontmatter.py +0 -0
  43. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/hermes_provider/README.md +0 -0
  44. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/model_discovery.py +0 -0
  58. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/models.py +0 -0
  59. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/native_index.py +0 -0
  60. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf.egg-info/SOURCES.txt +0 -0
  68. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.4 → memleaf-0.2.6}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.4 → memleaf-0.2.6}/tests/__init__.py +0 -0
  72. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_install.py +0 -0
  82. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.4 → memleaf-0.2.6}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,30 @@
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
+
18
+ ## 0.2.5 — 2026-09-01
19
+
20
+ - Detect multiple gate candidates that target the same active memory before
21
+ batch writing, then retry with an explicit instruction to merge the
22
+ candidates while preserving all supporting evidence.
23
+ - Keep gate-selected update targets and active-memory types stable through
24
+ summarization, with bounded corrective retries for target or type drift.
25
+ - Preserve `duplicate_update_target` and aggregate preflight diagnostics across
26
+ Core, MCP, and Hermes while retaining the inbox and zero-write transaction
27
+ boundary after repeated failure.
28
+
5
29
  ## 0.2.4 — 2026-09-01
6
30
 
7
31
  - 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.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.4。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 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.4。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.4 通过 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.4"
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.4"
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
@@ -75,6 +75,8 @@ _MODEL_VALIDATION_DETAILS = frozenset(
75
75
  "unknown_fields",
76
76
  "candidate_shape",
77
77
  "duplicate_candidate_id",
78
+ "duplicate_update_target",
79
+ "mixed_project_scopes",
78
80
  "invalid_evidence",
79
81
  "invalid_flags",
80
82
  "invalid_type",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.4
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
@@ -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,13 @@ 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,
36
+ MIXED_PROJECT_SCOPES_CORRECTION,
35
37
  RELATIVE_TIME_CORRECTION,
36
38
  GATE_SYSTEM,
39
+ SUMMARY_TARGET_CORRECTION,
40
+ SUMMARY_TYPE_CORRECTION,
37
41
  SUMMARIZE_SYSTEM,
38
42
  gate_prompt,
39
43
  summarize_prompt,
@@ -473,8 +477,17 @@ class Processor:
473
477
  @staticmethod
474
478
  def _correction_instruction(error: BaseException) -> Optional[str]:
475
479
  hint = Processor._safe_correction_hint(error)
480
+ stage = getattr(error, "stage", None)
481
+ if hint == "duplicate_update_target":
482
+ return DUPLICATE_TARGET_CORRECTION
483
+ if hint == "mixed_project_scopes":
484
+ return MIXED_PROJECT_SCOPES_CORRECTION
476
485
  if hint == "relative_time":
477
486
  return RELATIVE_TIME_CORRECTION
487
+ if stage == "summarize" and hint == "invalid_update_target":
488
+ return SUMMARY_TARGET_CORRECTION
489
+ if stage == "summarize" and hint == "invalid_type":
490
+ return SUMMARY_TYPE_CORRECTION
478
491
  if hint is not None:
479
492
  return f"Previous output violated: {hint}."
480
493
  return None
@@ -1548,6 +1561,13 @@ class Processor:
1548
1561
  if item.get("native") is not True and isinstance(item.get("memory_id"), str)
1549
1562
  ]
1550
1563
  gate_related_memory_ids = list(related_memory_ids)
1564
+ gate_related_memory_types = {
1565
+ item["memory_id"].casefold(): item["type"]
1566
+ for item in gate_related
1567
+ if isinstance(item, Mapping)
1568
+ and isinstance(item.get("memory_id"), str)
1569
+ and isinstance(item.get("type"), str)
1570
+ }
1551
1571
  if scope_fallback is not None:
1552
1572
  scoped_records, scope_ambiguous = scope_fallback
1553
1573
  if scope_ambiguous and self._single_specific_scope(scope_background):
@@ -1559,6 +1579,11 @@ class Processor:
1559
1579
  value.casefold() for value in gate_related_memory_ids
1560
1580
  }:
1561
1581
  gate_related_memory_ids.append(memory_id)
1582
+ if (
1583
+ isinstance(memory_id, str)
1584
+ and isinstance(entry.get("type"), str)
1585
+ ):
1586
+ gate_related_memory_types[memory_id.casefold()] = entry["type"]
1562
1587
  scope_registry = self._scope_registry_projection()
1563
1588
  with self.service.vault.lock():
1564
1589
  validation_scope_registry = self.service.vault.config().get("scopes", {})
@@ -1620,6 +1645,7 @@ class Processor:
1620
1645
  raw,
1621
1646
  current_event_keys=turn.event_keys,
1622
1647
  related_memory_ids=gate_related_memory_ids,
1648
+ related_memory_types=gate_related_memory_types,
1623
1649
  ),
1624
1650
  diagnostic_context={
1625
1651
  "source": turn.source,
@@ -1728,6 +1754,22 @@ class Processor:
1728
1754
  )
1729
1755
  continue
1730
1756
 
1757
+ gate_update_target = candidate.get("update_memory_id")
1758
+ gate_target_type = None
1759
+ if isinstance(gate_update_target, str):
1760
+ gate_target_key = gate_update_target.casefold()
1761
+ gate_target_type = next(
1762
+ (
1763
+ item.get("type")
1764
+ for item in candidate_related
1765
+ if isinstance(item, Mapping)
1766
+ and isinstance(item.get("memory_id"), str)
1767
+ and item["memory_id"].casefold() == gate_target_key
1768
+ and isinstance(item.get("type"), str)
1769
+ ),
1770
+ None,
1771
+ )
1772
+
1731
1773
  summary = self._complete_json_stage(
1732
1774
  backend,
1733
1775
  summarize_prompt(
@@ -1745,6 +1787,20 @@ class Processor:
1745
1787
  related_native_ids=candidate_native_ids,
1746
1788
  related_memory_ids=candidate_memory_ids,
1747
1789
  scope_registry=validation_scope_registry,
1790
+ expected_type=(
1791
+ candidate.get("type")
1792
+ if (
1793
+ gate_update_target is not None
1794
+ and gate_target_type == candidate.get("type")
1795
+ )
1796
+ else None
1797
+ ),
1798
+ expected_update_memory_id=gate_update_target,
1799
+ expected_target_type=(
1800
+ gate_target_type
1801
+ if gate_target_type == candidate.get("type")
1802
+ else None
1803
+ ),
1748
1804
  ),
1749
1805
  diagnostic_context={
1750
1806
  "source": turn.source,
@@ -1752,7 +1808,6 @@ class Processor:
1752
1808
  "turn_index": turn.turn_index,
1753
1809
  },
1754
1810
  )
1755
- gate_update_target = candidate.get("update_memory_id")
1756
1811
  if gate_update_target is not None:
1757
1812
  summary_update_target = summary.get("update_memory_id")
1758
1813
  if summary_update_target is None:
@@ -1766,6 +1821,9 @@ class Processor:
1766
1821
  "summary update target differs from gate target",
1767
1822
  validation_detail="invalid_update_target",
1768
1823
  )
1824
+ else:
1825
+ summary = dict(summary)
1826
+ summary["update_memory_id"] = gate_update_target
1769
1827
  if summary["scopes"] == ["unscoped"] or summary.get("scope_source") == "insufficient_context":
1770
1828
  self._defer_candidate(
1771
1829
  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
@@ -42,7 +47,7 @@ Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
42
47
  For a one-off relative date in a candidate or its later summary, use the
43
48
  timestamp of the evidence event that supports it as the calendar anchor and
44
49
  emit an absolute YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
45
- 今天/明天/昨天, 本周X/这周X/下周X/上周X, and this/next/last weekday;
50
+ 今天/明天/昨天/今日/明日/昨日, 本周X/这周X/下周X/上周X, and this/next/last weekday;
46
51
  use Monday-Sunday calendar weeks for week expressions. Do not leave these
47
52
  strong relative forms in a worthy memory, and never guess without a timestamp:
48
53
  defer or omit a date-dependent candidate if its supporting event has no
@@ -87,6 +92,13 @@ be independently retrieved and updated. Prefer one coherent current-state memory
87
92
  over adjacent overlapping snapshots; do not use a score or fixed category rule.
88
93
  Most ordinary turns should produce zero or one candidate; multiple require
89
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.
90
102
  An intermediate state such as a draft awaiting confirmation, preparation/
91
103
  processing status, temporary error, repeated confirmation, or unconfirmed
92
104
  suggestion normally has no independent future use and is worth=false. Keep one
@@ -108,6 +120,11 @@ operational incident spans stats/search/remember calls; this default remains.
108
120
  Retain only a separate, user-confirmed future-use fact, preference, identity, constraint, commitment,
109
121
  project risk, or durable lesson that prevents repeating the investigation, not
110
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.
111
128
  Return no prose, markdown fences, comments, or trailing text."""
112
129
 
113
130
 
@@ -130,14 +147,22 @@ evidence; they cannot make a rejected operational or test result worthy. Their
130
147
  item/body/serialized context is bounded; ellipses and omissions do not prove
131
148
  absence. Resolve indirect references only from supplied scope and one related
132
149
  active memory; otherwise return no candidate or leave it unscoped for retry.
150
+ When the gate candidate supplies update_memory_id, that target is immutable:
151
+ omit update_memory_id or copy that exact active ID, never replace it with a
152
+ different, guessed, native, or historical ID. Keep the summary type identical
153
+ to the gate candidate type and to the active update target type.
133
154
  For the same use, preserve still-valid facts, add confirmed progress, replace
134
155
  contradictions, and never reduce the memory to the latest operation or create a
135
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.
136
161
  Calendar dates are strict. Each event may include an ISO-8601 UTC timestamp.
137
162
  For a one-off relative date in the summary title or body, use the timestamp of
138
163
  the supporting evidence event as the calendar anchor and emit an absolute
139
164
  YYYY-MM-DD date. Resolve today/tomorrow/yesterday,
140
- 今天/明天/昨天, 本周X/这周X/下周X/上周X, and this/next/last weekday;
165
+ 今天/明天/昨天/今日/明日/昨日, 本周X/这周X/下周X/上周X, and this/next/last weekday;
141
166
  use Monday-Sunday calendar weeks for week expressions. Never leave these
142
167
  strong relative forms in the summary, and never guess without a timestamp.
143
168
  Recurring schedules such as 每天、每周三, or every Wednesday are not one-off
@@ -146,7 +171,12 @@ In automatic capture/process mode, a pure read-only query whose answer only
146
171
  restates a related active memory is not a new candidate. It must have been
147
172
  rejected by the gate; do not use duplicate_memory_id or update_memory_id to
148
173
  append the query as a source. Only a newly confirmed fact or state change in
149
- 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.
150
180
  fields and JSON types are title (string), body (string), tags (string list),
151
181
  type (one of preference, fact, project, todo, event, identity, other), scopes
152
182
  (non-empty string list), and sources (non-empty object list). scope_source, if
@@ -201,7 +231,7 @@ RELATIVE_TIME_CORRECTION = (
201
231
  "each one-off relative date from that timestamp and the stated weekday "
202
232
  "semantics. In the summary title and body, every one-off calendar date must "
203
233
  "be written only as YYYY-MM-DD: remove forms such as today/tomorrow/yesterday, "
204
- "今天/明天/昨天, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
234
+ "今天/明天/昨天/今日/明日/昨日, 本周三/这周三/下周三/上周三, and this/next/last Wednesday. "
205
235
  "If a relative weekday is followed by a parenthesized numeric date and they "
206
236
  "conflict, trust the event timestamp plus the weekday meaning, replace the "
207
237
  "numeric date with the computed YYYY-MM-DD, and remove the relative wording "
@@ -211,6 +241,45 @@ RELATIVE_TIME_CORRECTION = (
211
241
  )
212
242
 
213
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
+
257
+ DUPLICATE_TARGET_CORRECTION = (
258
+ "Previous output violated: duplicate_update_target. Merge every candidate "
259
+ "that refers to the same active memory target into one candidate for one "
260
+ "future-use topic. A target may appear only once across duplicate_memory_id "
261
+ "and update_memory_id; preserve all supporting current evidence_event_ids "
262
+ "in that single candidate. Keep update_memory_id only when it is the exact "
263
+ "related active ID, and return no separate sibling candidate for that target. "
264
+ "Return only the strict gate JSON object."
265
+ )
266
+
267
+
268
+ SUMMARY_TARGET_CORRECTION = (
269
+ "Previous output violated: invalid_update_target. The gate selected the "
270
+ "update target and it is immutable. In the summary, omit update_memory_id "
271
+ "or copy the gate-selected active memory_id exactly; never substitute a "
272
+ "different, guessed, native, or historical ID. Return only the strict JSON object."
273
+ )
274
+
275
+
276
+ SUMMARY_TYPE_CORRECTION = (
277
+ "Previous output violated: invalid_type. Keep the summary type exactly equal "
278
+ "to the gate candidate type and to the active update target type. Do not "
279
+ "change the target or invent a new type. Return only the strict JSON object."
280
+ )
281
+
282
+
214
283
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
215
284
  Merge only the supplied low-priority memories when they express compatible
216
285
  information. Return an object with a memories array; [] is a safe no-op.
@@ -51,6 +51,8 @@ MODEL_VALIDATION_DETAILS = frozenset(
51
51
  "unknown_fields",
52
52
  "candidate_shape",
53
53
  "duplicate_candidate_id",
54
+ "duplicate_update_target",
55
+ "mixed_project_scopes",
54
56
  "invalid_evidence",
55
57
  "invalid_flags",
56
58
  "invalid_type",
@@ -71,7 +73,7 @@ _RELATIVE_CALENDAR_EXPRESSION = re.compile(
71
73
  r"(?<![A-Za-z])(?:today|tomorrow|yesterday)(?![A-Za-z])"
72
74
  r"|(?<![A-Za-z])(?:this|next|last)\s+(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?![A-Za-z])"
73
75
  r"|(?:本|这|下|上)(?:个)?(?:周|星期|礼拜)\s*(?:一|二|三|四|五|六|日|天|末|[1-7])"
74
- r"|(?:今天|明天|昨天)"
76
+ r"|(?:今天|明天|昨天|今日|明日|昨日)"
75
77
  r")",
76
78
  re.IGNORECASE,
77
79
  )
@@ -227,6 +229,25 @@ def _scopes(value: Any, scope_source: Any) -> list[str]:
227
229
  return result
228
230
 
229
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
+
230
251
  def _memory_id(value: Any, field: str) -> str:
231
252
  result = _string(value, field)
232
253
  if "/" in result or "\\" in result or result in (".", ".."):
@@ -397,6 +418,7 @@ def validate_gate_output(
397
418
  *,
398
419
  current_event_keys: Iterable[Any] | None = None,
399
420
  related_memory_ids: Iterable[Any] | None = None,
421
+ related_memory_types: Mapping[str, Any] | None = None,
400
422
  ) -> dict[str, Any]:
401
423
  """Validate and return a normalized gate object without writing anything."""
402
424
 
@@ -426,6 +448,7 @@ def validate_gate_output(
426
448
  }
427
449
  normalized: list[dict[str, Any]] = []
428
450
  candidate_ids: set[str] = set()
451
+ target_ids: set[str] = set()
429
452
  for candidate in candidates:
430
453
  if not isinstance(candidate, Mapping):
431
454
  raise ModelOutputError("each gate candidate must be an object", validation_detail="candidate_shape")
@@ -504,10 +527,37 @@ def validate_gate_output(
504
527
  "update_memory_id is not a related active memory",
505
528
  validation_detail="invalid_update_target",
506
529
  )
530
+ target_type = None
531
+ if isinstance(related_memory_types, Mapping):
532
+ target_type = next(
533
+ (
534
+ value
535
+ for key, value in related_memory_types.items()
536
+ if isinstance(key, str) and key.casefold() == update_id.casefold()
537
+ ),
538
+ None,
539
+ )
540
+ if target_type is not None and candidate_type != target_type:
541
+ raise ModelOutputError(
542
+ "candidate type does not match update target",
543
+ validation_detail="invalid_type",
544
+ )
507
545
  item["update_memory_id"] = update_id
546
+ for target_field in ("duplicate_memory_id", "update_memory_id"):
547
+ target = item.get(target_field)
548
+ if isinstance(target, str):
549
+ target_key = target.casefold()
550
+ if target_key in target_ids:
551
+ raise ModelOutputError(
552
+ "multiple gate candidates reference the same memory target; merge them into one candidate",
553
+ validation_detail="duplicate_update_target",
554
+ )
555
+ target_ids.add(target_key)
508
556
  if not isinstance(item["scope_source"], str) or item["scope_source"] not in SCOPE_SOURCES:
509
557
  raise ModelOutputError("invalid scope_source", validation_detail="invalid_scope_source")
510
- _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"])
511
561
  if "reason" in item:
512
562
  _string(item["reason"], "reason", nonempty=False)
513
563
  if len(item["reason"]) > 30:
@@ -523,6 +573,7 @@ def parse_gate_output(
523
573
  *,
524
574
  current_event_keys: Iterable[Any] | None = None,
525
575
  related_memory_ids: Iterable[Any] | None = None,
576
+ related_memory_types: Mapping[str, Any] | None = None,
526
577
  ) -> dict[str, Any]:
527
578
  try:
528
579
  parsed = parse_strict_json(raw)
@@ -536,6 +587,7 @@ def parse_gate_output(
536
587
  event_keys,
537
588
  current_event_keys=current_event_keys,
538
589
  related_memory_ids=related_memory_ids,
590
+ related_memory_types=related_memory_types,
539
591
  )
540
592
  except ModelOutputError as error:
541
593
  if error.validation_reason is None:
@@ -581,6 +633,9 @@ def validate_summarize_output(
581
633
  related_native_ids: Iterable[Any] | None = None,
582
634
  related_memory_ids: Iterable[Any] | None = None,
583
635
  scope_registry: Mapping[str, Any] | None = None,
636
+ expected_type: str | None = None,
637
+ expected_update_memory_id: str | None = None,
638
+ expected_target_type: str | None = None,
584
639
  ) -> dict[str, Any]:
585
640
  """Validate one atomic memory summary; this function has no filesystem effects."""
586
641
 
@@ -615,7 +670,18 @@ def validate_summarize_output(
615
670
  candidate_type = item["type"]
616
671
  if not isinstance(candidate_type, str) or candidate_type not in MEMORY_TYPES:
617
672
  raise ModelOutputError("invalid memory type", validation_detail="invalid_type")
673
+ if expected_type is not None and candidate_type != expected_type:
674
+ raise ModelOutputError(
675
+ "summary type differs from gate candidate",
676
+ validation_detail="invalid_type",
677
+ )
678
+ if expected_target_type is not None and candidate_type != expected_target_type:
679
+ raise ModelOutputError(
680
+ "summary type does not match update target",
681
+ validation_detail="invalid_type",
682
+ )
618
683
  item["scopes"] = _scopes(item["scopes"], item.get("scope_source"))
684
+ _reject_mixed_project_scopes(item["scopes"])
619
685
  item["scope_operations"] = _scope_operations(
620
686
  item.get("scope_operations"),
621
687
  summary_scopes=item["scopes"],
@@ -635,6 +701,17 @@ def validate_summarize_output(
635
701
  item["memory_id"] = _memory_id(item["memory_id"], "memory_id")
636
702
  if "update_memory_id" in item:
637
703
  item["update_memory_id"] = _memory_id(item["update_memory_id"], "update_memory_id")
704
+ if (
705
+ expected_update_memory_id is not None
706
+ and (
707
+ not isinstance(expected_update_memory_id, str)
708
+ or item["update_memory_id"].casefold() != expected_update_memory_id.casefold()
709
+ )
710
+ ):
711
+ raise ModelOutputError(
712
+ "summary update target differs from gate target",
713
+ validation_detail="invalid_update_target",
714
+ )
638
715
  if related_memory_ids is not None:
639
716
  related_ids = {
640
717
  memory_id.casefold()
@@ -689,6 +766,9 @@ def parse_summarize_output(
689
766
  related_native_ids: Iterable[Any] | None = None,
690
767
  related_memory_ids: Iterable[Any] | None = None,
691
768
  scope_registry: Mapping[str, Any] | None = None,
769
+ expected_type: str | None = None,
770
+ expected_update_memory_id: str | None = None,
771
+ expected_target_type: str | None = None,
692
772
  ) -> dict[str, Any]:
693
773
  try:
694
774
  parsed = parse_strict_json(raw)
@@ -703,6 +783,9 @@ def parse_summarize_output(
703
783
  related_native_ids=related_native_ids,
704
784
  related_memory_ids=related_memory_ids,
705
785
  scope_registry=scope_registry,
786
+ expected_type=expected_type,
787
+ expected_update_memory_id=expected_update_memory_id,
788
+ expected_target_type=expected_target_type,
706
789
  )
707
790
  except ModelOutputError as error:
708
791
  if error.validation_reason is None: