memleaf 0.2.8__tar.gz → 0.2.9__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.8 → memleaf-0.2.9}/CHANGELOG.md +11 -0
  2. {memleaf-0.2.8/src/memleaf.egg-info → memleaf-0.2.9}/PKG-INFO +3 -3
  3. {memleaf-0.2.8 → memleaf-0.2.9}/README.en.md +2 -2
  4. {memleaf-0.2.8 → memleaf-0.2.9}/README.md +2 -2
  5. {memleaf-0.2.8 → memleaf-0.2.9}/pyproject.toml +1 -1
  6. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/hermes_provider/__init__.py +1 -0
  8. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/processing.py +210 -16
  10. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/prompts.py +12 -0
  11. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/validation.py +1 -0
  12. {memleaf-0.2.8 → memleaf-0.2.9/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_maintenance_v2.py +27 -14
  14. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_pypi_install.py +1 -1
  15. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b2a.py +209 -0
  16. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b2b.py +19 -5
  17. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3b_native_context.py +6 -6
  18. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_c1_mcp.py +1 -1
  19. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_c3_packaging.py +1 -1
  20. {memleaf-0.2.8 → memleaf-0.2.9}/IMPLEMENTATION_PLAN.md +0 -0
  21. {memleaf-0.2.8 → memleaf-0.2.9}/LICENSE +0 -0
  22. {memleaf-0.2.8 → memleaf-0.2.9}/MANIFEST.in +0 -0
  23. {memleaf-0.2.8 → memleaf-0.2.9}/RELEASE_CHECKLIST.md +0 -0
  24. {memleaf-0.2.8 → memleaf-0.2.9}/examples/README.md +0 -0
  25. {memleaf-0.2.8 → memleaf-0.2.9}/examples/basic_usage.py +0 -0
  26. {memleaf-0.2.8 → memleaf-0.2.9}/examples/mcp_stdio.ndjson +0 -0
  27. {memleaf-0.2.8 → memleaf-0.2.9}/install.ps1 +0 -0
  28. {memleaf-0.2.8 → memleaf-0.2.9}/install.sh +0 -0
  29. {memleaf-0.2.8 → memleaf-0.2.9}/setup.cfg +0 -0
  30. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/__main__.py +0 -0
  31. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/__init__.py +0 -0
  32. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/antigravity.py +0 -0
  33. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/base.py +0 -0
  34. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/codex.py +0 -0
  35. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/adapters/hermes.py +0 -0
  36. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/budget.py +0 -0
  37. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/capture.py +0 -0
  38. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/cli.py +0 -0
  39. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/compaction.py +0 -0
  40. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/config.py +0 -0
  41. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/credentials.py +0 -0
  42. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/frontmatter.py +0 -0
  43. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/hermes_provider/README.md +0 -0
  44. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/memory_writer.py +0 -0
  58. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/model_discovery.py +0 -0
  59. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/models.py +0 -0
  60. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/native_index.py +0 -0
  61. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/redaction.py +0 -0
  62. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/retrieval.py +0 -0
  63. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/retrieval_gate.py +0 -0
  64. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/scope_maintenance.py +0 -0
  65. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/scope_state.py +0 -0
  66. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/service.py +0 -0
  67. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf/vault.py +0 -0
  68. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/SOURCES.txt +0 -0
  69. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/dependency_links.txt +0 -0
  70. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/entry_points.txt +0 -0
  71. {memleaf-0.2.8 → memleaf-0.2.9}/src/memleaf.egg-info/top_level.txt +0 -0
  72. {memleaf-0.2.8 → memleaf-0.2.9}/tests/__init__.py +0 -0
  73. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_admission_noise.py +0 -0
  74. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_codex_install.py +0 -0
  75. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_codex_native_cli.py +0 -0
  76. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_context_budget.py +0 -0
  77. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_credential_safety.py +0 -0
  78. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_cross_host_acceptance.py +0 -0
  79. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_hermes_provider.py +0 -0
  80. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_hermes_stdio_transport.py +0 -0
  81. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_host_events.py +0 -0
  82. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_host_runtime_contract.py +0 -0
  83. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_install.py +0 -0
  84. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_model_discovery.py +0 -0
  85. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_retrieval_gate.py +0 -0
  86. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_retrieval_v2.py +0 -0
  87. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_session_lineage.py +0 -0
  88. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_a.py +0 -0
  89. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b1.py +0 -0
  90. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3a_commit.py +0 -0
  91. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3a_contract.py +0 -0
  92. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.8 → memleaf-0.2.9}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.8 → memleaf-0.2.9}/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.9 — 2026-09-01
6
+
7
+ - Reject update and duplicate targets that are relevant only to another item
8
+ in the same aggregate turn, preventing cross-project memory overwrites.
9
+ - Retry an unrelated gate target with an explicit correction, then recover
10
+ deterministically after the bounded final attempt: preserve unrelated
11
+ updates as independent candidates and discard unsafe duplicate claims.
12
+ - Query related memories from each candidate's own complete topic instead of
13
+ the whole mailbox turn, while allowing the remaining valid candidates to be
14
+ processed without holding the retained inbox turn hostage.
15
+
5
16
  ## 0.2.8 — 2026-09-01
6
17
 
7
18
  - Let the automatic summarizer independently reject an over-admitted gate
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.8
3
+ Version: 0.2.9
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.8。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 PyPI 分发。
26
+ > **当前版本:0.2.9。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -4,8 +4,8 @@
4
4
 
5
5
  [中文](README.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **Version: 0.2.4.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.4 is distributed through PyPI.
7
+ > **Version: 0.2.9.**
8
+ > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.9 is distributed through PyPI.
9
9
  > **The current release supports Hermes and Codex.** Antigravity is not detected, installed, or configured.
10
10
 
11
11
  ## Project scope
@@ -4,8 +4,8 @@
4
4
 
5
5
  [English](README.en.md) · [PyPI](https://pypi.org/project/memleaf/) · [GitHub](https://github.com/miffyblueboo/memleaf)
6
6
 
7
- > **当前版本:0.2.8。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 PyPI 分发。
7
+ > **当前版本:0.2.9。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.8"
7
+ version = "0.2.9"
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.8"
3
+ __version__ = "0.2.9"
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
@@ -78,6 +78,7 @@ _MODEL_VALIDATION_DETAILS = frozenset(
78
78
  "duplicate_update_target",
79
79
  "mixed_project_scopes",
80
80
  "update_target_type_mismatch",
81
+ "target_not_relevant",
81
82
  "scope_not_grounded",
82
83
  "scope_drift",
83
84
  "invalid_evidence",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.8
2
+ version: 0.2.9
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -42,6 +42,7 @@ from .prompts import (
42
42
  SUMMARY_TARGET_CORRECTION,
43
43
  SUMMARY_TYPE_CORRECTION,
44
44
  SUMMARIZE_SYSTEM,
45
+ TARGET_RELEVANCE_CORRECTION,
45
46
  UPDATE_TARGET_TYPE_CORRECTION,
46
47
  gate_prompt,
47
48
  summarize_prompt,
@@ -493,6 +494,8 @@ class Processor:
493
494
  return GATE_TYPE_CORRECTION
494
495
  if stage == "gate" and hint == "scope_not_grounded":
495
496
  return SCOPE_GROUNDING_CORRECTION
497
+ if stage == "gate" and hint == "target_not_relevant":
498
+ return TARGET_RELEVANCE_CORRECTION
496
499
  if stage == "summarize" and hint == "scope_drift":
497
500
  return SUMMARY_SCOPE_CORRECTION
498
501
  if hint == "relative_time":
@@ -1419,6 +1422,157 @@ class Processor:
1419
1422
  strict_relevance=True,
1420
1423
  )
1421
1424
 
1425
+ def _target_is_relevant_to_candidate(
1426
+ self,
1427
+ turn: InboxTurn,
1428
+ candidate: Mapping[str, Any],
1429
+ *,
1430
+ scope_directory: Optional[list[dict[str, Any]]] = None,
1431
+ scope_directory_complete: bool = True,
1432
+ ) -> bool:
1433
+ """Check a model-selected target against the candidate topic only.
1434
+
1435
+ The ordinary related-memory prompt may include several independent
1436
+ items from one mailbox turn. Target selection must not inherit that
1437
+ aggregate relevance: only the candidate's own extracted topic may
1438
+ make an active target eligible. A complete scope directory remains a
1439
+ bounded escape hatch for an explicitly indirect user/session scope;
1440
+ it never turns the target into an unconditional priority hit for a
1441
+ direct model-attributed candidate.
1442
+ """
1443
+
1444
+ target = next(
1445
+ (
1446
+ candidate.get(field)
1447
+ for field in ("duplicate_memory_id", "update_memory_id")
1448
+ if isinstance(candidate.get(field), str) and candidate.get(field)
1449
+ ),
1450
+ None,
1451
+ )
1452
+ memory = candidate.get("memory")
1453
+ candidate_scopes = candidate.get("scopes")
1454
+ if (
1455
+ not isinstance(target, str)
1456
+ or not isinstance(memory, str)
1457
+ or not isinstance(candidate_scopes, list)
1458
+ ):
1459
+ return False
1460
+
1461
+ target_key = target.casefold()
1462
+ candidate_scope_source = candidate.get("scope_source")
1463
+
1464
+ # A complete bounded directory is the existing indirect resolution
1465
+ # path. It contains only active IDs from the requested inherited
1466
+ # scope, so preserve its exact-ID selection semantics rather than
1467
+ # forcing a body relevance check that the directory intentionally
1468
+ # cannot provide.
1469
+ if scope_directory is not None:
1470
+ if not scope_directory_complete:
1471
+ return False
1472
+ selected = next(
1473
+ (
1474
+ item
1475
+ for item in scope_directory
1476
+ if isinstance(item, Mapping)
1477
+ and isinstance(item.get("memory_id"), str)
1478
+ and item["memory_id"].casefold() == target_key
1479
+ ),
1480
+ None,
1481
+ )
1482
+ if selected is not None and selected.get("type") == candidate.get("type"):
1483
+ return True
1484
+
1485
+ # Model-attributed, directly named scopes must match the active
1486
+ # target's stable topic title. Matching the whole old body is too
1487
+ # permissive: two different project memories can share implementation
1488
+ # details such as milestones while serving different future uses.
1489
+ if candidate_scope_source == "model":
1490
+ scope_terms: list[str] = []
1491
+ try:
1492
+ scope_config = self.service.vault.config()
1493
+ configured_scopes = scope_config.get("scopes", {})
1494
+ except (OSError, UnicodeError, ValueError, TypeError):
1495
+ scope_config = {}
1496
+ configured_scopes = {}
1497
+ for candidate_scope in candidate_scopes:
1498
+ if not isinstance(candidate_scope, str) or not candidate_scope.startswith("project:"):
1499
+ continue
1500
+ scope_terms.append(candidate_scope.partition(":")[2])
1501
+ metadata = (
1502
+ configured_scopes.get(candidate_scope)
1503
+ if isinstance(configured_scopes, Mapping)
1504
+ else None
1505
+ )
1506
+ aliases = metadata.get("aliases") if isinstance(metadata, Mapping) else None
1507
+ if isinstance(aliases, list):
1508
+ scope_terms.extend(
1509
+ alias for alias in aliases if isinstance(alias, str) and alias
1510
+ )
1511
+
1512
+ # The regression is project-scope drift inside aggregate turns.
1513
+ # Global/domain/portfolio targets keep their established semantic
1514
+ # gate behavior because there is no project identity to remove
1515
+ # before comparing stable topics.
1516
+ if not scope_terms:
1517
+ return True
1518
+
1519
+ visible_turn = " ".join(
1520
+ event.content for event in turn.events if isinstance(event.content, str)
1521
+ )
1522
+ if scope_terms and not any(
1523
+ normalize_term(term) in normalize_term(visible_turn) for term in scope_terms
1524
+ ):
1525
+ # The visible turn is elliptical (for example, "this
1526
+ # project"); the inherited scope and bounded related context
1527
+ # already resolved its target.
1528
+ return True
1529
+
1530
+ target_memory = None
1531
+ with self.service.vault.lock():
1532
+ for record in self.service._read_memories_unlocked("knowledge"):
1533
+ if record.memory.memory_id.casefold() == target_key:
1534
+ target_memory = record.memory
1535
+ break
1536
+ if target_memory is None:
1537
+ for item in self._planned_related:
1538
+ if (
1539
+ isinstance(item, Mapping)
1540
+ and isinstance(item.get("memory_id"), str)
1541
+ and item["memory_id"].casefold() == target_key
1542
+ ):
1543
+ try:
1544
+ target_memory = Memory.from_mapping(item)
1545
+ except (TypeError, ValueError):
1546
+ target_memory = None
1547
+ break
1548
+ if target_memory is None:
1549
+ return False
1550
+ if not filter_by_scope([target_memory], candidate_scopes, scope_config):
1551
+ return False
1552
+
1553
+ def without_scope_terms(value: str) -> str:
1554
+ result = value
1555
+ for term in sorted(set(scope_terms), key=len, reverse=True):
1556
+ result = re.sub(re.escape(term), "", result, flags=re.IGNORECASE)
1557
+ return result.strip()
1558
+
1559
+ topic_title = without_scope_terms(target_memory.title)
1560
+ topic_query = without_scope_terms(memory)
1561
+ if not topic_title or not topic_query:
1562
+ return False
1563
+ topic_memory = Memory(
1564
+ memory_id=target_memory.memory_id,
1565
+ title=topic_query,
1566
+ body="",
1567
+ type=target_memory.type,
1568
+ scopes=target_memory.scopes,
1569
+ )
1570
+ return candidate_matches_query(topic_memory, topic_title)
1571
+
1572
+ # User/session scopes are authoritative indirect context. The base
1573
+ # parser has already limited the target to an active related memory.
1574
+ return candidate_scope_source in {"user", "session_context"}
1575
+
1422
1576
  def _defer_candidate(
1423
1577
  self,
1424
1578
  turn_ref: tuple[str, str, str],
@@ -1643,6 +1797,60 @@ class Processor:
1643
1797
  list(summary["scopes"]),
1644
1798
  )
1645
1799
 
1800
+ gate_attempt_count = 0
1801
+
1802
+ def parse_gate(raw: str) -> dict[str, Any]:
1803
+ nonlocal gate_attempt_count
1804
+ gate_attempt_count += 1
1805
+ parsed = parse_gate_output(
1806
+ raw,
1807
+ current_event_keys=turn.event_keys,
1808
+ related_memory_ids=gate_related_memory_ids,
1809
+ related_memory_types=gate_related_memory_types,
1810
+ scope_registry=validation_scope_registry,
1811
+ )
1812
+ invalid_targets: dict[str, set[str]] = {}
1813
+ for candidate in parsed["candidates"]:
1814
+ target_fields = {
1815
+ field
1816
+ for field in ("duplicate_memory_id", "update_memory_id")
1817
+ if isinstance(candidate.get(field), str) and candidate.get(field)
1818
+ }
1819
+ if target_fields and not self._target_is_relevant_to_candidate(
1820
+ turn,
1821
+ candidate,
1822
+ scope_directory=scope_directory,
1823
+ scope_directory_complete=scope_directory_complete,
1824
+ ):
1825
+ invalid_targets[candidate["candidate_id"].casefold()] = target_fields
1826
+
1827
+ if invalid_targets and gate_attempt_count < 3:
1828
+ raise ModelOutputError(
1829
+ "selected target is not relevant to the candidate topic",
1830
+ validation_detail="target_not_relevant",
1831
+ )
1832
+
1833
+ if invalid_targets:
1834
+ # A persistently non-converging model must not hold an entire
1835
+ # inbox turn hostage. An unrelated update target can safely
1836
+ # become an independent CREATE candidate; an unrelated
1837
+ # duplicate has no independent fact to write and is dropped.
1838
+ candidates: list[dict[str, Any]] = []
1839
+ for candidate in parsed["candidates"]:
1840
+ fields = invalid_targets.get(candidate["candidate_id"].casefold())
1841
+ if not fields:
1842
+ candidates.append(candidate)
1843
+ continue
1844
+ if "duplicate_memory_id" in fields:
1845
+ continue
1846
+ if "update_memory_id" in fields and candidate.get("worth"):
1847
+ independent = dict(candidate)
1848
+ independent.pop("update_memory_id", None)
1849
+ candidates.append(independent)
1850
+ parsed = dict(parsed)
1851
+ parsed["candidates"] = candidates
1852
+ return parsed
1853
+
1646
1854
  gate = self._complete_json_stage(
1647
1855
  backend,
1648
1856
  gate_prompt(
@@ -1655,13 +1863,7 @@ class Processor:
1655
1863
  ),
1656
1864
  system=GATE_SYSTEM,
1657
1865
  purpose="gate",
1658
- parser=lambda raw: parse_gate_output(
1659
- raw,
1660
- current_event_keys=turn.event_keys,
1661
- related_memory_ids=gate_related_memory_ids,
1662
- related_memory_types=gate_related_memory_types,
1663
- scope_registry=validation_scope_registry,
1664
- ),
1866
+ parser=parse_gate,
1665
1867
  diagnostic_context={
1666
1868
  "source": turn.source,
1667
1869
  "session_id": turn.session_id,
@@ -1710,15 +1912,7 @@ class Processor:
1710
1912
  candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
1711
1913
  turn,
1712
1914
  state,
1713
- (
1714
- str(candidate.get("memory", ""))
1715
- + "\n"
1716
- + " ".join(
1717
- event.get("content", "")
1718
- for event in events
1719
- if isinstance(event.get("content"), str)
1720
- )
1721
- ).strip(),
1915
+ str(candidate.get("memory", "")).strip(),
1722
1916
  candidate_scopes,
1723
1917
  overlay=self._planned_related,
1724
1918
  priority_memory_ids=[
@@ -335,6 +335,18 @@ SUMMARY_SCOPE_CORRECTION = (
335
335
  )
336
336
 
337
337
 
338
+ TARGET_RELEVANCE_CORRECTION = (
339
+ "Previous output violated: target_not_relevant. An update or duplicate "
340
+ "target must match the candidate's own memory topic through the supplied "
341
+ "active memory; do not borrow relevance from another event in the same "
342
+ "mailbox turn, aggregate context, or a priority ID. For a different "
343
+ "future use, remove the target and emit a separate atomic candidate. An "
344
+ "indirect same-use update may use a complete scope directory or session "
345
+ "context only when exactly one supplied active target is clear; otherwise "
346
+ "leave the target unset or defer it. Return only the strict gate JSON object."
347
+ )
348
+
349
+
338
350
  SCOPE_GROUNDING_CORRECTION = (
339
351
  "Previous output violated: scope_not_grounded. For each worthy candidate "
340
352
  "with scope_source=model, choose a project scope named by that candidate's "
@@ -60,6 +60,7 @@ MODEL_VALIDATION_DETAILS = frozenset(
60
60
  "duplicate_update_target",
61
61
  "mixed_project_scopes",
62
62
  "update_target_type_mismatch",
63
+ "target_not_relevant",
63
64
  "scope_not_grounded",
64
65
  "scope_drift",
65
66
  "invalid_evidence",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.8
3
+ Version: 0.2.9
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.8。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 PyPI 分发。
26
+ > **当前版本:0.2.9。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -326,7 +326,7 @@ class MaintenanceV2Tests(unittest.TestCase):
326
326
  candidate(
327
327
  "priority-update",
328
328
  [user_event, assistant_event],
329
- "priority project 状态更新",
329
+ "priority project plan 状态更新",
330
330
  scopes=["project:priority"],
331
331
  type="project",
332
332
  update_memory_id=target.memory_id,
@@ -1026,24 +1026,37 @@ class MaintenanceV2Tests(unittest.TestCase):
1026
1026
  "alpha 项目负责人更新为乙。",
1027
1027
  "已确认 alpha 负责人为乙;beta 项目负责人仍为丙。",
1028
1028
  )
1029
- backend = QueueBackend(
1029
+ invalid_gate = gate(
1030
1030
  [
1031
- gate(
1032
- [
1033
- candidate(
1034
- "wrong-target",
1035
- [user_event, assistant_event],
1036
- "alpha 项目负责人更新为乙。",
1037
- scopes=["project:alpha"],
1038
- update_memory_id="mem-beta-owner",
1039
- )
1040
- ]
1031
+ candidate(
1032
+ "wrong-target",
1033
+ [user_event, assistant_event],
1034
+ "alpha 项目负责人更新为乙。",
1035
+ scopes=["project:alpha"],
1036
+ update_memory_id="mem-beta-owner",
1041
1037
  )
1042
1038
  ]
1043
1039
  )
1040
+ backend = QueueBackend(
1041
+ [
1042
+ invalid_gate,
1043
+ invalid_gate,
1044
+ invalid_gate,
1045
+ summary(
1046
+ user_event,
1047
+ "alpha 项目负责人更新为乙。",
1048
+ title="alpha 项目负责人",
1049
+ scopes=["project:alpha"],
1050
+ ),
1051
+ ]
1052
+ )
1053
+
1054
+ result = self.service.process(source="hermes", session_id="isolation", model=backend)
1044
1055
 
1045
- with self.assertRaises(ValueError):
1046
- self.service.process(source="hermes", session_id="isolation", model=backend)
1056
+ self.assertEqual(result["memories_written"], 1)
1057
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
1058
+ self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
1059
+ self.assertEqual(self.processed()["sessions"]["hermes/isolation"]["processing"]["status"], "idle")
1047
1060
 
1048
1061
  self.assertEqual(self.service.read("mem-alpha-owner").body, "alpha 项目负责人是甲。")
1049
1062
  self.assertEqual(self.service.read("mem-beta-owner").body, "beta 项目负责人是丙。")
@@ -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.8", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.9", (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:
@@ -873,6 +873,215 @@ class StageB2ATest(unittest.TestCase):
873
873
  self.assertEqual(service.read(other.memory_id).body, other.body)
874
874
  self.assertEqual(len(service.vault.list_markdown("history")), 1)
875
875
 
876
+ def test_same_project_different_future_use_target_retries_then_creates(self):
877
+ backend = QueueBackend()
878
+ service = self.service(backend, name="same-project-wrong-target")
879
+ old = service.create_memory(
880
+ memory_id="mem-orion-sync",
881
+ title="金元顺安项目任务同步到 Orion 系统",
882
+ body=(
883
+ "金元顺安项目任务已同步到 Orion;里程碑包含信创环境搭建、"
884
+ "功能验证测试和上线安排。"
885
+ ),
886
+ type="todo",
887
+ scopes=["project:金元顺安"],
888
+ )
889
+ user_key, assistant_key = self.capture_turn(
890
+ service,
891
+ turn="same-project-wrong-target",
892
+ user_event="same-project-wrong-target-user",
893
+ assistant_event="same-project-wrong-target-assistant",
894
+ user="金元顺安实施计划六项建议;同时需要查看 Orion 任务同步。",
895
+ assistant="已整理金元顺安实施计划与 Orion 任务同步。",
896
+ )
897
+ wrong = self.candidate(
898
+ "wrong-jinyuan-target",
899
+ [user_key, assistant_key],
900
+ memory="金元顺安实施计划六项建议含里程碑、信创环境、测试和上线安排。",
901
+ type="todo",
902
+ update_memory_id=old.memory_id,
903
+ )
904
+ wrong["scopes"] = ["project:金元顺安"]
905
+ corrected = dict(wrong, candidate_id="jinyuan-independent")
906
+ corrected.pop("update_memory_id")
907
+ backend.responses.extend(
908
+ [
909
+ self.gate([wrong]),
910
+ self.gate([corrected]),
911
+ self.summary(
912
+ user_key,
913
+ title="金元顺安实施计划六项建议",
914
+ body="金元顺安实施计划六项建议含里程碑、信创环境、测试和上线安排。",
915
+ type="todo",
916
+ scopes=["project:金元顺安"],
917
+ status="active",
918
+ ),
919
+ ]
920
+ )
921
+
922
+ result = service.process(source="codex", session_id="s", model=backend)
923
+
924
+ self.assertEqual(result["memories_written"], 1)
925
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "summarize"])
926
+ self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
927
+ self.assertEqual(service.read(old.memory_id).title, old.title)
928
+ self.assertEqual(service.read(old.memory_id).body, old.body)
929
+ self.assertEqual(len(service.vault.list_markdown("history")), 0)
930
+ active = service._read_memories_unlocked("knowledge")
931
+ self.assertEqual(len(active), 2)
932
+ self.assertIn(
933
+ "金元顺安实施计划六项建议含里程碑、信创环境、测试和上线安排。",
934
+ "\n".join(item.memory.body for item in active),
935
+ )
936
+
937
+ def test_same_project_same_future_use_target_remains_valid(self):
938
+ backend = QueueBackend()
939
+ service = self.service(backend, name="same-project-valid-target")
940
+ old = service.create_memory(
941
+ memory_id="mem-jinyuan-plan",
942
+ title="金元顺安实施计划",
943
+ body="金元顺安实施计划按原方案执行。",
944
+ type="project",
945
+ scopes=["project:金元顺安"],
946
+ )
947
+ user_key, assistant_key = self.capture_turn(
948
+ service,
949
+ turn="same-project-valid-target",
950
+ user_event="same-project-valid-target-user",
951
+ assistant_event="same-project-valid-target-assistant",
952
+ user="金元顺安实施计划新增部署要求。",
953
+ assistant="已确认金元顺安实施计划需要纳入部署要求。",
954
+ )
955
+ candidate = self.candidate(
956
+ "valid-jinyuan-target",
957
+ [user_key, assistant_key],
958
+ memory="金元顺安实施计划新增部署要求。",
959
+ type="project",
960
+ update_memory_id=old.memory_id,
961
+ )
962
+ candidate["scopes"] = ["project:金元顺安"]
963
+ backend.responses.extend(
964
+ [
965
+ self.gate([candidate]),
966
+ self.summary(
967
+ user_key,
968
+ title="金元顺安实施计划",
969
+ body="金元顺安实施计划新增部署要求。",
970
+ type="project",
971
+ scopes=["project:金元顺安"],
972
+ update_memory_id=old.memory_id,
973
+ ),
974
+ ]
975
+ )
976
+
977
+ result = service.process(source="codex", session_id="s", model=backend)
978
+
979
+ self.assertEqual(result["memories_written"], 1)
980
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "summarize"])
981
+ self.assertEqual(service.read(old.memory_id).body, "金元顺安实施计划新增部署要求。")
982
+ self.assertEqual(len(service.vault.list_markdown("history")), 1)
983
+
984
+ def test_same_project_wrong_update_target_final_retry_becomes_create(self):
985
+ backend = QueueBackend()
986
+ service = self.service(backend, name="same-project-wrong-target-final-retry")
987
+ old = service.create_memory(
988
+ memory_id="mem-beta-owner-final-retry",
989
+ title="beta 项目负责人",
990
+ body="beta 项目负责人是丙。",
991
+ type="identity",
992
+ scopes=["project:beta"],
993
+ )
994
+ user_key, assistant_key = self.capture_turn(
995
+ service,
996
+ turn="same-project-wrong-target-final-retry",
997
+ user_event="same-project-wrong-target-final-retry-user",
998
+ assistant_event="same-project-wrong-target-final-retry-assistant",
999
+ user="alpha 项目负责人更新为乙;beta 项目负责人仍为丙。",
1000
+ assistant="已确认 alpha 负责人为乙,beta 负责人仍为丙。",
1001
+ )
1002
+ wrong = self.candidate(
1003
+ "wrong-target-final-retry",
1004
+ [user_key, assistant_key],
1005
+ memory="alpha 项目负责人更新为乙。",
1006
+ type="identity",
1007
+ update_memory_id=old.memory_id,
1008
+ )
1009
+ wrong["scopes"] = ["project:alpha"]
1010
+ invalid_gate = self.gate([wrong])
1011
+ backend.responses.extend(
1012
+ [
1013
+ invalid_gate,
1014
+ invalid_gate,
1015
+ invalid_gate,
1016
+ self.summary(
1017
+ user_key,
1018
+ title="alpha 项目负责人",
1019
+ body="alpha 项目负责人已更新为乙。",
1020
+ type="identity",
1021
+ scopes=["project:alpha"],
1022
+ ),
1023
+ ]
1024
+ )
1025
+
1026
+ result = service.process(source="codex", session_id="s", model=backend)
1027
+
1028
+ self.assertEqual(result["processed_turns"], 1)
1029
+ self.assertEqual(result["memories_written"], 1)
1030
+ self.assertEqual(
1031
+ [call["purpose"] for call in backend.calls],
1032
+ ["gate", "gate", "gate", "summarize"],
1033
+ )
1034
+ self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
1035
+ self.assertEqual(service.read(old.memory_id).body, old.body)
1036
+ self.assertEqual(len(service.vault.list_markdown("history")), 0)
1037
+ self.assertEqual(self.processed(service)["sessions"]["codex/s"]["processing"]["status"], "idle")
1038
+ self.assertIn(
1039
+ "alpha 项目负责人已更新为乙。",
1040
+ "\n".join(record.memory.body for record in service._read_memories_unlocked("knowledge")),
1041
+ )
1042
+
1043
+ def test_same_project_wrong_duplicate_target_final_retry_is_dropped(self):
1044
+ backend = QueueBackend()
1045
+ service = self.service(backend, name="same-project-wrong-duplicate-final-retry")
1046
+ old = service.create_memory(
1047
+ memory_id="mem-beta-duplicate-final-retry",
1048
+ title="beta 项目状态",
1049
+ body="beta 项目状态保持不变。",
1050
+ type="fact",
1051
+ scopes=["project:beta"],
1052
+ )
1053
+ user_key, assistant_key = self.capture_turn(
1054
+ service,
1055
+ turn="same-project-wrong-duplicate-final-retry",
1056
+ user_event="same-project-wrong-duplicate-final-retry-user",
1057
+ assistant_event="same-project-wrong-duplicate-final-retry-assistant",
1058
+ user="alpha 项目状态保持不变;beta 项目状态也保持不变。",
1059
+ assistant="已确认两个项目状态均未变化。",
1060
+ )
1061
+ wrong = self.candidate(
1062
+ "wrong-duplicate-final-retry",
1063
+ [user_key, assistant_key],
1064
+ memory="alpha 项目状态保持不变。",
1065
+ duplicate=True,
1066
+ worth=False,
1067
+ type="fact",
1068
+ )
1069
+ wrong["duplicate_memory_id"] = old.memory_id
1070
+ wrong["scopes"] = ["project:alpha"]
1071
+ invalid_gate = self.gate([wrong])
1072
+ backend.responses.extend([invalid_gate, invalid_gate, invalid_gate])
1073
+
1074
+ result = service.process(source="codex", session_id="s", model=backend)
1075
+
1076
+ self.assertEqual(result["processed_turns"], 1)
1077
+ self.assertEqual(result["memories_written"], 0)
1078
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate"])
1079
+ self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
1080
+ self.assertEqual(service.read(old.memory_id).body, old.body)
1081
+ self.assertEqual(len(service._read_memories_unlocked("knowledge")), 1)
1082
+ self.assertEqual(len(service.vault.list_markdown("history")), 0)
1083
+ self.assertEqual(self.processed(service)["sessions"]["codex/s"]["processing"]["status"], "idle")
1084
+
876
1085
  def test_gate_and_summary_update_target_mismatch_fails_after_bounded_retries(self):
877
1086
  backend = QueueBackend()
878
1087
  service = self.service(backend, name="email-update-mismatch-failure")
@@ -150,7 +150,12 @@ class StageB2BTest(unittest.TestCase):
150
150
  user=f"user {turn} b2b{related_context}",
151
151
  assistant=f"assistant {turn}",
152
152
  )
153
- candidate = self.candidate("candidate-" + turn, [user_key], type=type)
153
+ candidate = self.candidate(
154
+ "candidate-" + turn,
155
+ [user_key],
156
+ memory=f"{active.title} {active.body}",
157
+ type=type,
158
+ )
154
159
  summary_extra = dict(extra or {})
155
160
  summary_extra["update_memory_id"] = active_id
156
161
  backend.responses.extend(
@@ -333,7 +338,9 @@ class StageB2BTest(unittest.TestCase):
333
338
  assistant_event="a2",
334
339
  user="user t2 old",
335
340
  )
336
- candidate = self.candidate("candidate-t2", [user_key])
341
+ candidate = self.candidate(
342
+ "candidate-t2", [user_key], memory=f"{active.title} {active.body}"
343
+ )
337
344
  responses = [
338
345
  self.gate([candidate]),
339
346
  self.summary(user_key, title="Updated", body="new", update_memory_id=active.memory_id),
@@ -373,7 +380,9 @@ class StageB2BTest(unittest.TestCase):
373
380
  assistant_event="a2",
374
381
  user="user t2 b2b old",
375
382
  )
376
- candidate = self.candidate("candidate-t2", [user_key])
383
+ candidate = self.candidate(
384
+ "candidate-t2", [user_key], memory=f"{active.title} {active.body}"
385
+ )
377
386
  responses = [self.gate([candidate]), self.summary(user_key, title="Updated", body="b2b new", update_memory_id=active.memory_id)]
378
387
 
379
388
  backend.responses.extend(responses)
@@ -405,7 +414,9 @@ class StageB2BTest(unittest.TestCase):
405
414
  assistant_event="p-a2",
406
415
  user="user t2 b2b old",
407
416
  )
408
- candidate2 = self.candidate("candidate-t2", [user_key2])
417
+ candidate2 = self.candidate(
418
+ "candidate-t2", [user_key2], memory=f"{active2.title} {active2.body}"
419
+ )
409
420
  responses2 = [self.gate([candidate2]), self.summary(user_key2, title="Updated", body="b2b new", update_memory_id=active2.memory_id)]
410
421
  backend2.responses.extend(responses2)
411
422
  import memleaf.processing as processing_module
@@ -543,7 +554,10 @@ class StageB2BTest(unittest.TestCase):
543
554
  assistant_event="a2",
544
555
  user="user t2 b2b old",
545
556
  )
546
- candidates = [self.candidate("c1", [user_key]), self.candidate("c2", [user_key], memory="second")]
557
+ candidates = [
558
+ self.candidate("c1", [user_key], memory=f"{active.title} {active.body}"),
559
+ self.candidate("c2", [user_key], memory=f"{active.title} {active.body} second"),
560
+ ]
547
561
  backend.responses.extend(
548
562
  [
549
563
  self.gate(candidates),
@@ -56,10 +56,10 @@ class StageB3BNativeContextTest(unittest.TestCase):
56
56
  return json.dumps({"candidates": [candidate] if candidate is not None else []})
57
57
 
58
58
  @staticmethod
59
- def candidate(event_key_value, *, duplicate=False, worth=True):
59
+ def candidate(event_key_value, *, memory="new state", duplicate=False, worth=True):
60
60
  return {
61
61
  "candidate_id": "candidate-1",
62
- "memory": "new state",
62
+ "memory": memory,
63
63
  "evidence_event_ids": [event_key_value],
64
64
  "duplicate": duplicate,
65
65
  "worth": worth,
@@ -188,7 +188,7 @@ class StageB3BNativeContextTest(unittest.TestCase):
188
188
  event_key_value = self.capture_turn(user="legacy status is now replaced", assistant="confirmed")
189
189
  invalid = QueueBackend(
190
190
  [
191
- self.gate(self.candidate(event_key_value)),
191
+ self.gate(self.candidate(event_key_value, memory="legacy status is now replaced")),
192
192
  self.summary(event_key_value, "new status", shadow=["native-not-related"]),
193
193
  self.summary(event_key_value, "new status", shadow=["native-not-related"]),
194
194
  self.summary(event_key_value, "new status", shadow=["native-not-related"]),
@@ -200,7 +200,7 @@ class StageB3BNativeContextTest(unittest.TestCase):
200
200
 
201
201
  backend = QueueBackend(
202
202
  [
203
- self.gate(self.candidate(event_key_value)),
203
+ self.gate(self.candidate(event_key_value, memory="legacy status is now replaced")),
204
204
  self.summary(event_key_value, "new status", shadow=[native_id]),
205
205
  ]
206
206
  )
@@ -239,9 +239,9 @@ class StageB3BNativeContextTest(unittest.TestCase):
239
239
 
240
240
  event_key_value = self.capture_turn(user="legacy retry status is replaced", assistant="confirmed")
241
241
  responses = [
242
- self.gate(self.candidate(event_key_value)),
242
+ self.gate(self.candidate(event_key_value, memory="legacy retry status is replaced")),
243
243
  self.summary(event_key_value, "retry status", shadow=[native_id]),
244
- self.gate(self.candidate(event_key_value)),
244
+ self.gate(self.candidate(event_key_value, memory="legacy retry status is replaced")),
245
245
  self.summary(event_key_value, "retry status", shadow=[native_id]),
246
246
  ]
247
247
  backend = QueueBackend(responses)
@@ -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.8"},
107
+ {"name": "memleaf", "version": "0.2.9"},
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.8")
27
+ self.assertEqual(self.project["version"], "0.2.9")
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