memleaf 0.2.7__tar.gz → 0.2.8__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.7 → memleaf-0.2.8}/CHANGELOG.md +13 -0
  2. {memleaf-0.2.7/src/memleaf.egg-info → memleaf-0.2.8}/PKG-INFO +3 -3
  3. {memleaf-0.2.7 → memleaf-0.2.8}/README.md +2 -2
  4. {memleaf-0.2.7 → memleaf-0.2.8}/pyproject.toml +1 -1
  5. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/hermes_provider/__init__.py +2 -0
  7. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/processing.py +42 -10
  9. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/prompts.py +51 -0
  10. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/scope_state.py +14 -2
  11. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/validation.py +120 -2
  12. {memleaf-0.2.7 → memleaf-0.2.8/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_admission_noise.py +9 -11
  14. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_cross_host_acceptance.py +4 -1
  15. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_hermes_provider.py +19 -0
  16. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_maintenance_v2.py +8 -6
  17. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_pypi_install.py +1 -1
  18. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_session_lineage.py +2 -2
  19. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b1.py +143 -3
  20. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b2a.py +258 -16
  21. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3b_scope.py +29 -8
  22. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3d_scope_maintenance.py +11 -2
  23. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_c1_mcp.py +1 -1
  24. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_c3_packaging.py +1 -1
  25. {memleaf-0.2.7 → memleaf-0.2.8}/IMPLEMENTATION_PLAN.md +0 -0
  26. {memleaf-0.2.7 → memleaf-0.2.8}/LICENSE +0 -0
  27. {memleaf-0.2.7 → memleaf-0.2.8}/MANIFEST.in +0 -0
  28. {memleaf-0.2.7 → memleaf-0.2.8}/README.en.md +0 -0
  29. {memleaf-0.2.7 → memleaf-0.2.8}/RELEASE_CHECKLIST.md +0 -0
  30. {memleaf-0.2.7 → memleaf-0.2.8}/examples/README.md +0 -0
  31. {memleaf-0.2.7 → memleaf-0.2.8}/examples/basic_usage.py +0 -0
  32. {memleaf-0.2.7 → memleaf-0.2.8}/examples/mcp_stdio.ndjson +0 -0
  33. {memleaf-0.2.7 → memleaf-0.2.8}/install.ps1 +0 -0
  34. {memleaf-0.2.7 → memleaf-0.2.8}/install.sh +0 -0
  35. {memleaf-0.2.7 → memleaf-0.2.8}/setup.cfg +0 -0
  36. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/__main__.py +0 -0
  37. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/__init__.py +0 -0
  38. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/antigravity.py +0 -0
  39. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/base.py +0 -0
  40. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/codex.py +0 -0
  41. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/adapters/hermes.py +0 -0
  42. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/budget.py +0 -0
  43. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/capture.py +0 -0
  44. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/cli.py +0 -0
  45. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/compaction.py +0 -0
  46. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/config.py +0 -0
  47. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/credentials.py +0 -0
  48. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/frontmatter.py +0 -0
  49. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/hermes_provider/README.md +0 -0
  50. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/host_events.py +0 -0
  51. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/host_runtime.py +0 -0
  52. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/inbox.py +0 -0
  53. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/index.py +0 -0
  54. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/installer.py +0 -0
  55. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/__init__.py +0 -0
  56. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/base.py +0 -0
  57. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/claude_compatible.py +0 -0
  58. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/gemini.py +0 -0
  59. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/openai_compatible.py +0 -0
  60. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/llm/router.py +0 -0
  61. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/locking.py +0 -0
  62. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/mcp_server.py +0 -0
  63. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/memory_writer.py +0 -0
  64. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/model_discovery.py +0 -0
  65. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/models.py +0 -0
  66. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/native_index.py +0 -0
  67. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/redaction.py +0 -0
  68. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/retrieval.py +0 -0
  69. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/retrieval_gate.py +0 -0
  70. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/scope_maintenance.py +0 -0
  71. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/service.py +0 -0
  72. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf/vault.py +0 -0
  73. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/SOURCES.txt +0 -0
  74. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/dependency_links.txt +0 -0
  75. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/entry_points.txt +0 -0
  76. {memleaf-0.2.7 → memleaf-0.2.8}/src/memleaf.egg-info/top_level.txt +0 -0
  77. {memleaf-0.2.7 → memleaf-0.2.8}/tests/__init__.py +0 -0
  78. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_codex_install.py +0 -0
  79. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_codex_native_cli.py +0 -0
  80. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_context_budget.py +0 -0
  81. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_credential_safety.py +0 -0
  82. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_hermes_stdio_transport.py +0 -0
  83. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_host_events.py +0 -0
  84. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_host_runtime_contract.py +0 -0
  85. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_install.py +0 -0
  86. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_model_discovery.py +0 -0
  87. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_retrieval_gate.py +0 -0
  88. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_retrieval_v2.py +0 -0
  89. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_a.py +0 -0
  90. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b2b.py +0 -0
  91. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3a_commit.py +0 -0
  92. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3a_contract.py +0 -0
  93. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3b_native_context.py +0 -0
  94. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3b_native_index.py +0 -0
  95. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_b3c_retrieval.py +0 -0
  96. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.7 → memleaf-0.2.8}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.8 — 2026-09-01
6
+
7
+ - Let the automatic summarizer independently reject an over-admitted gate
8
+ candidate with an exact `NO_CHANGE` response, without writing memory,
9
+ history, or Scope state; explicit remember remains write-oriented and does
10
+ not permit this response.
11
+ - Ground every model-attributed project Scope in the candidate's own project
12
+ name or registered alias, and prevent the summarizer from drifting away
13
+ from the gate-selected Scope.
14
+ - Register session and Vault Scopes only for committed memories or trusted
15
+ active duplicate targets, so temporary, deferred, and no-change mailbox
16
+ items cannot pollute Scope state.
17
+
5
18
  ## 0.2.7 — 2026-09-01
6
19
 
7
20
  - Distinguish an invalid gate type enum from a candidate type that conflicts
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.7
3
+ Version: 0.2.8
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.7。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 PyPI 分发。
26
+ > **当前版本:0.2.8。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 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.7。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 PyPI 分发。
7
+ > **当前版本:0.2.8。**
8
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 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.7"
7
+ version = "0.2.8"
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.7"
3
+ __version__ = "0.2.8"
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,8 @@ _MODEL_VALIDATION_DETAILS = frozenset(
78
78
  "duplicate_update_target",
79
79
  "mixed_project_scopes",
80
80
  "update_target_type_mismatch",
81
+ "scope_not_grounded",
82
+ "scope_drift",
81
83
  "invalid_evidence",
82
84
  "invalid_flags",
83
85
  "invalid_type",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.7
2
+ version: 0.2.8
3
3
  description: "Hermes-native external MemoryProvider for local-first Markdown memory shared with the memleaf MCP server."
4
4
  hooks:
5
5
  - prefetch
@@ -37,6 +37,8 @@ from .prompts import (
37
37
  MIXED_PROJECT_SCOPES_CORRECTION,
38
38
  RELATIVE_TIME_CORRECTION,
39
39
  GATE_SYSTEM,
40
+ SCOPE_GROUNDING_CORRECTION,
41
+ SUMMARY_SCOPE_CORRECTION,
40
42
  SUMMARY_TARGET_CORRECTION,
41
43
  SUMMARY_TYPE_CORRECTION,
42
44
  SUMMARIZE_SYSTEM,
@@ -51,6 +53,7 @@ from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_reg
51
53
  from .validation import (
52
54
  MODEL_VALIDATION_DETAILS,
53
55
  ModelOutputError,
56
+ NO_CHANGE_DECISION,
54
57
  parse_gate_output,
55
58
  parse_summarize_output,
56
59
  )
@@ -488,6 +491,10 @@ class Processor:
488
491
  return UPDATE_TARGET_TYPE_CORRECTION
489
492
  if stage == "gate" and hint == "invalid_type":
490
493
  return GATE_TYPE_CORRECTION
494
+ if stage == "gate" and hint == "scope_not_grounded":
495
+ return SCOPE_GROUNDING_CORRECTION
496
+ if stage == "summarize" and hint == "scope_drift":
497
+ return SUMMARY_SCOPE_CORRECTION
491
498
  if hint == "relative_time":
492
499
  return RELATIVE_TIME_CORRECTION
493
500
  if stage == "summarize" and hint == "invalid_update_target":
@@ -1614,6 +1621,7 @@ class Processor:
1614
1621
  related_native_ids=related_native_ids,
1615
1622
  related_memory_ids=related_memory_ids,
1616
1623
  scope_registry=validation_scope_registry,
1624
+ allow_no_change=False,
1617
1625
  ),
1618
1626
  diagnostic_context={
1619
1627
  "source": turn.source,
@@ -1652,6 +1660,7 @@ class Processor:
1652
1660
  current_event_keys=turn.event_keys,
1653
1661
  related_memory_ids=gate_related_memory_ids,
1654
1662
  related_memory_types=gate_related_memory_types,
1663
+ scope_registry=validation_scope_registry,
1655
1664
  ),
1656
1665
  diagnostic_context={
1657
1666
  "source": turn.source,
@@ -1663,13 +1672,6 @@ class Processor:
1663
1672
  observed_scopes: list[str] = []
1664
1673
  for candidate in gate["candidates"]:
1665
1674
  candidate_scopes = list(candidate["scopes"])
1666
- if (
1667
- candidate_scopes != ["unscoped"]
1668
- and candidate.get("scope_source") != "insufficient_context"
1669
- ):
1670
- for observed_scope in candidate_scopes:
1671
- if observed_scope not in observed_scopes:
1672
- observed_scopes.append(observed_scope)
1673
1675
  # An automatic candidate with no reliable project attribution is
1674
1676
  # retained as a retryable inbox turn, never silently promoted to
1675
1677
  # global knowledge. The processed ledger records only a compact
@@ -1758,6 +1760,27 @@ class Processor:
1758
1760
  native_refs=candidate_native_refs,
1759
1761
  )
1760
1762
  )
1763
+ # Automatic duplicate observations are metadata no-ops;
1764
+ # only the already-active target's scopes are trustworthy
1765
+ # session context, never a transient model-provided scope.
1766
+ duplicate_scopes = next(
1767
+ (
1768
+ item.get("scopes")
1769
+ for item in candidate_related
1770
+ if isinstance(item, Mapping)
1771
+ and isinstance(item.get("memory_id"), str)
1772
+ and item["memory_id"].casefold() == duplicate_memory_id.casefold()
1773
+ and isinstance(item.get("scopes"), list)
1774
+ ),
1775
+ [],
1776
+ )
1777
+ for observed_scope in duplicate_scopes:
1778
+ if (
1779
+ isinstance(observed_scope, str)
1780
+ and observed_scope != "unscoped"
1781
+ and observed_scope not in observed_scopes
1782
+ ):
1783
+ observed_scopes.append(observed_scope)
1761
1784
  continue
1762
1785
 
1763
1786
  gate_update_target = candidate.get("update_memory_id")
@@ -1793,6 +1816,9 @@ class Processor:
1793
1816
  related_native_ids=candidate_native_ids,
1794
1817
  related_memory_ids=candidate_memory_ids,
1795
1818
  scope_registry=validation_scope_registry,
1819
+ expected_scopes=candidate["scopes"],
1820
+ expected_scope_source=candidate["scope_source"],
1821
+ allow_no_change=True,
1796
1822
  expected_type=(
1797
1823
  candidate.get("type")
1798
1824
  if (
@@ -1814,6 +1840,8 @@ class Processor:
1814
1840
  "turn_index": turn.turn_index,
1815
1841
  },
1816
1842
  )
1843
+ if summary.get("decision") == NO_CHANGE_DECISION:
1844
+ continue
1817
1845
  if gate_update_target is not None:
1818
1846
  summary_update_target = summary.get("update_memory_id")
1819
1847
  if summary_update_target is None:
@@ -1839,9 +1867,6 @@ class Processor:
1839
1867
  scope_source=summary.get("scope_source"),
1840
1868
  )
1841
1869
  continue
1842
- for observed_scope in summary["scopes"]:
1843
- if observed_scope not in observed_scopes:
1844
- observed_scopes.append(observed_scope)
1845
1870
  requests.append(
1846
1871
  self._request(
1847
1872
  summary,
@@ -1851,6 +1876,13 @@ class Processor:
1851
1876
  native_refs=candidate_native_refs,
1852
1877
  )
1853
1878
  )
1879
+ for observed_scope in summary["scopes"]:
1880
+ if (
1881
+ isinstance(observed_scope, str)
1882
+ and observed_scope != "unscoped"
1883
+ and observed_scope not in observed_scopes
1884
+ ):
1885
+ observed_scopes.append(observed_scope)
1854
1886
  return requests, observed_scopes
1855
1887
 
1856
1888
  def _state_for_snapshot_unlocked(self, snapshot: _Snapshot, processed: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -34,6 +34,15 @@ 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
+ For a worthy candidate with scope_source=model, each selected project scope must
38
+ be grounded by this candidate's own memory text: use the project's registered
39
+ scope name or one of its registered aliases; for a new unregistered project
40
+ scope, use that project's name itself. Do not borrow a project name from another
41
+ event, the session background, related memories, or an aggregate mailbox turn.
42
+ If the candidate text does not identify exactly one selected project, choose an
43
+ evidence-supported project, use unscoped with insufficient_context, defer it, or
44
+ set worth=false. user and session_context scope sources may rely on their
45
+ authoritative scope context.
37
46
  An update target's existing type is immutable: when the same future use is being
38
47
  updated, candidate type must exactly equal the related active target type. If a
39
48
  fact serves a different future use, do not force it onto that target; omit
@@ -156,6 +165,16 @@ When the gate candidate supplies update_memory_id, that target is immutable:
156
165
  omit update_memory_id or copy that exact active ID, never replace it with a
157
166
  different, guessed, native, or historical ID. Keep the summary type identical
158
167
  to the gate candidate type and to the active update target type.
168
+ For automatic capture/process, independently re-check whether this candidate
169
+ still has a concrete future-use fact or action. If not, return exactly
170
+ {"decision":"NO_CHANGE"}; this is the only no-write summary response. Use it
171
+ for temporary delegation, waiting for feedback, a one-off meeting arrangement with
172
+ no durable outcome/decision/project constraint, or a subject/attachment-only item
173
+ without a concrete impact or conclusion.
174
+ Explicit remember mode never permits NO_CHANGE and must return the normal summary
175
+ object. When producing a normal automatic summary, copy the gate candidate's
176
+ scopes exactly. scope_source may be omitted to inherit the gate value, but an
177
+ explicit value must match it; never drift to another project.
159
178
  For the same use, preserve still-valid facts, add confirmed progress, replace
160
179
  contradictions, and never reduce the memory to the latest operation or create a
161
180
  sibling with a new label.
@@ -306,6 +325,28 @@ SUMMARY_TYPE_CORRECTION = (
306
325
  )
307
326
 
308
327
 
328
+ SUMMARY_SCOPE_CORRECTION = (
329
+ "Previous output violated: scope_drift. For a normal automatic summary, copy "
330
+ "the gate candidate scopes exactly and keep its scope_source unchanged. If "
331
+ "scope_source was omitted, inherit the gate value; an explicit value must "
332
+ "match it. Do not move the memory to another project. If the candidate has "
333
+ "no independent future-use fact or action, return exactly "
334
+ '{"decision":"NO_CHANGE"}. Return only the strict JSON object.'
335
+ )
336
+
337
+
338
+ SCOPE_GROUNDING_CORRECTION = (
339
+ "Previous output violated: scope_not_grounded. For each worthy candidate "
340
+ "with scope_source=model, choose a project scope named by that candidate's "
341
+ "own memory text: use the registered project name or alias, or the name "
342
+ "itself for a new scope. Do not borrow a name from another event, related "
343
+ "memory, session background, or an aggregate mailbox turn. If exactly one "
344
+ "project cannot be supported, choose the evidence-supported scope, use "
345
+ "unscoped with insufficient_context, defer it, or set worth=false. Return "
346
+ "only the strict gate JSON object."
347
+ )
348
+
349
+
309
350
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
310
351
  Merge only the supplied low-priority memories when they express compatible
311
352
  information. Return an object with a memories array; [] is a safe no-op.
@@ -398,6 +439,14 @@ def summarize_prompt(
398
439
  f"Current scope registry (safe projection; no paths):\n"
399
440
  f"{_json(scope_registry if scope_registry is not None else [])}\n"
400
441
  )
442
+ if not explicit:
443
+ prompt += (
444
+ "Automatic admission re-check: if the candidate has no independent "
445
+ "future-use fact or action after reviewing the evidence, return exactly "
446
+ '{"decision":"NO_CHANGE"}; do not return an empty or partial memory object. '
447
+ "For a normal summary, copy Candidate scopes exactly; omit scope_source "
448
+ "to inherit it or repeat the same value, never choose another scope.\n"
449
+ )
401
450
  example_key = _first_event_key(events)
402
451
  if example_key is not None:
403
452
  candidate_type = candidate.get("type") if isinstance(candidate, dict) else None
@@ -431,6 +480,8 @@ def summarize_prompt(
431
480
  }
432
481
  )
433
482
  )
483
+ if not explicit:
484
+ return prompt + '\nReturn one summary JSON object or exactly {"decision":"NO_CHANGE"}.'
434
485
  return prompt + "\nReturn one summary JSON object."
435
486
 
436
487
 
@@ -256,9 +256,20 @@ def _term_matches(query: str, term: str) -> bool:
256
256
  def resolve_query_project_scope(query: Any, config: Mapping[str, Any]) -> str | None:
257
257
  """Return one unambiguous project scope named or aliased by ``query``."""
258
258
 
259
+ matches = project_scope_matches_text(query, config)
260
+ return matches[0] if len(matches) == 1 else None
261
+
262
+
263
+ def project_scope_matches_text(query: Any, config: Mapping[str, Any]) -> list[str]:
264
+ """Return registered project scopes named or aliased by text.
265
+
266
+ Matching uses the same token/substring rules as query scope resolution,
267
+ but retains every match so callers can reject ambiguous model attribution.
268
+ """
269
+
259
270
  query_value = _query_text(query)
260
271
  if not query_value:
261
- return None
272
+ return []
262
273
  registry = validate_scope_registry(config.get("scopes", {}) if isinstance(config, Mapping) else {})
263
274
  matches: set[str] = set()
264
275
  for scope, node in registry.items():
@@ -271,7 +282,7 @@ def resolve_query_project_scope(query: Any, config: Mapping[str, Any]) -> str |
271
282
  terms.extend(item for item in aliases if isinstance(item, str))
272
283
  if any(_term_matches(query_value, term) for term in terms):
273
284
  matches.add(scope)
274
- return next(iter(matches)) if len(matches) == 1 else None
285
+ return sorted(matches, key=str.casefold)
275
286
 
276
287
 
277
288
  def _resolved_path(value: str | Path, *, base_dir: Path | None = None) -> Path:
@@ -320,6 +331,7 @@ def resolve_project_path_scope(
320
331
  __all__ = [
321
332
  "ScopeError",
322
333
  "normalize_scopes",
334
+ "project_scope_matches_text",
323
335
  "register_scope_nodes",
324
336
  "resolve_project_path_scope",
325
337
  "resolve_query_project_scope",
@@ -6,7 +6,12 @@ import json
6
6
  import re
7
7
  from typing import Any, Iterable, Mapping
8
8
 
9
- from .scope_state import ScopeError, validate_scope_key, validate_scope_registry
9
+ from .scope_state import (
10
+ ScopeError,
11
+ project_scope_matches_text,
12
+ validate_scope_key,
13
+ validate_scope_registry,
14
+ )
10
15
 
11
16
 
12
17
  class ModelOutputError(ValueError):
@@ -41,6 +46,7 @@ class ModelOutputError(ValueError):
41
46
  MEMORY_TYPES = frozenset(("preference", "fact", "project", "todo", "event", "identity", "other"))
42
47
  SCOPE_SOURCES = frozenset(("model", "user", "session_context", "insufficient_context"))
43
48
  TODO_STATUSES = frozenset(("active", "completed", "cancelled"))
49
+ NO_CHANGE_DECISION = "NO_CHANGE"
44
50
  MODEL_VALIDATION_REASONS = frozenset(
45
51
  ("empty_content", "invalid_json", "schema_violation", "response_shape")
46
52
  )
@@ -54,6 +60,8 @@ MODEL_VALIDATION_DETAILS = frozenset(
54
60
  "duplicate_update_target",
55
61
  "mixed_project_scopes",
56
62
  "update_target_type_mismatch",
63
+ "scope_not_grounded",
64
+ "scope_drift",
57
65
  "invalid_evidence",
58
66
  "invalid_flags",
59
67
  "invalid_type",
@@ -249,6 +257,78 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
249
257
  )
250
258
 
251
259
 
260
+ def _reject_ungrounded_project_scope(
261
+ memory: str,
262
+ scopes: Iterable[str],
263
+ scope_registry: Mapping[str, Any] | None,
264
+ ) -> None:
265
+ """Require model-attributed project scopes to be named by this candidate.
266
+
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.
271
+ """
272
+
273
+ selected = {
274
+ scope.casefold(): scope
275
+ for scope in scopes
276
+ if isinstance(scope, str) and scope.partition(":")[0] == "project"
277
+ }
278
+ if not selected:
279
+ return
280
+ try:
281
+ registry = validate_scope_registry(scope_registry or {})
282
+ except ScopeError as error:
283
+ raise ModelOutputError("invalid scope registry", validation_detail="invalid_scope") from error
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.
289
+ selected_owners: dict[str, str] = {}
290
+ for selected_scope in selected.values():
291
+ selected_owner = selected_scope
292
+ for registered_scope, node in registry.items():
293
+ if not registered_scope.startswith("project:"):
294
+ continue
295
+ terms = {
296
+ registered_scope.casefold(),
297
+ registered_scope.split(":", 1)[1].casefold(),
298
+ }
299
+ aliases = node.get("aliases", [])
300
+ if isinstance(aliases, list):
301
+ for alias in aliases:
302
+ if isinstance(alias, str):
303
+ alias_key = alias.casefold()
304
+ terms.add(alias_key)
305
+ if ":" not in alias:
306
+ terms.add(f"project:{alias_key}")
307
+ if selected_scope.casefold() in terms:
308
+ selected_owner = registered_scope
309
+ break
310
+ selected_owners[selected_scope.casefold()] = selected_owner.casefold()
311
+
312
+ # 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.
316
+ candidate_registry = dict(registry)
317
+ for scope in selected.values():
318
+ owner = selected_owners[scope.casefold()]
319
+ if owner not in {registered.casefold() for registered in registry}:
320
+ candidate_registry.setdefault(scope, {})
321
+ matches = {
322
+ scope.casefold()
323
+ for scope in project_scope_matches_text(memory, {"scopes": candidate_registry})
324
+ }
325
+ if len(matches) != 1 or not matches.intersection(selected_owners.values()):
326
+ raise ModelOutputError(
327
+ "model project scope is not grounded by this candidate",
328
+ validation_detail="scope_not_grounded",
329
+ )
330
+
331
+
252
332
  def _memory_id(value: Any, field: str) -> str:
253
333
  result = _string(value, field)
254
334
  if "/" in result or "\\" in result or result in (".", ".."):
@@ -420,6 +500,7 @@ def validate_gate_output(
420
500
  current_event_keys: Iterable[Any] | None = None,
421
501
  related_memory_ids: Iterable[Any] | None = None,
422
502
  related_memory_types: Mapping[str, Any] | None = None,
503
+ scope_registry: Mapping[str, Any] | None = None,
423
504
  ) -> dict[str, Any]:
424
505
  """Validate and return a normalized gate object without writing anything."""
425
506
 
@@ -559,6 +640,8 @@ def validate_gate_output(
559
640
  item["scopes"] = _scopes(item["scopes"], item["scope_source"])
560
641
  if item["worth"]:
561
642
  _reject_mixed_project_scopes(item["scopes"])
643
+ if item["scope_source"] == "model":
644
+ _reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
562
645
  if "reason" in item:
563
646
  _string(item["reason"], "reason", nonempty=False)
564
647
  if len(item["reason"]) > 30:
@@ -575,6 +658,7 @@ def parse_gate_output(
575
658
  current_event_keys: Iterable[Any] | None = None,
576
659
  related_memory_ids: Iterable[Any] | None = None,
577
660
  related_memory_types: Mapping[str, Any] | None = None,
661
+ scope_registry: Mapping[str, Any] | None = None,
578
662
  ) -> dict[str, Any]:
579
663
  try:
580
664
  parsed = parse_strict_json(raw)
@@ -589,6 +673,7 @@ def parse_gate_output(
589
673
  current_event_keys=current_event_keys,
590
674
  related_memory_ids=related_memory_ids,
591
675
  related_memory_types=related_memory_types,
676
+ scope_registry=scope_registry,
592
677
  )
593
678
  except ModelOutputError as error:
594
679
  if error.validation_reason is None:
@@ -637,6 +722,9 @@ def validate_summarize_output(
637
722
  expected_type: str | None = None,
638
723
  expected_update_memory_id: str | None = None,
639
724
  expected_target_type: str | None = None,
725
+ expected_scopes: Iterable[Any] | None = None,
726
+ expected_scope_source: str | None = None,
727
+ allow_no_change: bool = False,
640
728
  ) -> dict[str, Any]:
641
729
  """Validate one atomic memory summary; this function has no filesystem effects."""
642
730
 
@@ -644,6 +732,13 @@ def validate_summarize_output(
644
732
  value = parse_strict_json(value)
645
733
  if not isinstance(value, Mapping):
646
734
  raise ModelOutputError("summarize output must be an object", validation_detail="root_shape")
735
+ if "decision" in value:
736
+ if allow_no_change and dict(value) == {"decision": NO_CHANGE_DECISION}:
737
+ return {"decision": NO_CHANGE_DECISION}
738
+ raise ModelOutputError(
739
+ "NO_CHANGE is only valid as the exact automatic no-write response",
740
+ validation_detail="unknown_fields",
741
+ )
647
742
  allowed = {
648
743
  "memory_id",
649
744
  "update_memory_id",
@@ -681,8 +776,24 @@ def validate_summarize_output(
681
776
  "summary type does not match update target",
682
777
  validation_detail="invalid_type",
683
778
  )
684
- item["scopes"] = _scopes(item["scopes"], item.get("scope_source"))
779
+ summary_scope_source = item.get("scope_source")
780
+ if "scope_source" not in item and expected_scope_source is not None:
781
+ summary_scope_source = expected_scope_source
782
+ item["scope_source"] = summary_scope_source
783
+ item["scopes"] = _scopes(item["scopes"], summary_scope_source)
685
784
  _reject_mixed_project_scopes(item["scopes"])
785
+ if expected_scopes is not None:
786
+ expected_scope_values = list(expected_scopes)
787
+ if item["scopes"] != expected_scope_values:
788
+ raise ModelOutputError(
789
+ "summary scopes differ from gate candidate",
790
+ validation_detail="scope_drift",
791
+ )
792
+ if expected_scope_source is not None and summary_scope_source != expected_scope_source:
793
+ raise ModelOutputError(
794
+ "summary scope source differs from gate candidate",
795
+ validation_detail="scope_drift",
796
+ )
686
797
  item["scope_operations"] = _scope_operations(
687
798
  item.get("scope_operations"),
688
799
  summary_scopes=item["scopes"],
@@ -770,6 +881,9 @@ def parse_summarize_output(
770
881
  expected_type: str | None = None,
771
882
  expected_update_memory_id: str | None = None,
772
883
  expected_target_type: str | None = None,
884
+ expected_scopes: Iterable[Any] | None = None,
885
+ expected_scope_source: str | None = None,
886
+ allow_no_change: bool = False,
773
887
  ) -> dict[str, Any]:
774
888
  try:
775
889
  parsed = parse_strict_json(raw)
@@ -787,6 +901,9 @@ def parse_summarize_output(
787
901
  expected_type=expected_type,
788
902
  expected_update_memory_id=expected_update_memory_id,
789
903
  expected_target_type=expected_target_type,
904
+ expected_scopes=expected_scopes,
905
+ expected_scope_source=expected_scope_source,
906
+ allow_no_change=allow_no_change,
790
907
  )
791
908
  except ModelOutputError as error:
792
909
  if error.validation_reason is None:
@@ -894,6 +1011,7 @@ __all__ = [
894
1011
  "MODEL_VALIDATION_DETAILS",
895
1012
  "MODEL_VALIDATION_REASONS",
896
1013
  "ModelOutputError",
1014
+ "NO_CHANGE_DECISION",
897
1015
  "SCOPE_SOURCES",
898
1016
  "TODO_STATUSES",
899
1017
  "parse_gate",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.7
3
+ Version: 0.2.8
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.7。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.7 通过 PyPI 分发。
26
+ > **当前版本:0.2.8。**
27
+ > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.8 通过 PyPI 分发。
28
28
  > **当前版本支持 Hermes 和 Codex。** Antigravity(反重力)不检测、不安装、不配置。
29
29
 
30
30
  ## 项目定位
@@ -430,19 +430,17 @@ class AdmissionFlowTests(unittest.TestCase):
430
430
  )
431
431
  )
432
432
  query_summary["scope_source"] = "session_context"
433
+ query_candidate = candidate(
434
+ "owner-query-update",
435
+ [user_key, assistant_key],
436
+ memory=active_before.body,
437
+ type="identity",
438
+ update_memory_id=old.memory_id,
439
+ )
440
+ query_candidate["scope_source"] = "session_context"
433
441
  backend.responses.extend(
434
442
  [
435
- gate(
436
- [
437
- candidate(
438
- "owner-query-update",
439
- [user_key, assistant_key],
440
- memory=active_before.body,
441
- type="identity",
442
- update_memory_id=old.memory_id,
443
- )
444
- ]
445
- ),
443
+ gate([query_candidate]),
446
444
  json.dumps(query_summary, ensure_ascii=False),
447
445
  ]
448
446
  )
@@ -66,7 +66,10 @@ class CrossHostAcceptanceTests(unittest.TestCase):
66
66
  session = f"{writer_host}-session-a"
67
67
  turn = "turn-1"
68
68
  title = f"{writer_host} to {reader_host}"
69
- body = f"Durable fact written by {writer_host} and recalled by {reader_host}."
69
+ body = (
70
+ f"cross-host project durable fact written by {writer_host} "
71
+ f"and recalled by {reader_host}."
72
+ )
70
73
 
71
74
  writer_service = Memleaf(
72
75
  vault,
@@ -1768,6 +1768,25 @@ class HermesProviderTests(unittest.TestCase):
1768
1768
  self.assertIsNotNone(fields)
1769
1769
  self.assertEqual(fields[4], "update_target_type_mismatch")
1770
1770
 
1771
+ def test_scope_validation_details_are_preserved(self) -> None:
1772
+ for detail in ("scope_not_grounded", "scope_drift"):
1773
+ with self.subTest(detail=detail):
1774
+ value = {
1775
+ "isError": True,
1776
+ "structuredContent": {
1777
+ "error": {
1778
+ "code": "model_invalid_response",
1779
+ "stage": "gate" if detail == "scope_not_grounded" else "summarize",
1780
+ "validation_reason": "schema_violation",
1781
+ "validation_detail": detail,
1782
+ "attempt_count": 3,
1783
+ }
1784
+ },
1785
+ }
1786
+ fields = provider_module._mcp_error_fields(value)
1787
+ self.assertIsNotNone(fields)
1788
+ self.assertEqual(fields[4], detail)
1789
+
1771
1790
  def test_relative_time_validation_detail_is_preserved_and_logged(self) -> None:
1772
1791
  value = {
1773
1792
  "isError": True,