memleaf 0.2.6__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.6 → memleaf-0.2.8}/CHANGELOG.md +25 -0
  2. {memleaf-0.2.6/src/memleaf.egg-info → memleaf-0.2.8}/PKG-INFO +3 -3
  3. {memleaf-0.2.6 → memleaf-0.2.8}/README.md +2 -2
  4. {memleaf-0.2.6 → memleaf-0.2.8}/pyproject.toml +1 -1
  5. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/__init__.py +1 -1
  6. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/hermes_provider/__init__.py +3 -0
  7. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/processing.py +48 -10
  9. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/prompts.py +77 -0
  10. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/scope_state.py +14 -2
  11. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/validation.py +122 -3
  12. {memleaf-0.2.6 → memleaf-0.2.8/src/memleaf.egg-info}/PKG-INFO +3 -3
  13. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_admission_noise.py +9 -11
  14. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_cross_host_acceptance.py +4 -1
  15. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_hermes_provider.py +36 -0
  16. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_maintenance_v2.py +8 -6
  17. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_pypi_install.py +1 -1
  18. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_session_lineage.py +2 -2
  19. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b1.py +197 -4
  20. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b2a.py +351 -17
  21. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3b_scope.py +29 -8
  22. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3d_scope_maintenance.py +11 -2
  23. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_c1_mcp.py +1 -1
  24. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_c3_packaging.py +1 -1
  25. {memleaf-0.2.6 → memleaf-0.2.8}/IMPLEMENTATION_PLAN.md +0 -0
  26. {memleaf-0.2.6 → memleaf-0.2.8}/LICENSE +0 -0
  27. {memleaf-0.2.6 → memleaf-0.2.8}/MANIFEST.in +0 -0
  28. {memleaf-0.2.6 → memleaf-0.2.8}/README.en.md +0 -0
  29. {memleaf-0.2.6 → memleaf-0.2.8}/RELEASE_CHECKLIST.md +0 -0
  30. {memleaf-0.2.6 → memleaf-0.2.8}/examples/README.md +0 -0
  31. {memleaf-0.2.6 → memleaf-0.2.8}/examples/basic_usage.py +0 -0
  32. {memleaf-0.2.6 → memleaf-0.2.8}/examples/mcp_stdio.ndjson +0 -0
  33. {memleaf-0.2.6 → memleaf-0.2.8}/install.ps1 +0 -0
  34. {memleaf-0.2.6 → memleaf-0.2.8}/install.sh +0 -0
  35. {memleaf-0.2.6 → memleaf-0.2.8}/setup.cfg +0 -0
  36. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/__main__.py +0 -0
  37. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/adapters/__init__.py +0 -0
  38. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/adapters/antigravity.py +0 -0
  39. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/adapters/base.py +0 -0
  40. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/adapters/codex.py +0 -0
  41. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/adapters/hermes.py +0 -0
  42. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/budget.py +0 -0
  43. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/capture.py +0 -0
  44. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/cli.py +0 -0
  45. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/compaction.py +0 -0
  46. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/config.py +0 -0
  47. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/credentials.py +0 -0
  48. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/frontmatter.py +0 -0
  49. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/hermes_provider/README.md +0 -0
  50. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/host_events.py +0 -0
  51. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/host_runtime.py +0 -0
  52. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/inbox.py +0 -0
  53. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/index.py +0 -0
  54. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/installer.py +0 -0
  55. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/llm/__init__.py +0 -0
  56. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/llm/base.py +0 -0
  57. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/llm/claude_compatible.py +0 -0
  58. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/llm/gemini.py +0 -0
  59. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/llm/openai_compatible.py +0 -0
  60. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/llm/router.py +0 -0
  61. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/locking.py +0 -0
  62. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/mcp_server.py +0 -0
  63. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/memory_writer.py +0 -0
  64. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/model_discovery.py +0 -0
  65. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/models.py +0 -0
  66. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/native_index.py +0 -0
  67. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/redaction.py +0 -0
  68. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/retrieval.py +0 -0
  69. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/retrieval_gate.py +0 -0
  70. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/scope_maintenance.py +0 -0
  71. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/service.py +0 -0
  72. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf/vault.py +0 -0
  73. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf.egg-info/SOURCES.txt +0 -0
  74. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf.egg-info/dependency_links.txt +0 -0
  75. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf.egg-info/entry_points.txt +0 -0
  76. {memleaf-0.2.6 → memleaf-0.2.8}/src/memleaf.egg-info/top_level.txt +0 -0
  77. {memleaf-0.2.6 → memleaf-0.2.8}/tests/__init__.py +0 -0
  78. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_codex_install.py +0 -0
  79. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_codex_native_cli.py +0 -0
  80. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_context_budget.py +0 -0
  81. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_credential_safety.py +0 -0
  82. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_hermes_stdio_transport.py +0 -0
  83. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_host_events.py +0 -0
  84. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_host_runtime_contract.py +0 -0
  85. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_install.py +0 -0
  86. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_model_discovery.py +0 -0
  87. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_retrieval_gate.py +0 -0
  88. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_retrieval_v2.py +0 -0
  89. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_a.py +0 -0
  90. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b2b.py +0 -0
  91. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3a_commit.py +0 -0
  92. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3a_contract.py +0 -0
  93. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3b_native_context.py +0 -0
  94. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3b_native_index.py +0 -0
  95. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_b3c_retrieval.py +0 -0
  96. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.6 → memleaf-0.2.8}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,31 @@
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
+
18
+ ## 0.2.7 — 2026-09-01
19
+
20
+ - Distinguish an invalid gate type enum from a candidate type that conflicts
21
+ with its selected active update target, while preserving the strict,
22
+ immutable target-type constraint.
23
+ - Give each gate failure a bounded, actionable retry instruction: use a legal
24
+ memory type, preserve the target type for the same future use, or split a
25
+ genuinely different future use into an independent atomic candidate.
26
+ - Preserve the dedicated `update_target_type_mismatch` diagnostic across Core,
27
+ MCP, and Hermes; repeated failures still write nothing and retain the inbox
28
+ for a later retry.
29
+
5
30
  ## 0.2.6 — 2026-09-01
6
31
 
7
32
  - Reject one worthy memory that spans multiple independent project scopes and
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.6
3
+ Version: 0.2.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.6。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 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.6。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 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.6"
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.6"
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
@@ -77,6 +77,9 @@ _MODEL_VALIDATION_DETAILS = frozenset(
77
77
  "duplicate_candidate_id",
78
78
  "duplicate_update_target",
79
79
  "mixed_project_scopes",
80
+ "update_target_type_mismatch",
81
+ "scope_not_grounded",
82
+ "scope_drift",
80
83
  "invalid_evidence",
81
84
  "invalid_flags",
82
85
  "invalid_type",
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.6
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
@@ -32,13 +32,17 @@ from .models import Memory, utc_now
32
32
  from .native_index import NativeIndexer
33
33
  from .prompts import (
34
34
  DUPLICATE_TARGET_CORRECTION,
35
+ GATE_TYPE_CORRECTION,
35
36
  JSON_CORRECTION,
36
37
  MIXED_PROJECT_SCOPES_CORRECTION,
37
38
  RELATIVE_TIME_CORRECTION,
38
39
  GATE_SYSTEM,
40
+ SCOPE_GROUNDING_CORRECTION,
41
+ SUMMARY_SCOPE_CORRECTION,
39
42
  SUMMARY_TARGET_CORRECTION,
40
43
  SUMMARY_TYPE_CORRECTION,
41
44
  SUMMARIZE_SYSTEM,
45
+ UPDATE_TARGET_TYPE_CORRECTION,
42
46
  gate_prompt,
43
47
  summarize_prompt,
44
48
  )
@@ -49,6 +53,7 @@ from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_reg
49
53
  from .validation import (
50
54
  MODEL_VALIDATION_DETAILS,
51
55
  ModelOutputError,
56
+ NO_CHANGE_DECISION,
52
57
  parse_gate_output,
53
58
  parse_summarize_output,
54
59
  )
@@ -482,6 +487,14 @@ class Processor:
482
487
  return DUPLICATE_TARGET_CORRECTION
483
488
  if hint == "mixed_project_scopes":
484
489
  return MIXED_PROJECT_SCOPES_CORRECTION
490
+ if stage == "gate" and hint == "update_target_type_mismatch":
491
+ return UPDATE_TARGET_TYPE_CORRECTION
492
+ if stage == "gate" and hint == "invalid_type":
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
485
498
  if hint == "relative_time":
486
499
  return RELATIVE_TIME_CORRECTION
487
500
  if stage == "summarize" and hint == "invalid_update_target":
@@ -1608,6 +1621,7 @@ class Processor:
1608
1621
  related_native_ids=related_native_ids,
1609
1622
  related_memory_ids=related_memory_ids,
1610
1623
  scope_registry=validation_scope_registry,
1624
+ allow_no_change=False,
1611
1625
  ),
1612
1626
  diagnostic_context={
1613
1627
  "source": turn.source,
@@ -1646,6 +1660,7 @@ class Processor:
1646
1660
  current_event_keys=turn.event_keys,
1647
1661
  related_memory_ids=gate_related_memory_ids,
1648
1662
  related_memory_types=gate_related_memory_types,
1663
+ scope_registry=validation_scope_registry,
1649
1664
  ),
1650
1665
  diagnostic_context={
1651
1666
  "source": turn.source,
@@ -1657,13 +1672,6 @@ class Processor:
1657
1672
  observed_scopes: list[str] = []
1658
1673
  for candidate in gate["candidates"]:
1659
1674
  candidate_scopes = list(candidate["scopes"])
1660
- if (
1661
- candidate_scopes != ["unscoped"]
1662
- and candidate.get("scope_source") != "insufficient_context"
1663
- ):
1664
- for observed_scope in candidate_scopes:
1665
- if observed_scope not in observed_scopes:
1666
- observed_scopes.append(observed_scope)
1667
1675
  # An automatic candidate with no reliable project attribution is
1668
1676
  # retained as a retryable inbox turn, never silently promoted to
1669
1677
  # global knowledge. The processed ledger records only a compact
@@ -1752,6 +1760,27 @@ class Processor:
1752
1760
  native_refs=candidate_native_refs,
1753
1761
  )
1754
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)
1755
1784
  continue
1756
1785
 
1757
1786
  gate_update_target = candidate.get("update_memory_id")
@@ -1787,6 +1816,9 @@ class Processor:
1787
1816
  related_native_ids=candidate_native_ids,
1788
1817
  related_memory_ids=candidate_memory_ids,
1789
1818
  scope_registry=validation_scope_registry,
1819
+ expected_scopes=candidate["scopes"],
1820
+ expected_scope_source=candidate["scope_source"],
1821
+ allow_no_change=True,
1790
1822
  expected_type=(
1791
1823
  candidate.get("type")
1792
1824
  if (
@@ -1808,6 +1840,8 @@ class Processor:
1808
1840
  "turn_index": turn.turn_index,
1809
1841
  },
1810
1842
  )
1843
+ if summary.get("decision") == NO_CHANGE_DECISION:
1844
+ continue
1811
1845
  if gate_update_target is not None:
1812
1846
  summary_update_target = summary.get("update_memory_id")
1813
1847
  if summary_update_target is None:
@@ -1833,9 +1867,6 @@ class Processor:
1833
1867
  scope_source=summary.get("scope_source"),
1834
1868
  )
1835
1869
  continue
1836
- for observed_scope in summary["scopes"]:
1837
- if observed_scope not in observed_scopes:
1838
- observed_scopes.append(observed_scope)
1839
1870
  requests.append(
1840
1871
  self._request(
1841
1872
  summary,
@@ -1845,6 +1876,13 @@ class Processor:
1845
1876
  native_refs=candidate_native_refs,
1846
1877
  )
1847
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)
1848
1886
  return requests, observed_scopes
1849
1887
 
1850
1888
  def _state_for_snapshot_unlocked(self, snapshot: _Snapshot, processed: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -34,6 +34,20 @@ 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.
46
+ An update target's existing type is immutable: when the same future use is being
47
+ updated, candidate type must exactly equal the related active target type. If a
48
+ fact serves a different future use, do not force it onto that target; omit
49
+ update_memory_id and emit a separate atomic candidate, or use worth=false when
50
+ it is already covered or only temporary.
37
51
  Within one gate response, each active memory target may appear at most once
38
52
  across all candidates, whether it is named by duplicate_memory_id or
39
53
  update_memory_id. If multiple facts share one future-use/update target, merge
@@ -151,6 +165,16 @@ When the gate candidate supplies update_memory_id, that target is immutable:
151
165
  omit update_memory_id or copy that exact active ID, never replace it with a
152
166
  different, guessed, native, or historical ID. Keep the summary type identical
153
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.
154
178
  For the same use, preserve still-valid facts, add confirmed progress, replace
155
179
  contradictions, and never reduce the memory to the latest operation or create a
156
180
  sibling with a new label.
@@ -241,6 +265,27 @@ RELATIVE_TIME_CORRECTION = (
241
265
  )
242
266
 
243
267
 
268
+ GATE_TYPE_CORRECTION = (
269
+ "Previous output violated: invalid_type. In gate output, whenever type is "
270
+ "non-null it must be exactly one of preference, fact, project, todo, event, "
271
+ "identity, or other. worth=true requires a non-null legal type; worth=false "
272
+ "may use null or any of those legal types (for example, a duplicate candidate "
273
+ "may retain its type). Do not invent enum values such as requirement, "
274
+ "decision, or task. Return only the strict gate JSON object."
275
+ )
276
+
277
+
278
+ UPDATE_TARGET_TYPE_CORRECTION = (
279
+ "Previous output violated: update_target_type_mismatch. An existing active "
280
+ "update target's type is immutable. For the same future-use topic, set the "
281
+ "candidate type exactly equal to the supplied related target type. For a "
282
+ "different future use, remove update_memory_id and emit a separate atomic "
283
+ "candidate, or set worth=false if it is already covered or only temporary. "
284
+ "Use only a supplied related active ID; never guess, use native/history IDs, "
285
+ "or force a cross-purpose update. Return only the strict gate JSON object."
286
+ )
287
+
288
+
244
289
  MIXED_PROJECT_SCOPES_CORRECTION = (
245
290
  "Previous output violated: mixed_project_scopes. Keep each worthy "
246
291
  "candidate and summary atomic: it may contain at most one distinct "
@@ -280,6 +325,28 @@ SUMMARY_TYPE_CORRECTION = (
280
325
  )
281
326
 
282
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
+
283
350
  COMPACT_SYSTEM = """You are memleaf's memory compactor. Return JSON only.
284
351
  Merge only the supplied low-priority memories when they express compatible
285
352
  information. Return an object with a memories array; [] is a safe no-op.
@@ -372,6 +439,14 @@ def summarize_prompt(
372
439
  f"Current scope registry (safe projection; no paths):\n"
373
440
  f"{_json(scope_registry if scope_registry is not None else [])}\n"
374
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
+ )
375
450
  example_key = _first_event_key(events)
376
451
  if example_key is not None:
377
452
  candidate_type = candidate.get("type") if isinstance(candidate, dict) else None
@@ -405,6 +480,8 @@ def summarize_prompt(
405
480
  }
406
481
  )
407
482
  )
483
+ if not explicit:
484
+ return prompt + '\nReturn one summary JSON object or exactly {"decision":"NO_CHANGE"}.'
408
485
  return prompt + "\nReturn one summary JSON object."
409
486
 
410
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
  )
@@ -53,6 +59,9 @@ MODEL_VALIDATION_DETAILS = frozenset(
53
59
  "duplicate_candidate_id",
54
60
  "duplicate_update_target",
55
61
  "mixed_project_scopes",
62
+ "update_target_type_mismatch",
63
+ "scope_not_grounded",
64
+ "scope_drift",
56
65
  "invalid_evidence",
57
66
  "invalid_flags",
58
67
  "invalid_type",
@@ -248,6 +257,78 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
248
257
  )
249
258
 
250
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
+
251
332
  def _memory_id(value: Any, field: str) -> str:
252
333
  result = _string(value, field)
253
334
  if "/" in result or "\\" in result or result in (".", ".."):
@@ -419,6 +500,7 @@ def validate_gate_output(
419
500
  current_event_keys: Iterable[Any] | None = None,
420
501
  related_memory_ids: Iterable[Any] | None = None,
421
502
  related_memory_types: Mapping[str, Any] | None = None,
503
+ scope_registry: Mapping[str, Any] | None = None,
422
504
  ) -> dict[str, Any]:
423
505
  """Validate and return a normalized gate object without writing anything."""
424
506
 
@@ -540,7 +622,7 @@ def validate_gate_output(
540
622
  if target_type is not None and candidate_type != target_type:
541
623
  raise ModelOutputError(
542
624
  "candidate type does not match update target",
543
- validation_detail="invalid_type",
625
+ validation_detail="update_target_type_mismatch",
544
626
  )
545
627
  item["update_memory_id"] = update_id
546
628
  for target_field in ("duplicate_memory_id", "update_memory_id"):
@@ -558,6 +640,8 @@ def validate_gate_output(
558
640
  item["scopes"] = _scopes(item["scopes"], item["scope_source"])
559
641
  if item["worth"]:
560
642
  _reject_mixed_project_scopes(item["scopes"])
643
+ if item["scope_source"] == "model":
644
+ _reject_ungrounded_project_scope(item["memory"], item["scopes"], scope_registry)
561
645
  if "reason" in item:
562
646
  _string(item["reason"], "reason", nonempty=False)
563
647
  if len(item["reason"]) > 30:
@@ -574,6 +658,7 @@ def parse_gate_output(
574
658
  current_event_keys: Iterable[Any] | None = None,
575
659
  related_memory_ids: Iterable[Any] | None = None,
576
660
  related_memory_types: Mapping[str, Any] | None = None,
661
+ scope_registry: Mapping[str, Any] | None = None,
577
662
  ) -> dict[str, Any]:
578
663
  try:
579
664
  parsed = parse_strict_json(raw)
@@ -588,6 +673,7 @@ def parse_gate_output(
588
673
  current_event_keys=current_event_keys,
589
674
  related_memory_ids=related_memory_ids,
590
675
  related_memory_types=related_memory_types,
676
+ scope_registry=scope_registry,
591
677
  )
592
678
  except ModelOutputError as error:
593
679
  if error.validation_reason is None:
@@ -636,6 +722,9 @@ def validate_summarize_output(
636
722
  expected_type: str | None = None,
637
723
  expected_update_memory_id: str | None = None,
638
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,
639
728
  ) -> dict[str, Any]:
640
729
  """Validate one atomic memory summary; this function has no filesystem effects."""
641
730
 
@@ -643,6 +732,13 @@ def validate_summarize_output(
643
732
  value = parse_strict_json(value)
644
733
  if not isinstance(value, Mapping):
645
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
+ )
646
742
  allowed = {
647
743
  "memory_id",
648
744
  "update_memory_id",
@@ -680,8 +776,24 @@ def validate_summarize_output(
680
776
  "summary type does not match update target",
681
777
  validation_detail="invalid_type",
682
778
  )
683
- 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)
684
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
+ )
685
797
  item["scope_operations"] = _scope_operations(
686
798
  item.get("scope_operations"),
687
799
  summary_scopes=item["scopes"],
@@ -769,6 +881,9 @@ def parse_summarize_output(
769
881
  expected_type: str | None = None,
770
882
  expected_update_memory_id: str | None = None,
771
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,
772
887
  ) -> dict[str, Any]:
773
888
  try:
774
889
  parsed = parse_strict_json(raw)
@@ -786,6 +901,9 @@ def parse_summarize_output(
786
901
  expected_type=expected_type,
787
902
  expected_update_memory_id=expected_update_memory_id,
788
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,
789
907
  )
790
908
  except ModelOutputError as error:
791
909
  if error.validation_reason is None:
@@ -893,6 +1011,7 @@ __all__ = [
893
1011
  "MODEL_VALIDATION_DETAILS",
894
1012
  "MODEL_VALIDATION_REASONS",
895
1013
  "ModelOutputError",
1014
+ "NO_CHANGE_DECISION",
896
1015
  "SCOPE_SOURCES",
897
1016
  "TODO_STATUSES",
898
1017
  "parse_gate",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.6
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.6。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.6 通过 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
  ## 项目定位