memleaf 0.2.6__tar.gz → 0.2.7__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.6 → memleaf-0.2.7}/CHANGELOG.md +12 -0
  2. {memleaf-0.2.6/src/memleaf.egg-info → memleaf-0.2.7}/PKG-INFO +3 -3
  3. {memleaf-0.2.6 → memleaf-0.2.7}/README.md +2 -2
  4. {memleaf-0.2.6 → memleaf-0.2.7}/pyproject.toml +1 -1
  5. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/hermes_provider/__init__.py +1 -0
  7. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/processing.py +6 -0
  9. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/prompts.py +26 -0
  10. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/validation.py +2 -1
  11. {memleaf-0.2.6 → memleaf-0.2.7/src/memleaf.egg-info}/PKG-INFO +3 -3
  12. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_hermes_provider.py +17 -0
  13. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_pypi_install.py +1 -1
  14. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b1.py +54 -1
  15. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b2a.py +93 -1
  16. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_c1_mcp.py +1 -1
  17. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_c3_packaging.py +1 -1
  18. {memleaf-0.2.6 → memleaf-0.2.7}/IMPLEMENTATION_PLAN.md +0 -0
  19. {memleaf-0.2.6 → memleaf-0.2.7}/LICENSE +0 -0
  20. {memleaf-0.2.6 → memleaf-0.2.7}/MANIFEST.in +0 -0
  21. {memleaf-0.2.6 → memleaf-0.2.7}/README.en.md +0 -0
  22. {memleaf-0.2.6 → memleaf-0.2.7}/RELEASE_CHECKLIST.md +0 -0
  23. {memleaf-0.2.6 → memleaf-0.2.7}/examples/README.md +0 -0
  24. {memleaf-0.2.6 → memleaf-0.2.7}/examples/basic_usage.py +0 -0
  25. {memleaf-0.2.6 → memleaf-0.2.7}/examples/mcp_stdio.ndjson +0 -0
  26. {memleaf-0.2.6 → memleaf-0.2.7}/install.ps1 +0 -0
  27. {memleaf-0.2.6 → memleaf-0.2.7}/install.sh +0 -0
  28. {memleaf-0.2.6 → memleaf-0.2.7}/setup.cfg +0 -0
  29. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/__main__.py +0 -0
  30. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/adapters/__init__.py +0 -0
  31. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/adapters/antigravity.py +0 -0
  32. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/adapters/base.py +0 -0
  33. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/adapters/codex.py +0 -0
  34. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/adapters/hermes.py +0 -0
  35. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/budget.py +0 -0
  36. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/capture.py +0 -0
  37. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/cli.py +0 -0
  38. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/compaction.py +0 -0
  39. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/config.py +0 -0
  40. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/credentials.py +0 -0
  41. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/frontmatter.py +0 -0
  42. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/hermes_provider/README.md +0 -0
  43. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/host_events.py +0 -0
  44. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/host_runtime.py +0 -0
  45. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/inbox.py +0 -0
  46. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/index.py +0 -0
  47. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/installer.py +0 -0
  48. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/llm/__init__.py +0 -0
  49. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/llm/base.py +0 -0
  50. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/llm/claude_compatible.py +0 -0
  51. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/llm/gemini.py +0 -0
  52. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/llm/openai_compatible.py +0 -0
  53. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/llm/router.py +0 -0
  54. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/locking.py +0 -0
  55. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/mcp_server.py +0 -0
  56. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/memory_writer.py +0 -0
  57. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/model_discovery.py +0 -0
  58. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/models.py +0 -0
  59. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/native_index.py +0 -0
  60. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/redaction.py +0 -0
  61. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/retrieval.py +0 -0
  62. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/retrieval_gate.py +0 -0
  63. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/scope_maintenance.py +0 -0
  64. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/scope_state.py +0 -0
  65. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/service.py +0 -0
  66. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf/vault.py +0 -0
  67. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf.egg-info/SOURCES.txt +0 -0
  68. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf.egg-info/dependency_links.txt +0 -0
  69. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf.egg-info/entry_points.txt +0 -0
  70. {memleaf-0.2.6 → memleaf-0.2.7}/src/memleaf.egg-info/top_level.txt +0 -0
  71. {memleaf-0.2.6 → memleaf-0.2.7}/tests/__init__.py +0 -0
  72. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_admission_noise.py +0 -0
  73. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_codex_install.py +0 -0
  74. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_codex_native_cli.py +0 -0
  75. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_context_budget.py +0 -0
  76. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_credential_safety.py +0 -0
  77. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_cross_host_acceptance.py +0 -0
  78. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_hermes_stdio_transport.py +0 -0
  79. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_host_events.py +0 -0
  80. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_host_runtime_contract.py +0 -0
  81. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_install.py +0 -0
  82. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_maintenance_v2.py +0 -0
  83. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_model_discovery.py +0 -0
  84. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_retrieval_gate.py +0 -0
  85. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_retrieval_v2.py +0 -0
  86. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_session_lineage.py +0 -0
  87. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_a.py +0 -0
  88. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.6 → memleaf-0.2.7}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.7 — 2026-09-01
6
+
7
+ - Distinguish an invalid gate type enum from a candidate type that conflicts
8
+ with its selected active update target, while preserving the strict,
9
+ immutable target-type constraint.
10
+ - Give each gate failure a bounded, actionable retry instruction: use a legal
11
+ memory type, preserve the target type for the same future use, or split a
12
+ genuinely different future use into an independent atomic candidate.
13
+ - Preserve the dedicated `update_target_type_mismatch` diagnostic across Core,
14
+ MCP, and Hermes; repeated failures still write nothing and retain the inbox
15
+ for a later retry.
16
+
5
17
  ## 0.2.6 — 2026-09-01
6
18
 
7
19
  - Reject one worthy memory that spans multiple independent project scopes and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.6
3
+ Version: 0.2.7
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.6。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
26
+ > **当前版本:0.2.7。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 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.6。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
7
+ > **当前版本:0.2.7。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 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.6"
7
+ version = "0.2.7"
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.6"
3
+ __version__ = "0.2.7"
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
@@ -77,6 +77,7 @@ _MODEL_VALIDATION_DETAILS = frozenset(
77
77
  "duplicate_candidate_id",
78
78
  "duplicate_update_target",
79
79
  "mixed_project_scopes",
80
+ "update_target_type_mismatch",
80
81
  "invalid_evidence",
81
82
  "invalid_flags",
82
83
  "invalid_type",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.6
2
+ version: 0.2.7
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -32,6 +32,7 @@ from .models import Memory, utc_now
32
32
  from .native_index import NativeIndexer
33
33
  from .prompts import (
34
34
  DUPLICATE_TARGET_CORRECTION,
35
+ GATE_TYPE_CORRECTION,
35
36
  JSON_CORRECTION,
36
37
  MIXED_PROJECT_SCOPES_CORRECTION,
37
38
  RELATIVE_TIME_CORRECTION,
@@ -39,6 +40,7 @@ from .prompts import (
39
40
  SUMMARY_TARGET_CORRECTION,
40
41
  SUMMARY_TYPE_CORRECTION,
41
42
  SUMMARIZE_SYSTEM,
43
+ UPDATE_TARGET_TYPE_CORRECTION,
42
44
  gate_prompt,
43
45
  summarize_prompt,
44
46
  )
@@ -482,6 +484,10 @@ class Processor:
482
484
  return DUPLICATE_TARGET_CORRECTION
483
485
  if hint == "mixed_project_scopes":
484
486
  return MIXED_PROJECT_SCOPES_CORRECTION
487
+ if stage == "gate" and hint == "update_target_type_mismatch":
488
+ return UPDATE_TARGET_TYPE_CORRECTION
489
+ if stage == "gate" and hint == "invalid_type":
490
+ return GATE_TYPE_CORRECTION
485
491
  if hint == "relative_time":
486
492
  return RELATIVE_TIME_CORRECTION
487
493
  if stage == "summarize" and hint == "invalid_update_target":
@@ -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
+ An update target's existing type is immutable: when the same future use is being
38
+ updated, candidate type must exactly equal the related active target type. If a
39
+ fact serves a different future use, do not force it onto that target; omit
40
+ update_memory_id and emit a separate atomic candidate, or use worth=false when
41
+ it is already covered or only temporary.
37
42
  Within one gate response, each active memory target may appear at most once
38
43
  across all candidates, whether it is named by duplicate_memory_id or
39
44
  update_memory_id. If multiple facts share one future-use/update target, merge
@@ -241,6 +246,27 @@ RELATIVE_TIME_CORRECTION = (
241
246
  )
242
247
 
243
248
 
249
+ GATE_TYPE_CORRECTION = (
250
+ "Previous output violated: invalid_type. In gate output, whenever type is "
251
+ "non-null it must be exactly one of preference, fact, project, todo, event, "
252
+ "identity, or other. worth=true requires a non-null legal type; worth=false "
253
+ "may use null or any of those legal types (for example, a duplicate candidate "
254
+ "may retain its type). Do not invent enum values such as requirement, "
255
+ "decision, or task. Return only the strict gate JSON object."
256
+ )
257
+
258
+
259
+ UPDATE_TARGET_TYPE_CORRECTION = (
260
+ "Previous output violated: update_target_type_mismatch. An existing active "
261
+ "update target's type is immutable. For the same future-use topic, set the "
262
+ "candidate type exactly equal to the supplied related target type. For a "
263
+ "different future use, remove update_memory_id and emit a separate atomic "
264
+ "candidate, or set worth=false if it is already covered or only temporary. "
265
+ "Use only a supplied related active ID; never guess, use native/history IDs, "
266
+ "or force a cross-purpose update. Return only the strict gate JSON object."
267
+ )
268
+
269
+
244
270
  MIXED_PROJECT_SCOPES_CORRECTION = (
245
271
  "Previous output violated: mixed_project_scopes. Keep each worthy "
246
272
  "candidate and summary atomic: it may contain at most one distinct "
@@ -53,6 +53,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
53
53
  "duplicate_candidate_id",
54
54
  "duplicate_update_target",
55
55
  "mixed_project_scopes",
56
+ "update_target_type_mismatch",
56
57
  "invalid_evidence",
57
58
  "invalid_flags",
58
59
  "invalid_type",
@@ -540,7 +541,7 @@ def validate_gate_output(
540
541
  if target_type is not None and candidate_type != target_type:
541
542
  raise ModelOutputError(
542
543
  "candidate type does not match update target",
543
- validation_detail="invalid_type",
544
+ validation_detail="update_target_type_mismatch",
544
545
  )
545
546
  item["update_memory_id"] = update_id
546
547
  for target_field in ("duplicate_memory_id", "update_memory_id"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.6
3
+ Version: 0.2.7
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.6。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 PyPI 分发。
26
+ > **当前版本:0.2.7。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -1751,6 +1751,23 @@ class HermesProviderTests(unittest.TestCase):
1751
1751
  self.assertIsNotNone(fields)
1752
1752
  self.assertEqual(fields[4], "mixed_project_scopes")
1753
1753
 
1754
+ def test_update_target_type_validation_detail_is_preserved(self) -> None:
1755
+ value = {
1756
+ "isError": True,
1757
+ "structuredContent": {
1758
+ "error": {
1759
+ "code": "model_invalid_response",
1760
+ "stage": "gate",
1761
+ "validation_reason": "schema_violation",
1762
+ "validation_detail": "update_target_type_mismatch",
1763
+ "attempt_count": 3,
1764
+ }
1765
+ },
1766
+ }
1767
+ fields = provider_module._mcp_error_fields(value)
1768
+ self.assertIsNotNone(fields)
1769
+ self.assertEqual(fields[4], "update_target_type_mismatch")
1770
+
1754
1771
  def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
1755
1772
  value = {
1756
1773
  "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.6", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.7", (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:
@@ -11,12 +11,14 @@ from memleaf.config import default_config, load_config, save_config
11
11
  from memleaf.inbox import complete_turns, parse_inbox
12
12
  from memleaf.prompts import (
13
13
  DUPLICATE_TARGET_CORRECTION,
14
+ GATE_TYPE_CORRECTION,
14
15
  GATE_SYSTEM,
15
16
  MIXED_PROJECT_SCOPES_CORRECTION,
16
17
  RELATIVE_TIME_CORRECTION,
17
18
  SUMMARY_TARGET_CORRECTION,
18
19
  SUMMARY_TYPE_CORRECTION,
19
20
  SUMMARIZE_SYSTEM,
21
+ UPDATE_TARGET_TYPE_CORRECTION,
20
22
  gate_prompt,
21
23
  summarize_prompt,
22
24
  )
@@ -1100,6 +1102,20 @@ class ValidationTest(unittest.TestCase):
1100
1102
  ]
1101
1103
  }
1102
1104
  self.assertEqual(len(parse_gate_output(json.dumps(valid), ["event-a"])["candidates"]), 3)
1105
+ nonworthy_with_type = dict(
1106
+ base,
1107
+ candidate_id="duplicate-observation",
1108
+ duplicate=True,
1109
+ worth=False,
1110
+ type="fact",
1111
+ )
1112
+ self.assertEqual(
1113
+ parse_gate_output(
1114
+ json.dumps({"candidates": [nonworthy_with_type]}),
1115
+ ["event-a"],
1116
+ )["candidates"][0]["type"],
1117
+ "fact",
1118
+ )
1103
1119
  self.assertIsNone(ModelOutputError("secret", validation_detail="not-a-detail").validation_detail)
1104
1120
 
1105
1121
  def test_gate_rejects_reused_active_target_and_checks_target_type(self):
@@ -1137,7 +1153,28 @@ class ValidationTest(unittest.TestCase):
1137
1153
  related_memory_ids=["mem-target"],
1138
1154
  related_memory_types={"mem-target": "fact"},
1139
1155
  )
1140
- self.assertEqual(raised.exception.validation_detail, "invalid_type")
1156
+ self.assertEqual(raised.exception.validation_detail, "update_target_type_mismatch")
1157
+
1158
+ def test_gate_update_target_type_mismatch_has_dedicated_detail(self):
1159
+ candidate = {
1160
+ "candidate_id": "wrong-target-type",
1161
+ "memory": "A project update",
1162
+ "evidence_event_ids": ["event-a"],
1163
+ "duplicate": False,
1164
+ "worth": True,
1165
+ "type": "project",
1166
+ "scopes": ["global"],
1167
+ "scope_source": "model",
1168
+ "update_memory_id": "mem-fact",
1169
+ }
1170
+ with self.assertRaises(ModelOutputError) as raised:
1171
+ parse_gate_output(
1172
+ json.dumps({"candidates": [candidate]}),
1173
+ current_event_keys=["event-a"],
1174
+ related_memory_ids=["mem-fact"],
1175
+ related_memory_types={"mem-fact": "fact"},
1176
+ )
1177
+ self.assertEqual(raised.exception.validation_detail, "update_target_type_mismatch")
1141
1178
 
1142
1179
  def test_summary_constraints_preserve_gate_target_and_type(self):
1143
1180
  summary = {
@@ -1180,6 +1217,11 @@ class ValidationTest(unittest.TestCase):
1180
1217
  self.assertIn("only once", DUPLICATE_TARGET_CORRECTION.lower())
1181
1218
  self.assertIn("immutable", SUMMARY_TARGET_CORRECTION.lower())
1182
1219
  self.assertIn("exactly equal", SUMMARY_TYPE_CORRECTION.lower())
1220
+ self.assertIn("invalid_type", GATE_TYPE_CORRECTION)
1221
+ self.assertIn("worth=false", GATE_TYPE_CORRECTION)
1222
+ self.assertIn("may use null or any of those legal types", GATE_TYPE_CORRECTION)
1223
+ self.assertIn("update_target_type_mismatch", UPDATE_TARGET_TYPE_CORRECTION)
1224
+ self.assertIn("immutable", UPDATE_TARGET_TYPE_CORRECTION.lower())
1183
1225
 
1184
1226
  def test_correction_prompt_explains_project_scope_atomicity(self):
1185
1227
  self.assertIn("mixed_project_scopes", MIXED_PROJECT_SCOPES_CORRECTION)
@@ -1197,6 +1239,17 @@ class ValidationTest(unittest.TestCase):
1197
1239
  "duplicate_update_target",
1198
1240
  )
1199
1241
 
1242
+ def test_mcp_model_diagnostics_preserve_update_target_type_detail(self):
1243
+ error = ModelOutputError(
1244
+ "safe",
1245
+ validation_reason="schema_violation",
1246
+ validation_detail="update_target_type_mismatch",
1247
+ )
1248
+ self.assertEqual(
1249
+ _safe_model_diagnostics(error)["validation_detail"],
1250
+ "update_target_type_mismatch",
1251
+ )
1252
+
1200
1253
  def test_strict_json_rejects_fences_tail_and_summary_requires_atomic_fields(self):
1201
1254
  for raw in ("```json\n{\"candidates\":[]}\n```", '{"candidates":[]} trailing'):
1202
1255
  with self.assertRaises(ModelOutputError):
@@ -725,11 +725,102 @@ class StageB2ATest(unittest.TestCase):
725
725
  self.assertEqual(result["processed_turns"], 1)
726
726
  self.assertEqual(result["memories_written"], 1)
727
727
  self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "summarize", "summarize"])
728
- self.assertIn("Previous output violated: invalid_type.", backend.calls[1]["prompt"])
728
+ self.assertIn("Previous output violated: update_target_type_mismatch.", backend.calls[1]["prompt"])
729
+ self.assertIn("existing active update target's type is immutable", backend.calls[1]["prompt"])
729
730
  self.assertIn("Previous output violated: invalid_type.", backend.calls[3]["prompt"])
730
731
  self.assertEqual(service.read(existing.memory_id).body, "The fact is updated.")
731
732
  self.assertEqual(len(service.vault.list_markdown("history")), 1)
732
733
 
734
+ def test_different_future_use_omits_update_target_and_creates_independent_memory(self):
735
+ backend = QueueBackend()
736
+ service = self.service(backend, name="independent-future-use")
737
+ existing = service.create_memory(
738
+ memory_id="mem-existing-fact",
739
+ title="Existing fact",
740
+ body="The existing fact remains unchanged.",
741
+ type="fact",
742
+ scopes=["global"],
743
+ )
744
+ user_key, _ = self.capture_turn(
745
+ service,
746
+ turn="independent-future-use",
747
+ user_event="independent-future-use-user",
748
+ assistant_event="independent-future-use-assistant",
749
+ user="A separate project needs a deployment checklist.",
750
+ assistant="The checklist is a new future action.",
751
+ )
752
+ candidate = self.candidate(
753
+ "independent-project-todo",
754
+ [user_key],
755
+ memory="Prepare a deployment checklist for a separate project.",
756
+ type="todo",
757
+ )
758
+ candidate["scopes"] = ["project:separate"]
759
+ backend.responses.extend(
760
+ [
761
+ self.gate([candidate]),
762
+ self.summary(
763
+ user_key,
764
+ title="Deployment checklist",
765
+ body="Prepare a deployment checklist for a separate project.",
766
+ type="todo",
767
+ scopes=["project:separate"],
768
+ status="active",
769
+ ),
770
+ ]
771
+ )
772
+
773
+ result = service.process()
774
+
775
+ self.assertEqual(result["memories_written"], 1)
776
+ self.assertEqual(service.read(existing.memory_id).body, existing.body)
777
+ self.assertEqual(len(service.vault.list_markdown("history")), 0)
778
+ active = self.knowledge(service)
779
+ self.assertEqual(len(active), 2)
780
+ independent = next(record.memory for record in active if record.memory.memory_id != existing.memory_id)
781
+ self.assertEqual(independent.type, "todo")
782
+ self.assertEqual(independent.scopes, ["project:separate"])
783
+
784
+ def test_three_gate_update_target_type_mismatches_keep_queue_and_write_nothing(self):
785
+ backend = QueueBackend()
786
+ service = self.service(backend, name="update-target-type-failure")
787
+ existing = service.create_memory(
788
+ memory_id="mem-fact-target",
789
+ title="Existing fact",
790
+ body="The existing fact.",
791
+ type="fact",
792
+ scopes=["global"],
793
+ )
794
+ user_key, _ = self.capture_turn(
795
+ service,
796
+ turn="update-target-type-failure",
797
+ user_event="update-target-type-failure-user",
798
+ assistant_event="update-target-type-failure-assistant",
799
+ )
800
+ wrong_gate = self.candidate(
801
+ "wrong-target-type",
802
+ [user_key],
803
+ memory="A project update forced onto a fact target.",
804
+ type="project",
805
+ update_memory_id=existing.memory_id,
806
+ )
807
+ backend.responses.extend([self.gate([wrong_gate])] * 3)
808
+
809
+ with self.assertRaises(ModelOutputError) as raised:
810
+ service.process()
811
+
812
+ self.assertEqual(raised.exception.validation_detail, "update_target_type_mismatch")
813
+ self.assertEqual(raised.exception.stage, "gate")
814
+ self.assertEqual(raised.exception.attempt_count, 3)
815
+ marker = self.processed(service)["sessions"]["codex/s"]["processing"]
816
+ self.assertEqual(marker["failure_stage"], "gate")
817
+ self.assertEqual(marker["validation_detail"], "update_target_type_mismatch")
818
+ self.assertEqual(marker["attempt_count"], 3)
819
+ self.assertEqual(self.processed(service)["sessions"]["codex/s"].get("watermark", 0), 0)
820
+ self.assertTrue((service.vault.inbox_path / "codex" / "s.md").is_file())
821
+ self.assertEqual(service.read(existing.memory_id).body, existing.body)
822
+ self.assertEqual(service._read_memories_unlocked("history"), [])
823
+
733
824
  def test_mixed_project_summary_retries_before_writing(self):
734
825
  backend = QueueBackend()
735
826
  service = self.service(backend, name="mixed-summary-retry")
@@ -1184,6 +1275,7 @@ class StageB2ATest(unittest.TestCase):
1184
1275
 
1185
1276
  self.assertEqual(result["processed_turns"], 1)
1186
1277
  self.assertIn("Previous output violated: invalid_type.", backend.calls[1]["prompt"])
1278
+ self.assertIn("whenever type is non-null", backend.calls[1]["prompt"])
1187
1279
  self.assertNotIn(secret, backend.calls[1]["prompt"])
1188
1280
 
1189
1281
  def test_empty_fenced_and_missing_gate_shapes_retry_once(self):
@@ -104,7 +104,7 @@ class StageC1MCPTest(unittest.TestCase):
104
104
  testcase.assertEqual(result["resultType"], "complete")
105
105
  testcase.assertEqual(
106
106
  result["_meta"]["io.modelcontextprotocol/serverInfo"],
107
- {"name": "memleaf", "version": "0.2.6"},
107
+ {"name": "memleaf", "version": "0.2.7"},
108
108
  )
109
109
  return result
110
110
 
@@ -24,7 +24,7 @@ class StageC3PackagingTests(unittest.TestCase):
24
24
 
25
25
  def test_project_metadata_and_version_export(self) -> None:
26
26
  self.assertEqual(self.project["name"], "memleaf")
27
- self.assertEqual(self.project["version"], "0.2.6")
27
+ self.assertEqual(self.project["version"], "0.2.7")
28
28
  self.assertEqual(self.project["requires-python"], ">=3.11")
29
29
  self.assertEqual(self.project["dependencies"], [])
30
30
  self.assertEqual(self.project["license"], "MIT")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes