memleaf 0.2.8__tar.gz → 0.2.10__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.10}/CHANGELOG.md +23 -0
  2. {memleaf-0.2.8/src/memleaf.egg-info → memleaf-0.2.10}/PKG-INFO +3 -3
  3. {memleaf-0.2.8 → memleaf-0.2.10}/README.en.md +2 -2
  4. {memleaf-0.2.8 → memleaf-0.2.10}/README.md +2 -2
  5. {memleaf-0.2.8 → memleaf-0.2.10}/pyproject.toml +1 -1
  6. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/hermes_provider/__init__.py +1 -0
  8. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  9. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/processing.py +260 -17
  10. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/prompts.py +12 -0
  11. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/validation.py +46 -21
  12. {memleaf-0.2.8 → memleaf-0.2.10/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_maintenance_v2.py +27 -14
  14. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_pypi_install.py +1 -1
  15. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b2a.py +482 -0
  16. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b2b.py +19 -5
  17. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3b_native_context.py +6 -6
  18. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_c1_mcp.py +1 -1
  19. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_c3_packaging.py +1 -1
  20. {memleaf-0.2.8 → memleaf-0.2.10}/IMPLEMENTATION_PLAN.md +0 -0
  21. {memleaf-0.2.8 → memleaf-0.2.10}/LICENSE +0 -0
  22. {memleaf-0.2.8 → memleaf-0.2.10}/MANIFEST.in +0 -0
  23. {memleaf-0.2.8 → memleaf-0.2.10}/RELEASE_CHECKLIST.md +0 -0
  24. {memleaf-0.2.8 → memleaf-0.2.10}/examples/README.md +0 -0
  25. {memleaf-0.2.8 → memleaf-0.2.10}/examples/basic_usage.py +0 -0
  26. {memleaf-0.2.8 → memleaf-0.2.10}/examples/mcp_stdio.ndjson +0 -0
  27. {memleaf-0.2.8 → memleaf-0.2.10}/install.ps1 +0 -0
  28. {memleaf-0.2.8 → memleaf-0.2.10}/install.sh +0 -0
  29. {memleaf-0.2.8 → memleaf-0.2.10}/setup.cfg +0 -0
  30. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/__main__.py +0 -0
  31. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/__init__.py +0 -0
  32. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/antigravity.py +0 -0
  33. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/base.py +0 -0
  34. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/codex.py +0 -0
  35. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/adapters/hermes.py +0 -0
  36. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/budget.py +0 -0
  37. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/capture.py +0 -0
  38. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/cli.py +0 -0
  39. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/compaction.py +0 -0
  40. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/config.py +0 -0
  41. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/credentials.py +0 -0
  42. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/frontmatter.py +0 -0
  43. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/hermes_provider/README.md +0 -0
  44. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/host_events.py +0 -0
  45. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/host_runtime.py +0 -0
  46. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/inbox.py +0 -0
  47. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/index.py +0 -0
  48. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/installer.py +0 -0
  49. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/__init__.py +0 -0
  50. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/base.py +0 -0
  51. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/claude_compatible.py +0 -0
  52. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/gemini.py +0 -0
  53. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/openai_compatible.py +0 -0
  54. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/llm/router.py +0 -0
  55. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/locking.py +0 -0
  56. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/mcp_server.py +0 -0
  57. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/memory_writer.py +0 -0
  58. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/model_discovery.py +0 -0
  59. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/models.py +0 -0
  60. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/native_index.py +0 -0
  61. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/redaction.py +0 -0
  62. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/retrieval.py +0 -0
  63. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/retrieval_gate.py +0 -0
  64. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/scope_maintenance.py +0 -0
  65. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/scope_state.py +0 -0
  66. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/service.py +0 -0
  67. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf/vault.py +0 -0
  68. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/SOURCES.txt +0 -0
  69. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/dependency_links.txt +0 -0
  70. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/entry_points.txt +0 -0
  71. {memleaf-0.2.8 → memleaf-0.2.10}/src/memleaf.egg-info/top_level.txt +0 -0
  72. {memleaf-0.2.8 → memleaf-0.2.10}/tests/__init__.py +0 -0
  73. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_admission_noise.py +0 -0
  74. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_codex_install.py +0 -0
  75. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_codex_native_cli.py +0 -0
  76. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_context_budget.py +0 -0
  77. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_credential_safety.py +0 -0
  78. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_cross_host_acceptance.py +0 -0
  79. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_hermes_provider.py +0 -0
  80. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_hermes_stdio_transport.py +0 -0
  81. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_host_events.py +0 -0
  82. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_host_runtime_contract.py +0 -0
  83. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_install.py +0 -0
  84. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_model_discovery.py +0 -0
  85. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_retrieval_gate.py +0 -0
  86. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_retrieval_v2.py +0 -0
  87. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_session_lineage.py +0 -0
  88. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_a.py +0 -0
  89. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b1.py +0 -0
  90. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3a_commit.py +0 -0
  91. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3a_contract.py +0 -0
  92. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.8 → memleaf-0.2.10}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,29 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.10 — 2026-09-01
6
+
7
+ - Keep strict candidate-local project Scope grounding for the first two gate
8
+ attempts, then isolate a persistently invalid candidate instead of failing
9
+ the entire mailbox turn.
10
+ - Correct a final-attempt model Scope only when the candidate text identifies
11
+ exactly one registered project name or alias; defer zero-match and
12
+ multi-project candidates as insufficient context without writing memory.
13
+ - Preserve valid sibling candidates, new explicitly named project Scopes, and
14
+ the existing update-target relevance guard when Scope and target errors occur
15
+ together.
16
+
17
+ ## 0.2.9 — 2026-09-01
18
+
19
+ - Reject update and duplicate targets that are relevant only to another item
20
+ in the same aggregate turn, preventing cross-project memory overwrites.
21
+ - Retry an unrelated gate target with an explicit correction, then recover
22
+ deterministically after the bounded final attempt: preserve unrelated
23
+ updates as independent candidates and discard unsafe duplicate claims.
24
+ - Query related memories from each candidate's own complete topic instead of
25
+ the whole mailbox turn, while allowing the remaining valid candidates to be
26
+ processed without holding the retained inbox turn hostage.
27
+
5
28
  ## 0.2.8 — 2026-09-01
6
29
 
7
30
  - 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.10
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.10。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.10 通过 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.10.**
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.10 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.10。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.10 通过 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.10"
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.10"
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.10
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,18 +42,24 @@ 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,
48
49
  )
49
50
  from .redaction import redact_text
50
51
  from .retrieval import candidate_matches_query, filter_by_scope, normalize_term
51
- from .scope_state import ScopeError, normalize_scopes, register_scope_nodes
52
+ from .scope_state import (
53
+ ScopeError,
54
+ normalize_scopes,
55
+ register_scope_nodes,
56
+ )
52
57
  from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_registry_projection
53
58
  from .validation import (
54
59
  MODEL_VALIDATION_DETAILS,
55
60
  ModelOutputError,
56
61
  NO_CHANGE_DECISION,
62
+ _model_scope_grounding_evidence,
57
63
  parse_gate_output,
58
64
  parse_summarize_output,
59
65
  )
@@ -493,6 +499,8 @@ class Processor:
493
499
  return GATE_TYPE_CORRECTION
494
500
  if stage == "gate" and hint == "scope_not_grounded":
495
501
  return SCOPE_GROUNDING_CORRECTION
502
+ if stage == "gate" and hint == "target_not_relevant":
503
+ return TARGET_RELEVANCE_CORRECTION
496
504
  if stage == "summarize" and hint == "scope_drift":
497
505
  return SUMMARY_SCOPE_CORRECTION
498
506
  if hint == "relative_time":
@@ -1419,6 +1427,157 @@ class Processor:
1419
1427
  strict_relevance=True,
1420
1428
  )
1421
1429
 
1430
+ def _target_is_relevant_to_candidate(
1431
+ self,
1432
+ turn: InboxTurn,
1433
+ candidate: Mapping[str, Any],
1434
+ *,
1435
+ scope_directory: Optional[list[dict[str, Any]]] = None,
1436
+ scope_directory_complete: bool = True,
1437
+ ) -> bool:
1438
+ """Check a model-selected target against the candidate topic only.
1439
+
1440
+ The ordinary related-memory prompt may include several independent
1441
+ items from one mailbox turn. Target selection must not inherit that
1442
+ aggregate relevance: only the candidate's own extracted topic may
1443
+ make an active target eligible. A complete scope directory remains a
1444
+ bounded escape hatch for an explicitly indirect user/session scope;
1445
+ it never turns the target into an unconditional priority hit for a
1446
+ direct model-attributed candidate.
1447
+ """
1448
+
1449
+ target = next(
1450
+ (
1451
+ candidate.get(field)
1452
+ for field in ("duplicate_memory_id", "update_memory_id")
1453
+ if isinstance(candidate.get(field), str) and candidate.get(field)
1454
+ ),
1455
+ None,
1456
+ )
1457
+ memory = candidate.get("memory")
1458
+ candidate_scopes = candidate.get("scopes")
1459
+ if (
1460
+ not isinstance(target, str)
1461
+ or not isinstance(memory, str)
1462
+ or not isinstance(candidate_scopes, list)
1463
+ ):
1464
+ return False
1465
+
1466
+ target_key = target.casefold()
1467
+ candidate_scope_source = candidate.get("scope_source")
1468
+
1469
+ # A complete bounded directory is the existing indirect resolution
1470
+ # path. It contains only active IDs from the requested inherited
1471
+ # scope, so preserve its exact-ID selection semantics rather than
1472
+ # forcing a body relevance check that the directory intentionally
1473
+ # cannot provide.
1474
+ if scope_directory is not None:
1475
+ if not scope_directory_complete:
1476
+ return False
1477
+ selected = next(
1478
+ (
1479
+ item
1480
+ for item in scope_directory
1481
+ if isinstance(item, Mapping)
1482
+ and isinstance(item.get("memory_id"), str)
1483
+ and item["memory_id"].casefold() == target_key
1484
+ ),
1485
+ None,
1486
+ )
1487
+ if selected is not None and selected.get("type") == candidate.get("type"):
1488
+ return True
1489
+
1490
+ # Model-attributed, directly named scopes must match the active
1491
+ # target's stable topic title. Matching the whole old body is too
1492
+ # permissive: two different project memories can share implementation
1493
+ # details such as milestones while serving different future uses.
1494
+ if candidate_scope_source == "model":
1495
+ scope_terms: list[str] = []
1496
+ try:
1497
+ scope_config = self.service.vault.config()
1498
+ configured_scopes = scope_config.get("scopes", {})
1499
+ except (OSError, UnicodeError, ValueError, TypeError):
1500
+ scope_config = {}
1501
+ configured_scopes = {}
1502
+ for candidate_scope in candidate_scopes:
1503
+ if not isinstance(candidate_scope, str) or not candidate_scope.startswith("project:"):
1504
+ continue
1505
+ scope_terms.append(candidate_scope.partition(":")[2])
1506
+ metadata = (
1507
+ configured_scopes.get(candidate_scope)
1508
+ if isinstance(configured_scopes, Mapping)
1509
+ else None
1510
+ )
1511
+ aliases = metadata.get("aliases") if isinstance(metadata, Mapping) else None
1512
+ if isinstance(aliases, list):
1513
+ scope_terms.extend(
1514
+ alias for alias in aliases if isinstance(alias, str) and alias
1515
+ )
1516
+
1517
+ # The regression is project-scope drift inside aggregate turns.
1518
+ # Global/domain/portfolio targets keep their established semantic
1519
+ # gate behavior because there is no project identity to remove
1520
+ # before comparing stable topics.
1521
+ if not scope_terms:
1522
+ return True
1523
+
1524
+ visible_turn = " ".join(
1525
+ event.content for event in turn.events if isinstance(event.content, str)
1526
+ )
1527
+ if scope_terms and not any(
1528
+ normalize_term(term) in normalize_term(visible_turn) for term in scope_terms
1529
+ ):
1530
+ # The visible turn is elliptical (for example, "this
1531
+ # project"); the inherited scope and bounded related context
1532
+ # already resolved its target.
1533
+ return True
1534
+
1535
+ target_memory = None
1536
+ with self.service.vault.lock():
1537
+ for record in self.service._read_memories_unlocked("knowledge"):
1538
+ if record.memory.memory_id.casefold() == target_key:
1539
+ target_memory = record.memory
1540
+ break
1541
+ if target_memory is None:
1542
+ for item in self._planned_related:
1543
+ if (
1544
+ isinstance(item, Mapping)
1545
+ and isinstance(item.get("memory_id"), str)
1546
+ and item["memory_id"].casefold() == target_key
1547
+ ):
1548
+ try:
1549
+ target_memory = Memory.from_mapping(item)
1550
+ except (TypeError, ValueError):
1551
+ target_memory = None
1552
+ break
1553
+ if target_memory is None:
1554
+ return False
1555
+ if not filter_by_scope([target_memory], candidate_scopes, scope_config):
1556
+ return False
1557
+
1558
+ def without_scope_terms(value: str) -> str:
1559
+ result = value
1560
+ for term in sorted(set(scope_terms), key=len, reverse=True):
1561
+ result = re.sub(re.escape(term), "", result, flags=re.IGNORECASE)
1562
+ return result.strip()
1563
+
1564
+ topic_title = without_scope_terms(target_memory.title)
1565
+ topic_query = without_scope_terms(memory)
1566
+ if not topic_title or not topic_query:
1567
+ return False
1568
+ topic_memory = Memory(
1569
+ memory_id=target_memory.memory_id,
1570
+ title=topic_query,
1571
+ body="",
1572
+ type=target_memory.type,
1573
+ scopes=target_memory.scopes,
1574
+ )
1575
+ return candidate_matches_query(topic_memory, topic_title)
1576
+
1577
+ # User/session scopes are authoritative indirect context. The base
1578
+ # parser has already limited the target to an active related memory.
1579
+ return candidate_scope_source in {"user", "session_context"}
1580
+
1422
1581
  def _defer_candidate(
1423
1582
  self,
1424
1583
  turn_ref: tuple[str, str, str],
@@ -1643,6 +1802,104 @@ class Processor:
1643
1802
  list(summary["scopes"]),
1644
1803
  )
1645
1804
 
1805
+ gate_attempt_count = 0
1806
+
1807
+ def parse_gate(raw: str) -> dict[str, Any]:
1808
+ nonlocal gate_attempt_count
1809
+ gate_attempt_count += 1
1810
+ parsed = parse_gate_output(
1811
+ raw,
1812
+ current_event_keys=turn.event_keys,
1813
+ related_memory_ids=gate_related_memory_ids,
1814
+ related_memory_types=gate_related_memory_types,
1815
+ scope_registry=validation_scope_registry,
1816
+ enforce_model_scope_grounding=gate_attempt_count < 3,
1817
+ )
1818
+ if gate_attempt_count >= 3:
1819
+ candidates = []
1820
+ for candidate in parsed["candidates"]:
1821
+ candidate_scopes = [
1822
+ item
1823
+ for item in candidate.get("scopes", [])
1824
+ if isinstance(item, str)
1825
+ ]
1826
+ if (
1827
+ candidate.get("scope_source") == "model"
1828
+ and candidate.get("worth") is True
1829
+ and any(item.partition(":")[0] == "project" for item in candidate_scopes)
1830
+ ):
1831
+ selected_owners, matches = _model_scope_grounding_evidence(
1832
+ str(candidate.get("memory", "")),
1833
+ candidate_scopes,
1834
+ validation_scope_registry,
1835
+ )
1836
+ candidate = dict(candidate)
1837
+ if not selected_owners:
1838
+ candidates.append(candidate)
1839
+ continue
1840
+ if len(matches) == 1:
1841
+ grounded_scope = next(iter(matches.values()))
1842
+ if grounded_scope.casefold() not in set(selected_owners.values()):
1843
+ non_project_scopes = [
1844
+ item
1845
+ for item in candidate_scopes
1846
+ if item.partition(":")[0] != "project"
1847
+ ]
1848
+ if all(
1849
+ item.casefold() != grounded_scope.casefold()
1850
+ for item in non_project_scopes
1851
+ ):
1852
+ non_project_scopes.append(grounded_scope)
1853
+ candidate["scopes"] = non_project_scopes
1854
+ else:
1855
+ candidate["scopes"] = ["unscoped"]
1856
+ candidate["scope_source"] = "insufficient_context"
1857
+ candidates.append(candidate)
1858
+ parsed = dict(parsed)
1859
+ parsed["candidates"] = candidates
1860
+
1861
+ invalid_targets: dict[str, set[str]] = {}
1862
+ for candidate in parsed["candidates"]:
1863
+ target_fields = {
1864
+ field
1865
+ for field in ("duplicate_memory_id", "update_memory_id")
1866
+ if isinstance(candidate.get(field), str) and candidate.get(field)
1867
+ }
1868
+ if target_fields and not self._target_is_relevant_to_candidate(
1869
+ turn,
1870
+ candidate,
1871
+ scope_directory=scope_directory,
1872
+ scope_directory_complete=scope_directory_complete,
1873
+ ):
1874
+ invalid_targets[candidate["candidate_id"].casefold()] = target_fields
1875
+
1876
+ if invalid_targets and gate_attempt_count < 3:
1877
+ raise ModelOutputError(
1878
+ "selected target is not relevant to the candidate topic",
1879
+ validation_detail="target_not_relevant",
1880
+ )
1881
+
1882
+ if invalid_targets:
1883
+ # A persistently non-converging model must not hold an entire
1884
+ # inbox turn hostage. An unrelated update target can safely
1885
+ # become an independent CREATE candidate; an unrelated
1886
+ # duplicate has no independent fact to write and is dropped.
1887
+ candidates: list[dict[str, Any]] = []
1888
+ for candidate in parsed["candidates"]:
1889
+ fields = invalid_targets.get(candidate["candidate_id"].casefold())
1890
+ if not fields:
1891
+ candidates.append(candidate)
1892
+ continue
1893
+ if "duplicate_memory_id" in fields:
1894
+ continue
1895
+ if "update_memory_id" in fields and candidate.get("worth"):
1896
+ independent = dict(candidate)
1897
+ independent.pop("update_memory_id", None)
1898
+ candidates.append(independent)
1899
+ parsed = dict(parsed)
1900
+ parsed["candidates"] = candidates
1901
+ return parsed
1902
+
1646
1903
  gate = self._complete_json_stage(
1647
1904
  backend,
1648
1905
  gate_prompt(
@@ -1655,13 +1912,7 @@ class Processor:
1655
1912
  ),
1656
1913
  system=GATE_SYSTEM,
1657
1914
  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
- ),
1915
+ parser=parse_gate,
1665
1916
  diagnostic_context={
1666
1917
  "source": turn.source,
1667
1918
  "session_id": turn.session_id,
@@ -1710,15 +1961,7 @@ class Processor:
1710
1961
  candidate_related, candidate_scope_background, candidate_native_refs, _ = self._related_query(
1711
1962
  turn,
1712
1963
  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(),
1964
+ str(candidate.get("memory", "")).strip(),
1722
1965
  candidate_scopes,
1723
1966
  overlay=self._planned_related,
1724
1967
  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",
@@ -257,17 +258,16 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
257
258
  )
258
259
 
259
260
 
260
- def _reject_ungrounded_project_scope(
261
+ def _model_scope_grounding_evidence(
261
262
  memory: str,
262
263
  scopes: Iterable[str],
263
264
  scope_registry: Mapping[str, Any] | None,
264
- ) -> None:
265
- """Require model-attributed project scopes to be named by this candidate.
265
+ ) -> tuple[dict[str, str], dict[str, str]]:
266
+ """Return model scope ownership and memory-grounded matches.
266
267
 
267
- The gate's memory text is the only candidate-local attribution evidence.
268
- Session background, other events, and related-memory bodies are
269
- intentionally excluded so an aggregate mailbox turn cannot lend a
270
- project name to an unrelated candidate.
268
+ Returns a tuple of:
269
+ - selected project scopes -> selected owner scope (casefold)
270
+ - grounded project scope matches from candidate memory (casefold -> canonical)
271
271
  """
272
272
 
273
273
  selected = {
@@ -276,16 +276,14 @@ def _reject_ungrounded_project_scope(
276
276
  if isinstance(scope, str) and scope.partition(":")[0] == "project"
277
277
  }
278
278
  if not selected:
279
- return
279
+ return {}, {}
280
280
  try:
281
281
  registry = validate_scope_registry(scope_registry or {})
282
282
  except ScopeError as error:
283
283
  raise ModelOutputError("invalid scope registry", validation_detail="invalid_scope") from error
284
284
 
285
- # Resolve a selected project key through a registered canonical scope when
286
- # it is an alias (including the full ``project:...`` spelling). A merged
287
- # source may remain selectable as an alias of its target; treating both as
288
- # separate projects would incorrectly reject that otherwise valid update.
285
+ # Resolve each selected project scope to its canonical owner when it is an
286
+ # alias of a registered project scope.
289
287
  selected_owners: dict[str, str] = {}
290
288
  for selected_scope in selected.values():
291
289
  selected_owner = selected_scope
@@ -310,19 +308,42 @@ def _reject_ungrounded_project_scope(
310
308
  selected_owners[selected_scope.casefold()] = selected_owner.casefold()
311
309
 
312
310
  # Include unregistered selections as temporary nodes: new scopes must be
313
- # grounded by their own project name, while registered scopes also accept
314
- # their configured aliases. All known projects are checked to reject an
315
- # otherwise ambiguous candidate that names multiple projects.
311
+ # grounded by their own project name. All known projects are still checked
312
+ # to reject ambiguous memory wording.
316
313
  candidate_registry = dict(registry)
317
- for scope in selected.values():
318
- owner = selected_owners[scope.casefold()]
314
+ for selected_scope in selected.values():
315
+ owner = selected_owners[selected_scope.casefold()]
319
316
  if owner not in {registered.casefold() for registered in registry}:
320
- candidate_registry.setdefault(scope, {})
317
+ candidate_registry.setdefault(selected_scope, {})
318
+
321
319
  matches = {
322
- scope.casefold()
320
+ scope.casefold(): scope
323
321
  for scope in project_scope_matches_text(memory, {"scopes": candidate_registry})
324
322
  }
325
- if len(matches) != 1 or not matches.intersection(selected_owners.values()):
323
+ return selected_owners, matches
324
+
325
+
326
+ def _reject_ungrounded_project_scope(
327
+ memory: str,
328
+ scopes: Iterable[str],
329
+ scope_registry: Mapping[str, Any] | None,
330
+ ) -> None:
331
+ """Require model-attributed project scopes to be named by this candidate.
332
+
333
+ The gate's memory text is the only candidate-local attribution evidence.
334
+ Session background, other events, and related-memory bodies are
335
+ intentionally excluded so an aggregate mailbox turn cannot lend a
336
+ project name to an unrelated candidate.
337
+ """
338
+
339
+ selected_owners, matches = _model_scope_grounding_evidence(
340
+ memory,
341
+ scopes,
342
+ scope_registry,
343
+ )
344
+ if not selected_owners:
345
+ return
346
+ if len(matches) != 1 or matches.keys().isdisjoint(set(selected_owners.values())):
326
347
  raise ModelOutputError(
327
348
  "model project scope is not grounded by this candidate",
328
349
  validation_detail="scope_not_grounded",
@@ -501,6 +522,7 @@ def validate_gate_output(
501
522
  related_memory_ids: Iterable[Any] | None = None,
502
523
  related_memory_types: Mapping[str, Any] | None = None,
503
524
  scope_registry: Mapping[str, Any] | None = None,
525
+ enforce_model_scope_grounding: bool = True,
504
526
  ) -> dict[str, Any]:
505
527
  """Validate and return a normalized gate object without writing anything."""
506
528
 
@@ -641,7 +663,8 @@ def validate_gate_output(
641
663
  if item["worth"]:
642
664
  _reject_mixed_project_scopes(item["scopes"])
643
665
  if item["scope_source"] == "model":
644
- _reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
666
+ if enforce_model_scope_grounding:
667
+ _reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
645
668
  if "reason" in item:
646
669
  _string(item["reason"], "reason", nonempty=False)
647
670
  if len(item["reason"]) > 30:
@@ -659,6 +682,7 @@ def parse_gate_output(
659
682
  related_memory_ids: Iterable[Any] | None = None,
660
683
  related_memory_types: Mapping[str, Any] | None = None,
661
684
  scope_registry: Mapping[str, Any] | None = None,
685
+ enforce_model_scope_grounding: bool = True,
662
686
  ) -> dict[str, Any]:
663
687
  try:
664
688
  parsed = parse_strict_json(raw)
@@ -674,6 +698,7 @@ def parse_gate_output(
674
698
  related_memory_ids=related_memory_ids,
675
699
  related_memory_types=related_memory_types,
676
700
  scope_registry=scope_registry,
701
+ enforce_model_scope_grounding=enforce_model_scope_grounding,
677
702
  )
678
703
  except ModelOutputError as error:
679
704
  if error.validation_reason is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.8
3
+ Version: 0.2.10
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.10。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.10 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位