memleaf 0.2.9__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.9 → memleaf-0.2.10}/CHANGELOG.md +12 -0
  2. {memleaf-0.2.9/src/memleaf.egg-info → memleaf-0.2.10}/PKG-INFO +3 -3
  3. {memleaf-0.2.9 → memleaf-0.2.10}/README.en.md +2 -2
  4. {memleaf-0.2.9 → memleaf-0.2.10}/README.md +2 -2
  5. {memleaf-0.2.9 → memleaf-0.2.10}/pyproject.toml +1 -1
  6. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/__init__.py +1 -1
  7. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/hermes_provider/plugin.yaml +1 -1
  8. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/processing.py +50 -1
  9. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/validation.py +45 -21
  10. {memleaf-0.2.9 → memleaf-0.2.10/src/memleaf.egg-info}/PKG-INFO +3 -3
  11. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_pypi_install.py +1 -1
  12. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b2a.py +273 -0
  13. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_c1_mcp.py +1 -1
  14. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_c3_packaging.py +1 -1
  15. {memleaf-0.2.9 → memleaf-0.2.10}/IMPLEMENTATION_PLAN.md +0 -0
  16. {memleaf-0.2.9 → memleaf-0.2.10}/LICENSE +0 -0
  17. {memleaf-0.2.9 → memleaf-0.2.10}/MANIFEST.in +0 -0
  18. {memleaf-0.2.9 → memleaf-0.2.10}/RELEASE_CHECKLIST.md +0 -0
  19. {memleaf-0.2.9 → memleaf-0.2.10}/examples/README.md +0 -0
  20. {memleaf-0.2.9 → memleaf-0.2.10}/examples/basic_usage.py +0 -0
  21. {memleaf-0.2.9 → memleaf-0.2.10}/examples/mcp_stdio.ndjson +0 -0
  22. {memleaf-0.2.9 → memleaf-0.2.10}/install.ps1 +0 -0
  23. {memleaf-0.2.9 → memleaf-0.2.10}/install.sh +0 -0
  24. {memleaf-0.2.9 → memleaf-0.2.10}/setup.cfg +0 -0
  25. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/__main__.py +0 -0
  26. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/__init__.py +0 -0
  27. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/antigravity.py +0 -0
  28. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/base.py +0 -0
  29. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/codex.py +0 -0
  30. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/adapters/hermes.py +0 -0
  31. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/budget.py +0 -0
  32. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/capture.py +0 -0
  33. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/cli.py +0 -0
  34. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/compaction.py +0 -0
  35. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/config.py +0 -0
  36. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/credentials.py +0 -0
  37. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/frontmatter.py +0 -0
  38. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/hermes_provider/README.md +0 -0
  39. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/hermes_provider/__init__.py +0 -0
  40. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/host_events.py +0 -0
  41. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/host_runtime.py +0 -0
  42. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/inbox.py +0 -0
  43. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/index.py +0 -0
  44. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/installer.py +0 -0
  45. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/__init__.py +0 -0
  46. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/base.py +0 -0
  47. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/claude_compatible.py +0 -0
  48. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/gemini.py +0 -0
  49. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/openai_compatible.py +0 -0
  50. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/llm/router.py +0 -0
  51. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/locking.py +0 -0
  52. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/mcp_server.py +0 -0
  53. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/memory_writer.py +0 -0
  54. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/model_discovery.py +0 -0
  55. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/models.py +0 -0
  56. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/native_index.py +0 -0
  57. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/prompts.py +0 -0
  58. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/redaction.py +0 -0
  59. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/retrieval.py +0 -0
  60. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/retrieval_gate.py +0 -0
  61. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/scope_maintenance.py +0 -0
  62. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/scope_state.py +0 -0
  63. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/service.py +0 -0
  64. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf/vault.py +0 -0
  65. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/SOURCES.txt +0 -0
  66. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/dependency_links.txt +0 -0
  67. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/entry_points.txt +0 -0
  68. {memleaf-0.2.9 → memleaf-0.2.10}/src/memleaf.egg-info/top_level.txt +0 -0
  69. {memleaf-0.2.9 → memleaf-0.2.10}/tests/__init__.py +0 -0
  70. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_admission_noise.py +0 -0
  71. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_codex_install.py +0 -0
  72. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_codex_native_cli.py +0 -0
  73. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_context_budget.py +0 -0
  74. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_credential_safety.py +0 -0
  75. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_cross_host_acceptance.py +0 -0
  76. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_hermes_provider.py +0 -0
  77. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_hermes_stdio_transport.py +0 -0
  78. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_host_events.py +0 -0
  79. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_host_runtime_contract.py +0 -0
  80. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_install.py +0 -0
  81. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_maintenance_v2.py +0 -0
  82. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_model_discovery.py +0 -0
  83. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_retrieval_gate.py +0 -0
  84. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_retrieval_v2.py +0 -0
  85. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_session_lineage.py +0 -0
  86. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_a.py +0 -0
  87. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b1.py +0 -0
  88. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b2b.py +0 -0
  89. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3a_commit.py +0 -0
  90. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3a_contract.py +0 -0
  91. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3b_native_context.py +0 -0
  92. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3b_native_index.py +0 -0
  93. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3b_scope.py +0 -0
  94. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3c_retrieval.py +0 -0
  95. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_b3d_scope_maintenance.py +0 -0
  96. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_stage_c2_init.py +0 -0
  97. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_upgrade_preserves_vault.py +0 -0
  98. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_gate_limits.py +0 -0
  99. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_host_flow.py +0 -0
  100. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_mcp_flow.py +0 -0
  101. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_nomatch_semantics.py +0 -0
  102. {memleaf-0.2.9 → memleaf-0.2.10}/tests/test_v2_search_gate_acceptance.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to memleaf are documented here.
4
4
 
5
+ ## 0.2.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
+
5
17
  ## 0.2.9 — 2026-09-01
6
18
 
7
19
  - Reject update and duplicate targets that are relevant only to another item
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memleaf
3
- Version: 0.2.9
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.9。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.9.**
8
- > The core library, Vault, stdio MCP server, initialization CLI, model routing, memory extraction, controlled retrieval protocol, and host adapters are implemented. memleaf 0.2.9 is distributed through PyPI.
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.9。**
8
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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.9"
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.9"
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
@@ -1,5 +1,5 @@
1
1
  name: memleaf
2
- version: 0.2.9
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
@@ -49,12 +49,17 @@ from .prompts import (
49
49
  )
50
50
  from .redaction import redact_text
51
51
  from .retrieval import candidate_matches_query, filter_by_scope, normalize_term
52
- 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
+ )
53
57
  from .scope_maintenance import ScopeMaintainer, ScopeMaintenanceError, scope_registry_projection
54
58
  from .validation import (
55
59
  MODEL_VALIDATION_DETAILS,
56
60
  ModelOutputError,
57
61
  NO_CHANGE_DECISION,
62
+ _model_scope_grounding_evidence,
58
63
  parse_gate_output,
59
64
  parse_summarize_output,
60
65
  )
@@ -1808,7 +1813,51 @@ class Processor:
1808
1813
  related_memory_ids=gate_related_memory_ids,
1809
1814
  related_memory_types=gate_related_memory_types,
1810
1815
  scope_registry=validation_scope_registry,
1816
+ enforce_model_scope_grounding=gate_attempt_count < 3,
1811
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
+
1812
1861
  invalid_targets: dict[str, set[str]] = {}
1813
1862
  for candidate in parsed["candidates"]:
1814
1863
  target_fields = {
@@ -258,17 +258,16 @@ def _reject_mixed_project_scopes(scopes: Iterable[str]) -> None:
258
258
  )
259
259
 
260
260
 
261
- def _reject_ungrounded_project_scope(
261
+ def _model_scope_grounding_evidence(
262
262
  memory: str,
263
263
  scopes: Iterable[str],
264
264
  scope_registry: Mapping[str, Any] | None,
265
- ) -> None:
266
- """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.
267
267
 
268
- The gate's memory text is the only candidate-local attribution evidence.
269
- Session background, other events, and related-memory bodies are
270
- intentionally excluded so an aggregate mailbox turn cannot lend a
271
- 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)
272
271
  """
273
272
 
274
273
  selected = {
@@ -277,16 +276,14 @@ def _reject_ungrounded_project_scope(
277
276
  if isinstance(scope, str) and scope.partition(":")[0] == "project"
278
277
  }
279
278
  if not selected:
280
- return
279
+ return {}, {}
281
280
  try:
282
281
  registry = validate_scope_registry(scope_registry or {})
283
282
  except ScopeError as error:
284
283
  raise ModelOutputError("invalid scope registry", validation_detail="invalid_scope") from error
285
284
 
286
- # Resolve a selected project key through a registered canonical scope when
287
- # it is an alias (including the full ``project:...`` spelling). A merged
288
- # source may remain selectable as an alias of its target; treating both as
289
- # 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.
290
287
  selected_owners: dict[str, str] = {}
291
288
  for selected_scope in selected.values():
292
289
  selected_owner = selected_scope
@@ -311,19 +308,42 @@ def _reject_ungrounded_project_scope(
311
308
  selected_owners[selected_scope.casefold()] = selected_owner.casefold()
312
309
 
313
310
  # Include unregistered selections as temporary nodes: new scopes must be
314
- # grounded by their own project name, while registered scopes also accept
315
- # their configured aliases. All known projects are checked to reject an
316
- # 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.
317
313
  candidate_registry = dict(registry)
318
- for scope in selected.values():
319
- owner = selected_owners[scope.casefold()]
314
+ for selected_scope in selected.values():
315
+ owner = selected_owners[selected_scope.casefold()]
320
316
  if owner not in {registered.casefold() for registered in registry}:
321
- candidate_registry.setdefault(scope, {})
317
+ candidate_registry.setdefault(selected_scope, {})
318
+
322
319
  matches = {
323
- scope.casefold()
320
+ scope.casefold(): scope
324
321
  for scope in project_scope_matches_text(memory, {"scopes": candidate_registry})
325
322
  }
326
- 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())):
327
347
  raise ModelOutputError(
328
348
  "model project scope is not grounded by this candidate",
329
349
  validation_detail="scope_not_grounded",
@@ -502,6 +522,7 @@ def validate_gate_output(
502
522
  related_memory_ids: Iterable[Any] | None = None,
503
523
  related_memory_types: Mapping[str, Any] | None = None,
504
524
  scope_registry: Mapping[str, Any] | None = None,
525
+ enforce_model_scope_grounding: bool = True,
505
526
  ) -> dict[str, Any]:
506
527
  """Validate and return a normalized gate object without writing anything."""
507
528
 
@@ -642,7 +663,8 @@ def validate_gate_output(
642
663
  if item["worth"]:
643
664
  _reject_mixed_project_scopes(item["scopes"])
644
665
  if item["scope_source"] == "model":
645
- _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)
646
668
  if "reason" in item:
647
669
  _string(item["reason"], "reason", nonempty=False)
648
670
  if len(item["reason"]) > 30:
@@ -660,6 +682,7 @@ def parse_gate_output(
660
682
  related_memory_ids: Iterable[Any] | None = None,
661
683
  related_memory_types: Mapping[str, Any] | None = None,
662
684
  scope_registry: Mapping[str, Any] | None = None,
685
+ enforce_model_scope_grounding: bool = True,
663
686
  ) -> dict[str, Any]:
664
687
  try:
665
688
  parsed = parse_strict_json(raw)
@@ -675,6 +698,7 @@ def parse_gate_output(
675
698
  related_memory_ids=related_memory_ids,
676
699
  related_memory_types=related_memory_types,
677
700
  scope_registry=scope_registry,
701
+ enforce_model_scope_grounding=enforce_model_scope_grounding,
678
702
  )
679
703
  except ModelOutputError as error:
680
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.9
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.9。**
27
- > 核心库、Vault、stdio MCP Server、初始化 CLI、模型路由、提炼流程、受控检索协议和宿主适配器已经实现。memleaf 0.2.9 通过 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
  ## 项目定位
@@ -39,7 +39,7 @@ class PyPIInstallTests(unittest.TestCase):
39
39
 
40
40
  self.assertTrue(target.is_dir())
41
41
  self.assertFalse(target.is_symlink())
42
- self.assertIn("version: 0.2.9", (target / "plugin.yaml").read_text(encoding="utf-8"))
42
+ self.assertIn("version: 0.2.10", (target / "plugin.yaml").read_text(encoding="utf-8"))
43
43
 
44
44
  def test_windows_hermes_paths_follow_official_native_layout(self) -> None:
45
45
  with tempfile.TemporaryDirectory(prefix="memleaf-win-paths-") as temporary:
@@ -266,6 +266,279 @@ class StageB2ATest(unittest.TestCase):
266
266
  self.assertNotIn("project:浙江东方", state.get("scopes", []))
267
267
  self.assertNotIn("project:浙江东方", service.vault.config()["scopes"])
268
268
 
269
+ def test_gate_scope_not_grounded_final_retry_corrects_by_unique_memory_match(self):
270
+ backend = QueueBackend()
271
+ service = self.service(backend, name="scope-final-correct")
272
+ config = service.vault.config()
273
+ config["scopes"] = {
274
+ "project:zhongyin": {"aliases": ["中银国际"]},
275
+ "project:金元顺安": {"aliases": ["金元顺安"]},
276
+ }
277
+ save_config(service.vault.config_path, config)
278
+ user_key, _ = self.capture_turn(
279
+ service,
280
+ turn="scope-final-correct",
281
+ user_event="scope-final-correct-user",
282
+ assistant_event="scope-final-correct-assistant",
283
+ user="中银国际实施计划需要更新。",
284
+ assistant="确认中银国际计划更新。",
285
+ )
286
+ wrong = self.candidate(
287
+ "wrong-zg-scope",
288
+ [user_key],
289
+ memory="中银国际实施计划需要更新。",
290
+ type="project",
291
+ )
292
+ wrong["scopes"] = ["project:金元顺安"]
293
+ backend.responses.extend(
294
+ [
295
+ self.gate([wrong]),
296
+ self.gate([wrong]),
297
+ self.gate([wrong]),
298
+ self.summary(
299
+ user_key,
300
+ title="中银国际实施计划",
301
+ body="中银国际实施计划需要更新。",
302
+ type="project",
303
+ scopes=["project:zhongyin"],
304
+ ),
305
+ ]
306
+ )
307
+
308
+ result = service.process(source="codex", session_id="s", model=backend)
309
+
310
+ self.assertEqual(result["memories_written"], 1)
311
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
312
+ self.assertIn("Previous output violated: scope_not_grounded.", backend.calls[1]["prompt"])
313
+ self.assertEqual(self.knowledge(service)[0].memory.scopes, ["project:zhongyin"])
314
+ self.assertIn("project:zhongyin", service.vault.config()["scopes"])
315
+
316
+ def test_scope_not_grounded_final_retry_keeps_other_valid_candidates(self):
317
+ backend = QueueBackend()
318
+ service = self.service(backend, name="scope-final-keep-valid")
319
+ config = service.vault.config()
320
+ config["scopes"] = {
321
+ "project:zhongyin": {"aliases": ["中银国际"]},
322
+ "project:xinyuan": {"aliases": ["鑫元基金"]},
323
+ }
324
+ save_config(service.vault.config_path, config)
325
+ user_key, assistant_key = self.capture_turn(
326
+ service,
327
+ turn="scope-final-keep-valid",
328
+ user_event="scope-final-keep-valid-user",
329
+ assistant_event="scope-final-keep-valid-assistant",
330
+ user="中银国际计划已更新;鑫元待反馈。",
331
+ assistant="待确认中银国际与鑫元相关事项。",
332
+ )
333
+
334
+ valid = self.candidate(
335
+ "zhongyin-plan",
336
+ [user_key],
337
+ memory="中银国际计划已更新。",
338
+ type="project",
339
+ )
340
+ valid["scopes"] = ["project:zhongyin"]
341
+ bad = self.candidate(
342
+ "xinyuan-ambiguous",
343
+ [assistant_key],
344
+ memory="鑫元 待反馈。",
345
+ type="event",
346
+ )
347
+ bad["scopes"] = ["project:xinyuan"]
348
+ backend.responses.extend(
349
+ [
350
+ self.gate([bad, valid]),
351
+ self.gate([bad, valid]),
352
+ self.gate([bad, valid]),
353
+ self.summary(
354
+ user_key,
355
+ title="中银国际计划",
356
+ body="中银国际计划已更新。",
357
+ type="project",
358
+ scopes=["project:zhongyin"],
359
+ ),
360
+ ]
361
+ )
362
+
363
+ result = service.process(source="codex", session_id="s", model=backend)
364
+
365
+ self.assertEqual(result["memories_written"], 1)
366
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
367
+ self.assertIn("Previous output violated: scope_not_grounded.", backend.calls[1]["prompt"])
368
+ self.assertEqual(len(self.knowledge(service)), 1)
369
+ self.assertEqual(self.knowledge(service)[0].memory.scopes, ["project:zhongyin"])
370
+ turn_entry = self.processed(service)["sessions"]["codex/s"]["processed_turns"][0]
371
+ deferred = turn_entry["deferred_candidates"]
372
+ self.assertEqual(len(deferred), 1)
373
+ self.assertEqual(deferred[0]["candidate_id"], "xinyuan-ambiguous")
374
+ self.assertNotIn("鑫元", "\n".join(record.memory.body for record in self.knowledge(service)))
375
+
376
+ def test_scope_not_grounded_final_retry_ambiguous_mention_no_write(self):
377
+ backend = QueueBackend()
378
+ service = self.service(backend, name="scope-final-ambiguous")
379
+ config = service.vault.config()
380
+ config["scopes"] = {
381
+ "project:zhongyin": {"aliases": ["中银国际"]},
382
+ "project:jinyuan": {"aliases": ["金元顺安"]},
383
+ }
384
+ save_config(service.vault.config_path, config)
385
+ user_key, _ = self.capture_turn(
386
+ service,
387
+ turn="scope-final-ambiguous",
388
+ user_event="scope-final-ambiguous-user",
389
+ assistant_event="scope-final-ambiguous-assistant",
390
+ user="中银国际和金元顺安均有更新。",
391
+ assistant="已确认两个项目的进展。",
392
+ )
393
+ wrong = self.candidate(
394
+ "both-mentioned",
395
+ [user_key],
396
+ memory="中银国际和金元顺安均有更新。",
397
+ type="fact",
398
+ )
399
+ wrong["scopes"] = ["project:zhongyin"]
400
+ backend.responses.extend([self.gate([wrong]), self.gate([wrong]), self.gate([wrong])])
401
+
402
+ result = service.process(source="codex", session_id="s", model=backend)
403
+
404
+ self.assertEqual(result["memories_written"], 0)
405
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate"])
406
+ self.assertEqual(self.knowledge(service), [])
407
+ turn_entry = self.processed(service)["sessions"]["codex/s"]["processed_turns"][0]
408
+ deferred = turn_entry["deferred_candidates"]
409
+ self.assertEqual(len(deferred), 1)
410
+ self.assertEqual(deferred[0]["candidate_id"], "both-mentioned")
411
+ self.assertEqual(deferred[0]["reason"], "scope_required")
412
+ self.assertEqual(self.processed(service)["sessions"]["codex/s"]["processing"]["status"], "idle")
413
+
414
+ def test_scope_not_grounded_final_retry_wrong_scope_and_wrong_update_target_is_safe(self):
415
+ backend = QueueBackend()
416
+ service = self.service(backend, name="scope-final-wrong-scope-target")
417
+ config = service.vault.config()
418
+ config["scopes"] = {
419
+ "project:alpha": {"aliases": ["阿尔法"]},
420
+ "project:beta": {"aliases": ["贝塔"]},
421
+ }
422
+ save_config(service.vault.config_path, config)
423
+ old = service.create_memory(
424
+ memory_id="alpha-existing",
425
+ title="阿尔法 项目负责人",
426
+ body="阿尔法 项目负责人当前为甲。",
427
+ type="identity",
428
+ scopes=["project:alpha"],
429
+ )
430
+ user_key, _ = self.capture_turn(
431
+ service,
432
+ turn="scope-final-wrong-scope-target",
433
+ user_event="scope-final-wrong-scope-target-user",
434
+ assistant_event="scope-final-wrong-scope-target-assistant",
435
+ user="阿尔法 项目负责人当前为甲;贝塔 项目负责人更新为乙。",
436
+ assistant="已确认两个项目的负责人信息。",
437
+ )
438
+ wrong = self.candidate(
439
+ "wrong-scope-target",
440
+ [user_key],
441
+ memory="贝塔 项目负责人更新为乙。",
442
+ type="identity",
443
+ update_memory_id=old.memory_id,
444
+ )
445
+ wrong["scopes"] = ["project:alpha"]
446
+ backend.responses.extend(
447
+ [
448
+ self.gate([wrong]),
449
+ self.gate([wrong]),
450
+ self.gate([wrong]),
451
+ self.summary(
452
+ user_key,
453
+ title="贝塔 项目负责人",
454
+ body="贝塔 项目负责人更新为乙。",
455
+ type="identity",
456
+ scopes=["project:beta"],
457
+ ),
458
+ ]
459
+ )
460
+
461
+ result = service.process(source="codex", session_id="s", model=backend)
462
+
463
+ self.assertEqual(result["memories_written"], 1)
464
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
465
+ self.assertIn("Previous output violated: scope_not_grounded.", backend.calls[1]["prompt"])
466
+ self.assertEqual(service.read(old.memory_id).body, old.body)
467
+ self.assertEqual(len(self.knowledge(service)), 2)
468
+ self.assertIn(
469
+ "贝塔 项目负责人更新为乙。",
470
+ "\n".join(record.memory.body for record in self.knowledge(service)),
471
+ )
472
+ self.assertEqual(
473
+ {
474
+ tuple(record.memory.scopes): record.memory.body
475
+ for record in self.knowledge(service)
476
+ }[("project:beta",)],
477
+ "贝塔 项目负责人更新为乙。",
478
+ )
479
+
480
+ def test_scope_not_grounded_final_retry_preserves_unregistered_project_scope(self):
481
+ backend = QueueBackend()
482
+ service = self.service(backend, name="scope-final-unregistered-legal")
483
+ config = service.vault.config()
484
+ config["scopes"] = {
485
+ "project:alpha": {"aliases": ["阿尔法"]},
486
+ }
487
+ save_config(service.vault.config_path, config)
488
+ old = service.create_memory(
489
+ memory_id="alpha-existing",
490
+ title="阿尔法 项目状态",
491
+ body="阿尔法 项目状态保持不变。",
492
+ type="project",
493
+ scopes=["project:alpha"],
494
+ )
495
+ user_key, _ = self.capture_turn(
496
+ service,
497
+ turn="scope-final-unregistered-legal",
498
+ user_event="scope-final-unregistered-legal-user",
499
+ assistant_event="scope-final-unregistered-legal-assistant",
500
+ user="阿尔法项目状态不变;浙江东方实施计划待确认。",
501
+ assistant="已确认两个项目的状态。",
502
+ )
503
+ wrong = self.candidate(
504
+ "new-unregistered-final-retry",
505
+ [user_key],
506
+ memory="浙江东方实施计划待确认。",
507
+ type="project",
508
+ update_memory_id=old.memory_id,
509
+ )
510
+ wrong["scopes"] = ["project:浙江东方"]
511
+ backend.responses.extend(
512
+ [
513
+ self.gate([wrong]),
514
+ self.gate([wrong]),
515
+ self.gate([wrong]),
516
+ self.summary(
517
+ user_key,
518
+ title="浙江东方实施计划",
519
+ body="浙江东方实施计划待确认。",
520
+ type="project",
521
+ scopes=["project:浙江东方"],
522
+ ),
523
+ ]
524
+ )
525
+
526
+ result = service.process(source="codex", session_id="s", model=backend)
527
+
528
+ self.assertEqual(result["processed_turns"], 1)
529
+ self.assertEqual(result["memories_written"], 1)
530
+ self.assertEqual([call["purpose"] for call in backend.calls], ["gate", "gate", "gate", "summarize"])
531
+ self.assertIn("Previous output violated: target_not_relevant.", backend.calls[1]["prompt"])
532
+ self.assertEqual(service.read(old.memory_id).body, old.body)
533
+ self.assertEqual(len(self.knowledge(service)), 2)
534
+ unregistered = [
535
+ record
536
+ for record in self.knowledge(service)
537
+ if record.memory.scopes == ["project:浙江东方"]
538
+ ]
539
+ self.assertEqual(len(unregistered), 1)
540
+ self.assertEqual(unregistered[0].memory.body, "浙江东方实施计划待确认。")
541
+
269
542
  def test_summary_scope_drift_retries_before_writing(self):
270
543
  backend = QueueBackend()
271
544
  service = self.service(backend, name="summary-scope-drift")
@@ -104,7 +104,7 @@ class StageC1MCPTest(unittest.TestCase):
104
104
  testcase.assertEqual(result["resultType"], "complete")
105
105
  testcase.assertEqual(
106
106
  result["_meta"]["io.modelcontextprotocol/serverInfo"],
107
- {"name": "memleaf", "version": "0.2.9"},
107
+ {"name": "memleaf", "version": "0.2.10"},
108
108
  )
109
109
  return result
110
110
 
@@ -24,7 +24,7 @@ class StageC3PackagingTests(unittest.TestCase):
24
24
 
25
25
  def test_project_metadata_and_version_export(self) -> None:
26
26
  self.assertEqual(self.project["name"], "memleaf")
27
- self.assertEqual(self.project["version"], "0.2.9")
27
+ self.assertEqual(self.project["version"], "0.2.10")
28
28
  self.assertEqual(self.project["requires-python"], ">=3.11")
29
29
  self.assertEqual(self.project["dependencies"], [])
30
30
  self.assertEqual(self.project["license"], "MIT")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes